fix: multi-session broker and state lifecycle bugs#439
Conversation
- SessionEnd skips broker teardown while another session has active jobs, reads job state directly (fails closed on unreadable state), and deletes its own jobs' artifacts explicitly - saveState/pruneJobs never delete or evict a queued/running job's files, so a concurrent worker's output survives a stale-snapshot write - broker.json written atomically (tmp+rename), matching state.json
The broker kept answering health probes locally after its child exited, so the next turn failed with a bare 'connection closed'. Now the broker watches its child's exit and shuts itself down (probes fail with ECONNREFUSED, which the retry path handles), and the client classifies 'connection closed' as a broker failure worth a direct retry. An intentional-shutdown flag keeps a graceful broker/shutdown from racing the exit handler into exit(1).
terminateProcessTree used process.kill(-pid); pid 0 signals the caller's own group and pid <= -1 arbitrary groups. Guard to a concrete child pid.
- global after() hook tears down brokers started in temp workspaces - setup tests run in a temp cwd instead of the repo root - regression tests for SessionEnd broker preservation and setup isolation
14da37e to
2ff996d
Compare
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 2ff996dd0c
ℹ️ About Codex in GitHub
Codex has been enabled to automatically 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 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
| const shouldRetryDirect = | ||
| (client?.transport === "broker" && error?.rpcCode === BROKER_BUSY_RPC_CODE) || | ||
| (brokerRequested && (error?.code === "ENOENT" || error?.code === "ECONNREFUSED")); | ||
| (brokerRequested && (error?.code === "ENOENT" || error?.code === "ECONNREFUSED")) || | ||
| brokerConnectionClosed; |
There was a problem hiding this comment.
Avoid retrying mid-flight broker turns
When a brokered request has already been forwarded and the broker socket then closes (for example the child app-server crashes during turn/start or review/start), this new brokerConnectionClosed branch makes withAppServer re-run the entire fn against a direct app-server. Those requests can already have created a turn or started file-modifying work before the disconnect, so retrying here can launch a duplicate turn instead of surfacing the lost connection; the direct retry should be limited to connect/initialize failures or otherwise prove no request was in flight.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
@chatgpt-codex-connector Good catch. I am considering narrowing this so a bare connection closed only falls back to direct when no app-server request has been sent yet, and otherwise surfaces the lost broker connection instead of replaying thread/start, turn/start, or review/start.
Does that match the safety boundary you intended, or do you see a better way to handle a broker child death between turns without risking duplicate side effects?
- Codex
There was a problem hiding this comment.
Note
To use Codex here, create a Codex account and connect to github.
There was a problem hiding this comment.
Implemented in 3649797: the client now tracks whether any non-initialize app-server request was sent on the broker connection, and bare connection closed only takes the direct fallback before that point. Once thread/start, turn/start, review/start, etc. has been attempted, the original connection loss is surfaced instead of replaying the whole operation direct.
Added a regression where a stub broker acknowledges thread/start, receives turn/start, then drops the socket; the task fails and the fake direct app-server is never started.
Verified:
-
node --test --test-name-pattern "task does not retry direct" tests/runtime.test.mjs -
node --test tests/runtime.test.mjs(64/64) -
node --test tests/commands.test.mjs tests/render.test.mjs tests/state.test.mjs tests/bump-version.test.mjs tests/git.test.mjs tests/process.test.mjs tests/broker-endpoint.test.mjs(29/29) -
Codex
Fixes a family of bugs that appear when more than one Claude Code session shares
a project directory. The broker and state files are keyed by cwd, so sessions
coordinate through shared processes and files — with no coordination primitive.
What this fixes
ending shut down the cwd-shared broker with no ownership check, killing other
sessions' running jobs and leaving records stuck at
"running". Teardown isnow skipped while another session has active jobs; SessionEnd reads job state
directly (fails closed if unreadable) and removes only its own artifacts.
probes passed locally, so the next turn failed with a bare
connection closedin a pass/fail/pass/fail pattern. The broker now exits when its child exits,
and the client treats
connection closedas a broker failure worth a retry.live workspace state (a live repo-root broker failed five unrelated tests).
Fixed with a global broker-cleanup hook and temp-cwd isolation.
queued/running job's files,
broker.jsonis written atomically, andterminateProcessTreeno longer group-killspid <= 1.This ships the safe, self-contained fixes. It does not attempt the larger
refactor the pattern calls for (a state-transaction lock and a broker-acquisition
lease) — that's a design change better done as its own PR. The analysis below
explains why.
Tests
node --test tests/*.test.mjs→ 92/92, verified across repeated sequentialruns. (Earlier intermittent hangs were host contention from running multiple
suites at once, not a code issue.) Follow-ups, not in this PR: dedicated
regression tests for the #402 retry path and the
pid <= 1guard.Related: #380, #402, #286, #416.
The core problem — why 19 findings, one cause
codex-plugin-cccoordinates several independent OS processes — interactivesessions, detached background workers, and lifecycle hooks — through shared JSON
files (
state.json,broker.json) and one broker process, all keyed by theworking directory. Nothing serialises them: there is no cross-process lock,
compare-and-swap, or liveness check anywhere in the tree, so every mutation is an
unguarded read-modify-write or check-then-act done as if a single owner held the
files. The
tmp+renamewrites keep individual writes from tearing but never makethe compound operations atomic — which is why the same defect surfaces
repeatedly: concurrent processes clobber each other's records, spawn duplicate
brokers, and delete files or kill pids out from under a peer still using them.
An eight-surface audit (each finding checked by a separate verifier) confirmed 19
multi-session races. They collapse to a few root causes:
state.jsonload → mutate → saveis not atomic across processes, andsaveStatethen deletes job files absent from a possibly-stale snapshotbroker.jsonwritten non-atomicallyBROKER_BUSYhandlingClusters A and B are twelve of the nineteen, and most come down to one thing:
no lock on a shared file.
The bugs in the source — pre-fix code, pinned permalinks
1. SessionEnd shut the broker down unconditionally — no check for other
sessions or their jobs:
📍
plugins/codex/scripts/session-lifecycle-hook.mjs:101-114 @ 86ea7dd3. Setup tests ran in the real repo root, so a live broker for the repo made
them read live state:
📍
tests/runtime.test.mjs:36-45 @ 86ea7ddThe test harness reaped a throwaway
sleepprocess but never the brokers itspawned:
📍
tests/runtime.test.mjs:1560-1570 @ 86ea7ddFull worked write-ups for all four bugs — with more extracts — are in the
dossier
on the
analysis/broker-lifecyclebranch.What the fixes reach, and the refactor they don't
The race and the damage are separable, and the damage is the simpler thing to
fix. This PR targets the destruction, not the race:
broker.jsonatomic write (was missing;state.jsonalready had it).saveState/pruneJobsnever delete a queued/running job'sfiles — a concurrent worker's output survives a stale write.
pid > 1group-kill guard, the Sequential task calls fail ~50% with "codex app-server connection closed" — broker reused after single-turn exit, shouldRetryDirect misses clean close #402 broker self-teardown, and theSessionEnd ownership gate.
The seam the simple fix can't reach:
saveStatestill writes the caller's wholejob array, so a stale write can drop another session's job record (its files
now survive, but the ownership gate can't see it) or resurrect a finished job as
running. Closing that needs the lock, or a merge that knows the caller'sintent. That's the refactor, out of scope here:
load → mutate → save, withcleanupSessionJobsreading fresh state — closes cluster A.cold-start brokers) — closes cluster B.
BROKER_BUSYas serialisation, not transport failure for writetasks — a protocol decision, closes finding 5.
shutdown awareness — the tail.
Items 1–2 are the lock and cover about half; 3–4 are separate mechanisms. One
design change plus a few independent fixes — fewer moving parts than nineteen
tickets, but more than one lever.