Skip to content

fix: release idle companion locks and recover broker startup - #836

Open
pecord-ent wants to merge 16 commits into
openai:mainfrom
pecord-ent:pecord/companion-broker-cleanup
Open

pecord-ent wants to merge 16 commits into
openai:mainfrom
pecord-ent:pecord/companion-broker-cleanup

Conversation

@pecord-ent

@pecord-ent pecord-ent commented Nov 4, 2026 •

Copy link
Copy Markdown

Completed companion conversations can stay impossible to archive because detached app-servers retain their writer locks after the caller exits. Five affected conversations originally reproduced the problem; archiving through their owning servers succeeded. The same workaround cleared four more during the latest cleanup.

The broker now exits after 60 seconds (CODEX_COMPANION_BROKER_IDLE_TIMEOUT_MS) with no connected clients, pending RPCs, or running turns, which releases the app-server and its writer lock. Saved conversations remain resumable through a replacement broker. The exit has to preserve these invariants:

  • Work outlives its caller. A turn keeps the broker alive after its client disconnects. A turn that ends with a final answer and no turn/completed is released the way lib/codex.mjs completes it: after the thread's pending collaborations drain (one grace timer per turn, restarted by each drain), and a resumed thread does not inherit an interrupted turn's collaboration.
  • No client reaches a draining broker. The listener closes before the app-server close, which can take seconds; a replacement admitted during it would fail its first RPC. Reuse-existing callers check that the saved endpoint is reachable.
  • Concurrent cleanup is harmless. An exiting broker and a replacement's teardown both remove the pid file and socket; removal ignores files the other side already took.
  • The saved pid never outlives the broker. On exit the broker removes broker.json when it still names its own endpoint, so SessionEnd cannot signal a pid the OS has reused, and a replacement's state is kept. The check and the delete are not atomic: a replacement publishing between them loses its broker.json, which costs one extra broker that idles out.

Not included: serializing broker startup across processes. Simultaneous first commands can still each start a broker; the extras exit after the idle interval.

Validation: lifecycle suite 12 passed; the eight final-answer, collaboration, resume, drain, cleanup-race and saved-pid tests fail on the previous head. Full suite: 99 passed, 3 failed (status and result runtime tests), which fail identically on the unmodified previous head in a fresh checkout. TypeScript build passes. Windows and Linux runtime validation is outstanding.

@pecord-ent
pecord-ent requested a review from a team November 4, 2026 03:24
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Nov 4, 2026 •

Copy link
Copy Markdown
Codex Review Summary: ✅ Completed

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-11-12T19:03:30.789540Z 1860c00 Manual request
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 37cfcaa5a0

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@agammann agammann left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One broker startup recovery issue.

Idle shutdown now stops admitting clients before the slow app-server close,
so a command arriving mid-drain starts a replacement broker instead of
failing its first request. Reuse-existing callers verify the saved endpoint
is reachable and fall back to a direct app-server when the broker expired.
Startup lock recovery treats partial owner JSON like an empty lock (age
check), and lock release no longer throws if the lock was reclaimed.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 65b1926b4b

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread tests/broker-lifecycle.test.mjs Resolved
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
@pecord-ent

Copy link
Copy Markdown
Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 0f1a5f9609

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread tests/broker-lifecycle.test.mjs Resolved
…nore zombies in test teardown

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: eb7e0cfc6f

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 7bfff43d43

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread tests/broker-lifecycle.test.mjs Resolved
@pecord-ent
pecord-ent requested a review from agammann November 6, 2026 23:35
…perl

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
@pecord-ent

Copy link
Copy Markdown
Author

@agammann - ran into this bug again locally where I couldn't archive out some old chat sessions - it's not a big deal but it reminded me to go ahead and update the PR based on comments and to clean it up - let me know if there is anything else you'd like to see

@pecord-ent pecord-ent changed the title fix: release idle companion conversation locks fix: release idle companion locks and recover broker startup Nov 9, 2026

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 36b93a876e

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

…onEnd cleanup, judge reclaim guard by owner liveness

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 555ec0c746

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@pecord-ent

Copy link
Copy Markdown
Author

@codex review

…ty collaboration sets

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Hooray!

Reviewed commit: 555ec0c746

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

…ends

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
@pecord-ent

Copy link
Copy Markdown
Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 3bd48081da

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread tests/broker-lifecycle.test.mjs Resolved
URL.pathname keeps %20 escapes and a leading slash before Windows drive
letters, so the session-end e2e test could not spawn the hook from a
checkout path containing spaces.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
pecord-ent and others added 2 commits November 9, 2026 16:02
…ecting

An empty write callback plus a 50ms sleep did not prove the broker had
registered the request, so a slow runner could disconnect first and test
idle expiry instead of pending-request retention.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Keep the idle timer with its active-turn and pending-RPC guards and the
saved-endpoint readiness check. Drop the startup lock, reclaim guard,
zombie detection, SessionEnd changes and collaboration tracking: with idle
expiry a duplicate broker from a startup race exits on its own. The full
version lives on pecord/companion-broker-hardening.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 5301a69f67

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

idleTimer = null;
if (shuttingDown || sockets.size || pendingRequests || activeTurns.size) return;
idleTimer = setTimeout(() => {
shutdown(server).then(() => process.exit(0)).catch((error) => {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Invalidate the saved PID when the idle broker exits

On POSIX, if the OS reuses the expired broker's PID for another process-group leader before Claude's SessionEnd, this idle exit leaves broker.json pointing at that unrelated process. The still-unchecked consumer is handleSessionEnd() in session-lifecycle-hook.mjs: it passes the saved PID to teardownBrokerSession() with terminateProcessTree, which sends SIGTERM to that process group even when the broker endpoint is gone. The new endpoint-reachability check only protects connections, not teardown. Clear the matching saved session/PID on idle exit or verify process ownership before terminating it.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 5f2688c: on exit the broker removes broker.json when it still names its own endpoint, so SessionEnd no longer reads the expired pid, and a replacement's state is kept. Tests: an expired broker leaves no saved session; clearing for a different endpoint keeps the replacement's.

@agammann agammann left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Requesting changes on head 5301a69. Four lifecycle issues remain in the current code:

  • Idle exit leaves broker.json with the expired PID. SessionEnd can later pass that PID to terminateProcessTree after POSIX has reused it for an unrelated process group. Please clear the matching saved session on exit or verify ownership before signaling it. Current finding.
  • shutdown() still awaits appClient.close() before closing the listener, so a replacement request can connect to a draining broker and fail on its first real RPC. Earlier finding is present again.
  • activeTurns is cleared only by turn/completed, but the supported final_answer inferred-completion path can finish without that notification. The broker then retains the writer lock indefinitely. Earlier finding is present again.
  • Concurrent idle cleanup and replacement still use existsSync() followed by unguarded unlinkSync() for the same files; the loser can throw ENOENT and fail startup. Earlier finding is present again.

The old SessionEnd lock-wait comment no longer applies after the lock code was removed.

@iZeeuXx

iZeeuXx commented Nov 11, 2026

Copy link
Copy Markdown

Tested this patch (the three plugins/codex/scripts/ files only) on top of the published 1.0.6 on macOS (Apple Silicon, Node 24.15).

Before: after about a week of normal use (reviews from several git worktrees plus unattended claude -p runs) I had 44 leftover brokers: 130 codex app-server processes, about 663 MB RSS, the oldest almost 7 days old. Terminating the stale broker trees closed 375 processes in total. This matches #791 and #801.

After: I started a broker by hand with CODEX_COMPANION_BROKER_IDLE_TIMEOUT_MS=15000 and no clients. It started its app-server child, then exited on its own after the timeout and took the child with it. The app-server count went back to the baseline.

Thanks for the fix. It would be great to see it in a release, since 1.0.6 is still the latest.

…ear saved pid on exit

The trim to the idle-shutdown fix dropped four behaviours the idle exit
depends on:

- An idle broker that kept its listener open while the app-server closed
  admitted a replacement client that then failed its first RPC. Stop
  accepting before the close.
- A turn finished by its final answer alone (no turn/completed) pinned
  the broker, and so the conversation's writer lock, forever. Mirror the
  client's inferred completion, including waiting for pending
  collaborations and not letting a resumed thread inherit an interrupted
  turn's.
- Cleanup used existsSync then unlinkSync on files a replacement's
  teardown removes concurrently; the loser threw ENOENT. Use rmSync with
  force.

An expired broker also left broker.json pointing at its pid, which
SessionEnd passes to terminateProcessTree after the OS may have reused
it. The broker now clears the saved session on exit, only when it still
names this broker's endpoint, so a replacement's state is kept.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
@pecord-ent

Copy link
Copy Markdown
Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 5f2688cba8

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +95 to +98
function settleFinalAnswer(turnKey, threadId) {
if (pendingCollaborations.get(threadId)?.size) return;
setTimeout(() => {
if (pendingCollaborations.get(threadId)?.size || !finalAnswered.has(turnKey)) return;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reset the final-answer timer after each collaboration drain

When a final-answer-only turn's collaborations drain repeatedly within 250 ms, each call leaves the previous timeout armed, unlike scheduleInferredCompletion() in lib/codex.mjs. An older timeout can remove the turn between collaborations before the latest grace period expires; a collaboration starting in that remaining window is then unprotected after the caller disconnects, allowing idle shutdown to terminate unfinished work. A Linux fake-server reproduction with drains at 200/350 ms and another collaboration starting at 500 ms closed the app-server at approximately 700 ms with a 200 ms idle timeout, before that collaboration's 1200 ms completion; runAppServerTurn() correctly waited. Keep one resettable inference timer per turn.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 1860c00: settleFinalAnswer cancels the turn's previous timer before arming a new one, so repeated drains share one grace period like scheduleInferredCompletion(). Test: collaborations drain at 100 and 200 ms and a third starts at 400 ms; without the fix the first timer releases the turn at ~350 ms and the app-server closes while the third is running, with the fix it stays up until that collaboration completes.

Comment on lines +102 to +103
if (loadBrokerSession(cwd)?.endpoint === endpoint) {
clearBrokerSession(cwd);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Coordinate saved-session ownership checks with deletion

When a command replaces an idle broker while it is draining, the replacement can publish its state between loadBrokerSession() and clearBrokerSession(). The exiting broker then deletes the replacement's broker.json despite the endpoint comparison. Pausing the real detached broker immediately before this removal reproduced a null saved session while the replacement remained alive. Subsequent ensureBrokerSession() calls start another app-server instead of reusing it, and the normal lazy-broker SessionEnd path loses the saved reference needed to stop the replacement. Serialize conditional deletion with state publication, or use an ownership-safe state layout.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not changing this one. The window is the gap between a synchronous loadBrokerSession and the rmSync in clearBrokerSessionIfCurrent, and it only matters when a replacement publishes inside it. The result is a lost broker.json, so the next ensureBrokerSession starts one extra broker, and the unreferenced one exits after its 60 s idle timeout. Closing the window needs an atomic take-and-restore (rename, compare, relink) or a locked state layout, which this PR dropped from scope in 5301a69. Happy to take it as a follow-up if you want it.

…rain

Each drain armed a new 250ms timeout without cancelling the previous one,
so an older timeout could release the turn before the latest grace period
ended. A collaboration starting in that window was then unprotected once
the caller disconnected, and idle expiry could close its app-server.
lib/codex.mjs keeps a single resettable timer per turn; the broker now does
the same.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
@pecord-ent

Copy link
Copy Markdown
Author

@codex review

@agammann agammann left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I rechecked head 1860c00 against my earlier review. The listener now closes before the app-server, the final-answer path tracks pending collaborations and resets its grace timer, cleanup uses forceful removals, and idle exit clears the matching saved endpoint. The added tests exercise those paths. I have not run them locally.

The ownership race raised in the later Codex review remains: a replacement can publish between the read and delete, losing its saved session. The stated 60-second cleanup bound only applies once that replacement is idle; an active turn or connected client keeps it alive, and SessionEnd can no longer find it through broker.json. I understand that a coordinated state layout was removed from this PR's scope, but I would retain this as an explicit follow-up rather than treat cleanup as guaranteed. AI-assisted review.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 1860c00e0e

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +104 to +107
if (pendingCollaborations.get(threadId)?.size) return;
const timer = setTimeout(() => {
settleTimers.delete(turnKey);
if (pendingCollaborations.get(threadId)?.size || !finalAnswered.has(turnKey)) return;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Wait for active subagents before settling the parent turn

For a disconnected caller whose main thread emits final_answer while a subagent is still active, this predicate can retire the parent turn before that subagent finishes; scheduleInferredCompletion() in lib/codex.mjs:378-389 explicitly waits for activeSubagentTurns as well. A parent collaboration starting after the 250-ms timer is then unprotected because the parent has already been removed from both activeTurns and finalAnswered. The last subagent's completion consequently makes the broker idle despite that pending collaboration. On Linux with a 200-ms idle timeout, I reproduced shutdown at approximately 900 ms for a collaboration running from 400 to 1500 ms, while runAppServerTurn() with the same events correctly waited and succeeded. Keep the parent's guard until its active subagents drain, then start the inference grace period.

Useful? React with 👍 / 👎.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants