Repository navigation
Description
Summary
handleSessionEnd cleans up the broker by calling loadBrokerSession(input.cwd), but broker.json is stored under a cwd-derived hash (resolveStateDir(cwd)). When the broker was spawned for a different cwd than the session's cwd, the lookup misses, endpoint is null, no broker/shutdown is sent, and the broker is orphaned — even when the session exits gracefully (/quit, SIGTERM).
This is distinct from #108 (which attributes orphans to the hook not firing on crash/kill). Here the hook fires normally (exit 0) but cannot locate the broker.
Why this happens in normal use
The broker cwd is decided by resolveCommandCwd (codex-companion.mjs:149):
options.cwd ? path.resolve(process.cwd(), options.cwd) : process.cwd()→ flows to connect(cwd) → ensureBrokerSession(cwd) → broker --cwd and broker.json location.
A very common workflow breaks this:
- Session starts in the main tree (session cwd =
/repo). - A review runs against a git worktree — either
cd worktree && node companion …ornode companion … --cwd worktree→ broker cwd =worktree. /quit→ SessionEndinput.cwd=/repo→loadBrokerSession(/repo)looks at the/repohash, never finds theworktreebroker.json → broker survives.
Reviewing a worktree's uncommitted working tree requires broker cwd = worktree, so for a main-tree session this mismatch is structural, not accidental.
Reproduction (verified)
# broker registered for cwd=A
ensureBrokerSession("/tmp/probe") # broker --cwd=/tmp/probe, alive
# SessionEnd with matching cwd -> broker is killed (OK)
echo '{"cwd":"/tmp/probe","session_id":"s1","hook_event_name":"SessionEnd"}' \
| node session-lifecycle-hook.mjs SessionEnd # exit 0, broker DEAD
# SessionEnd with mismatched cwd -> hook exits 0 but broker SURVIVES
echo '{"cwd":"/tmp","session_id":"s2","hook_event_name":"SessionEnd"}' \
| node session-lifecycle-hook.mjs SessionEnd # exit 0, broker ALIVE <-- bugObserved in the wild: every live session had cwd in the main tree, while every orphan broker's --cwd pointed at a worktree — a perfect mismatch pattern.
Root-cause fix (preferred)
Decouple broker cleanup from cwd:
- A. Track brokers by session, not cwd-hash. Record the owning
session_idinbroker.json(and/or a global broker index).handleSessionEndresolves brokers byinput.session_idand tears down all of them regardless of cwd. This fixes the graceful-exit mismatch directly. - B. Parent-session liveness watchdog in the broker. The broker records the owning session PID and periodically checks
process.kill(pid, 0); if the owner is gone it self-terminates. This additionally covers SIGKILL / crash / OOM, where SessionEnd never fires at all.
A handles graceful exits; B handles abnormal exits. Together they remove the cwd dependency entirely.
Fallback (defense-in-depth, not a root fix)
An idle timeout (#108) as a last-resort net if both A and B fail. Useful but secondary — it lets brokers linger for the timeout window and masks the real ownership/lookup defect.
Related
- Broker process not cleaned up on session exit — no idle timeout #108 — broker not cleaned up on session exit / no idle timeout (overlapping symptom, different attributed root cause; the idle-timeout there is the fallback above)
- Same-cwd parallel /codex:* races on jobs.json + broker.json: data loss + orphan brokers #286 — same-cwd parallel races on broker.json (opposite case: same cwd)
- Background task hangs forever in "queued" when --cwd is a git worktree (detached worker dies silently; no timeout) #367 — background task hangs in a worktree cwd (worktree-specific, different symptom)
Environment
- Plugin
openai-codex/codexv1.0.4 - Linux; reproduced directly against
session-lifecycle-hook.mjs/broker-lifecycle.mjs
Activity
MountainsCalling-me commentedon Jul 10, 2026
Confirming #380 in the wild, with a
git worktree removeamplifier plus scale/symptom data that may help prioritize.Setup: a repo that cuts a fresh
git worktreeper task and removes it right after merge. Codex reviews run against the worktree working tree, so the broker's--cwdis the worktree path — exactly the cwd ≠ session-cwd mismatch described here.The amplifier: after merge,
git worktree removedeletes the broker's--cwd. So beyond the SessionEnd lookup missing (this issue), the broker is now keyed to a path that no longer exists — it can never be located for shutdown and never be reused (ensureBrokerSessiononly reuses a reachable endpoint for the same cwd-hash, and that cwd is gone). Every merged worktree leaves a permanent orphan, recurring on every task.Scale observed (macOS, plugin v1.0.4, ~26 days): 98 orphaned
app-server-broker.mjs serve+ 375codex app-serverprocesses, all holding~/.codex/logs_2.sqliteopen → the logs DB had grown to 384 MB with an active WAL.Downstream symptom: once enough orphans accumulate,
adversarial-reviewbegan intermittently dying at startup with:So the accumulation doesn't just waste resources — it eventually breaks the review path (looks like contention on shared per-session state / the logs DB). Killing the orphans and rotating the logs DB cleared it immediately; a fresh run rebuilt a ~2 MB DB.
This reinforces the #108 idle-timeout ask as the right fix: in the deleted-worktree case, neither a corrected cwd-hash lookup nor session-liveness (#450) can help — the owning path is gone — so a broker-side idle self-terminate is the only thing that can reap it.
sufhayes7 commentedon Jul 15, 2026
Corroborating on Linux (plugin v1.0.4, worktree-per-task workflow,
task --cwd <worktree>background jobs rather than reviews): 69 orphanedapp-server-broker.mjsprocesses accumulated over a few weeks, every one keyed to a since-removed worktree path — same mismatch pattern as above. Two additions to the thread:1. The deleted-worktree case is still reachable for graceful shutdown — idle timeout is not the only remedy.
This turns out not to be so:
resolveStateDirfalls back to hashing the raw path string when the directory no longer resolves (realpathSync/git-repo resolution fail), soloadBrokerSession(<deleted-path>)still findsbroker.jsonand the broker can be shut down cleanly. Verified directly: spawned a broker viaensureBrokerSession(dir),rm -rf dir, then ranloadBrokerSession(dir)→sendBrokerShutdown(endpoint)→teardownBrokerSession({..., killProcess: terminateProcessTree})→clearBrokerSession(dir)— the broker exits and state is cleaned, directory long gone. (That sequence, targeted at the workdir instead of the session cwd, is exactly whathandleSessionEndalready does; it's how we're working around this today, run right beforegit worktree remove.)So fix A (session-scoped broker registry) would cover deleted worktrees on graceful exit, and an idle timeout is defense-in-depth for crash paths rather than the only option.
2. Feature ask: a supported caller-facing shutdown verb.
The companion CLI currently has no way to stop a broker (
cancelinterrupts the turn and kills the job worker's process tree, but the broker is spawneddetached+unref()and isn't in it). Callers that manage worktrees know exactly when a workdir's broker becomes garbage — atgit worktree remove— but the only way to act on that today is importinglib/broker-lifecycle.mjsinternals as above. A supported(or
broker stop) would make deterministic cleanup possible for any worktree-based workflow, independent of — and complementary to — the SessionEnd-side fixes proposed here.axisrow commentedon Jul 16, 2026
+1. Corroborating the mismatch pattern in normal use on macOS v1.0.6 — among the ~260 orphaned
app-server-broker.mjs(allPPID=1) I just cleaned up, the survivors consistently pointed at socket/cwd dirs (cxc-*) different from the session cwd, exactly the "hook fires but can't locate the broker" class described here rather than the never-fires case in #108. Decoupling cleanup from the cwd-hash (option A) + a parent-session liveness watchdog (option B) would cover both. The idle-timeout in #490 is a useful net for the survivors, but as you note it only masks the lookup defect.axisrow commentedon Jul 18, 2026
Correction on the "
PPID=1" qualifier I used above — it's the wrong discriminator for the broker process, as @GeoffreyDjof lays out in #108 (and I've since confirmed on my own box): becausespawnBrokerProcessusesdetached: true+unref()and the spawner (codex-companion.mjs) is a one-shot that exits immediately, a broker is reparented to launchd at birth. A perfectly healthy broker serving a live session readsPPID=1. So "PPID=1" alone does not separate leaked from active — SIGTERM'ing that cohort would kill in-flight sessions.The ~260 count in my earlier comment is still real, but the proof has to come from elsewhere: the survivors pointed at
cxc-*socket/cwd dirs whose--cwdno longer exists on disk (a deleted worktree can't be under review by anyone — false positives impossible, and it reads--cwdoff the process cmdline so it doesn't depend on PID liveness at all). That's the same sound signal @GeoffreyDjof converges on. The mismatch/lookup defect this issue is about is unchanged; just flagging theppidframing in my prior note as unreliable.h2suzuki commentedon Aug 23, 2026
Still present in 1.0.6, and I have field data plus two sub-mechanisms that I don't think are on this issue yet.
The lookup key is the git repository root, not the cwd
This sharpens the framing here, and it explains why some "different cwd" cases don't leak.
resolveStateDirruns the cwd throughresolveWorkspaceRoot→ensureGitRepositorybefore hashing (lib/state.mjs:29-40,lib/workspace.mjs:3-9), so the key is the repository root of the cwd.Measured on 1.0.6 with a real linked worktree:
Consequences:
Reproduction, extending yours — no live broker needed. Plant a
broker.jsonwith"pid": nullunder the worktree's state dir and drive the hook directly:exit=0, no output, record still presentexit=0, no output, record removedexit=0, no output, record removed, directory still thereCases 1 and 2 are byte-identical in exit status and output, which is the observability half of this bug (also reported as #636).
Sub-mechanism A: the env-var fallback could not kill even if it were reachable
handleSessionEndfalls back toCODEX_COMPANION_APP_SERVER_ENDPOINTwhen the lookup misses (session-lifecycle-hook.mjs:87-93). That object carries onlyendpoint,pidFileandlogFile— nopidand nosessionDir.teardownBrokerSessionguards its kill withNumber.isFinite(pid)(lib/broker-lifecycle.mjs:174), so this path can only send a shutdown request; it can never terminate the process.#636 separately establishes that the variable is never exported to hooks, so the branch is dead code today. Both facts point the same way: it should either carry
pid/sessionDiror be removed, because as written it cannot finish the job it exists for.Sub-mechanism B: the record is cleared whether or not the broker died
Inside
teardownBrokerSessiononly the kill is conditional (:174). Everything that makes the process findable is removed unconditionally: pid file (:182-184), log (:186-188), socket (:190-199), session dir (:201-207). ThenhandleSessionEndcallsclearBrokerSession(cwd)unconditionally (:113).So on any path where the kill does not happen — no
pidin the record, the fallback above, a signal that does not land — the end state is a live broker whose pid file, socket and record have all been deleted. Record-driven cleanup can never reach it, and neither can an age-based sweep, because the files it would inspect are gone. This is the same defect one call-site over from the one PR #566 addresses.Suggested ordering: clear the record last, and only once the process is confirmed gone. A stale record is recoverable; a missing one is not.
Field data
Two machines, both on 1.0.5/1.0.6:
The distribution on machine B matches this issue's thesis exactly: 121 of 121 leftover records were keyed to a short-lived working directory, and not one was keyed to a long-lived repository root. The eight clean state directories were the repository roots and the worktrees where sessions had actually ended.
The three record-less live brokers on machine B are sub-mechanism B's end state observed in the wild. I can't trace what removed their records, so I'm offering that as an observed end state rather than a causal claim.
Reclaiming them, 8 of 8 stopped on the
broker/shutdownRPC alone — noSIGTERMneeded. Worth noting for any reaper: going straight to a forced kill leaves the socket and the record behind and creates more of the orphans described above.For cross-reference, the self-termination half of this (no idle timeout, no parent-death watch; 1.0.6's
app-server-broker.mjsis 252 lines with zero occurrences of idle/ppid/parent/watchdog/heartbeat) is #108 / #543, with PR #490 proposing the idle timer.kasnol commentedon Oct 4, 2026
Adding an impact data point not yet on this issue (plugin 1.0.5, macOS). Each leaked broker keeps its
codex app-serverchildren and their 64 KiB pipe buffers. Once enough accumulate (~50 idle brokers holding ~24 MiB of 64 KiB pipe buffers in our case), the kernel's global pipe-buffer limit is exceeded and every new pipe on the host stays at 512 bytes. Unrelated programs that write more than a few hundred bytes into a pipe that isn't drained concurrently then block inwrite()and hang. Sending the plugin's own gracefulbroker/shutdownto the idle leaked brokers restored normal 64 KiB pipes immediately.pdrivom commentedon Oct 11, 2026
Another data point, still reproducing on 1.0.4 (and the cleanup path is unchanged in 1.0.6):
/codex:adversarial-review --cwd <git worktree of a child repo>run many times a day, worktrees removed after each task.app-server-broker.mjs servetrees accumulate with ppid 1 — 68 brokers, up to 13 days old, ~2,900 processes on 2026-10-03, and 63 brokers, 1–4 days old on 2026-10-11. Each holds acodex app-serverplus its MCP children. macOS hitskern.maxprocperuid(2666) and every unrelated command fails withfork failed: resource temporarily unavailable; memory pressure makes the machine unusable before that.--cwddirectory has since been deleted are never reused either, so nothing ever reaches them.Workaround we run until one of the idle-timeout PRs (#680, #580, #457, #652) lands: a launchd job every 5 minutes that kills a broker tree when its
--cwdno longer exists, or when it is >20 min old, used <1 s CPU since the previous pass, and nocodex-companion.mjsprocess is running. An in-broker idle timeout would make that unnecessary.skaffen commentedon Oct 20, 2026
Confirming this on 1.0.6 (codex-cli 0.162.1, Node 24.22, macOS 27 / Apple silicon, 16 GB). Here it caused a kernel watchdog panic.
Git worktrees trigger this all the time. Each linked worktree resolves to its own workspace root, so it gets its own
broker.jsonand its own broker. A common workflow starts Claude Code at the repo root and has review agents runcodex-companion.mjs reviewfrom inside.worktrees/<branch>.SessionEndthen callsloadBrokerSession(input.cwd)on the repo root, finds no broker (or only the root's), and every worktree broker is left running. 141 of my 154 state dirs under$CLAUDE_PLUGIN_DATA/state/still have abroker.json, almost all named after worktrees. Reboots inflate that count, but it shows the pattern.Impact. Every leftover broker keeps a
codex app-serverrunning with all of the user's configured MCP servers (for me:npx @playwright/mcp@latest,node_repl,codex-code-mode-host). The macOS jetsam report from just before the panic showed 28 of these trees, about 300 processes holding roughly 10 GB resident, some of them 5+ days old. When a heavy test run started on top of that, the compressor filled up andwatchdogdstopped checking in.Minimal repro (no model calls):
With the session cwd set to the worktree instead, the same hook stops the broker.
Possible fix: record each broker the session creates in session-scoped state, keyed by
CODEX_COMPANION_SESSION_ID(it's already exported atSessionStart), not only per workspace. ThenSessionEndcan tear down all of them whatever its own cwd is. An idle timeout in the broker (#543) would cover the cases whereSessionEndnever fires (#605).Workaround I'm using for now: a user-level
SessionEndhook that sends SIGTERM to any runningapp-server-broker.mjswhose--cwdappears in the ending session's transcript (including subagent transcripts), or whose--cwdno longer exists. It reads the live process list rather thanbroker.json, so it avoids #743. It does have the trade-off in #803.davidomisi commentedon Oct 20, 2026
Same on 1.0.6 / Linux: sessions start in
~, reviews run in repos and worktrees, so SessionEnd never findsbroker.json. Found 5 live brokers (oldest 17 days) and 16 stale records.Workaround that has held up: a SessionStart hook that kills a broker when the
CODEX_COMPANION_SESSION_IDin its env matches no~/.claude/sessions/<pid>.jsonwith a live pid and its unix socket has no connected peer (/proc/net/unix, state 03). The second check matters because the broker is shared per workspace (#803). It also removesbroker.jsonrecords whose pid is no longer a broker (#743). Recording the owning session ID inbroker.jsonand running this check on SessionStart would cover #380, #605 and most of #543.wtrchmr commentedon Oct 24, 2026
Another data point on 1.0.6 / macOS, same root cause.
Workflow: I always start Claude Code in my home directory and
cdinto different project repos during a session before running/codex:reviewor/codex:rescue. Each project gets its own broker (state/<project>-<hash>/broker.json), butSessionEndonly looks upinput.cwd(the home directory), so none of them are ever shut down.What accumulated in about 7 days: 5 live brokers, idle for 49–158 hours, with 0 queued/running jobs. Each one held a
codex app-server, and each app-server kept one stdio MCP server per thread. One of my MCP servers is an SSH bridge, so that alone came to 46ssh-mcpprocesses, each holding an SSH connection to the same host.On working around it outside the plugin: I tried an external reaper (stop brokers with no jobs, idle for over 6 hours, and not answering busy on a probe RPC). A Codex review of it pointed out that a client can still start a request between the last check and the SIGTERM, and that
broker.jsoncan be replaced between being read and being renamed. A reaper outside the broker can't close those races, so I went back to monitoring and cleaning up by hand.An upstream fix — tracking which brokers a session started and shutting those down at
SessionEnd, or an idle timeout in the broker itself as proposed in #543 — would be much safer than anything users can do from outside.