Repository navigation
Description
Problem
A foreground companion run that is SIGKILLed by Claude Code's Bash tool (~120s ceiling) leaves the underlying Codex turn running inside the detached broker, still able to modify the workspace. The job is then reconciled to failed, and /codex:cancel refuses to touch it — so the orphaned turn cannot be stopped through the plugin at all.
This is not introduced by #41. It is reachable on main today. Verified against 15f22e6: the broker's socket-close handler, the /cancel status filter, and the missing turnTimeoutMs on the adversarial-review path are all identical there. #41's idle semantics widen the window for actively-progressing turns, but every link in the chain predates it.
Failure chain
-
Foreground runs the turn in-process.
handleTask(plugins/codex/scripts/codex-companion.mjs:905-922) andhandleReviewCommand(:828-843) both go throughrunForegroundCommand(:744-755). Onlytask --backgroundspawns a detached worker (spawnDetachedTaskWorker,:757-768). NotehandleReviewCommandparses--background(:801) but never consultsoptions.background— for reviews the flag only raises the idle budget viaresolveTurnTimeoutMsFromOptions(:726), it does not background anything. -
An active turn never trips the internal deadline.
armIdleDeadline(plugins/codex/scripts/lib/codex.mjs:713-726) resets on every lifecycle and in-item progress notification, so a healthy long-running foreground turn sails past the host's Bash ceiling. -
SIGKILL leaves no trace. There are no signal handlers anywhere in
plugins/codex/scripts/, andrunTrackedJob(plugins/codex/scripts/lib/tracked-jobs.mjs:194-226) has nofinally, so the job record staysstatus: "running". -
The broker never interrupts.
socket.on("close")/socket.on("error")(plugins/codex/scripts/lib/broker-controller.mjs:353-370) call onlysockets.delete+clearSocketOwnership(:149-157) +scheduleIdleShutdown. Noturn/interruptis issued; the app-server child keeps executing the turn. -
Notifications are dropped and the broker reports itself idle.
routeNotification(:159-175) returns early once ownership is cleared, andscheduleIdleShutdownarms the 15-minute timer — soprobeBrokersees an idle broker while a turn is live, and a reuse path may even shut it down mid-turn. -
Reconciliation marks the job
failed.reconcileRunningJobs(plugins/codex/scripts/lib/state.mjs:151-196) lazily flipsrunning→failedwithUNREPORTED_PROCESS_EXIT_MESSAGEon the nextlistJobs. -
/cancelcan no longer reach it.resolveCancelableJob(plugins/codex/scripts/lib/job-control.mjs:326-353) callslistJobs(which reconciles first) and then filtersstatus === "queued" || "running". The job is alreadyfailed, sohandleCancelthrows before ever reachinginterruptAppServerTurn(plugins/codex/scripts/lib/codex.mjs:1166).
Why the fix is cheap
Everything needed for recovery is already in place:
createJobProgressUpdater(plugins/codex/scripts/lib/tracked-jobs.mjs:89-97) persiststhreadIdandturnIdincrementally, as soon as each is observed — so a killed job's record already carries valid ids.interruptAppServerTurnpassesallowBusyStaleBroker: true(plugins/codex/scripts/lib/codex.mjs:1186-1188), so it can attach to a busy broker.- The broker already has a dedicated bypass for interrupting a stream owned by another socket (
plugins/codex/scripts/lib/broker-controller.mjs:298-323), and it tracksactiveStreamThreadIds(:99,:334).
The machinery exists; only the reachability gate is missing.
Proposed fixes (independent, both worth doing)
- Broker-side (defense in depth). In
socket.on("close")/on("error"), if the closing socket wasactiveStreamSocket, issueturn/interruptfor the active stream before clearing ownership. This needs the turn id as well as the thread id —buildStreamThreadIdscurrently retains only thread ids, so it would need to captureturnIdtoo. - Cancel-side (user-facing). Let
/cancelreach a job thatreconcileRunningJobsjust markedfailedwithUNREPORTED_PROCESS_EXIT_MESSAGEand a non-nullthreadId/turnId, sointerruptAppServerTurnbecomes reachable. Either widen the filter atjob-control.mjs:328or introduce a distinct terminal state (e.g.orphaned) that cancel accepts. - Companion-side (partial). Install
SIGTERM/SIGINThandlers that interrupt the in-flight turn. Does not help against SIGKILL, but covers ordinary termination.
Related
- adversarial-review ignores --turn-timeout-ms — turnTimeoutMs not forwarded to runAppServerTurn #38 / fix: thread turn-timeout-ms into adversarial-review's app-server turn #39 —
adversarial-reviewdropsturnTimeoutMs(codex-companion.mjs:465), so it runs with the 600000ms default even in the foreground. That is today's single worst exposure to this bug, but it is tracked separately and should not be folded in here. - Turn timeout should measure agent-idle time, not total turn wall-clock #40 / fix: measure turn timeout as agent idle time, not total wall-clock #41 — the idle-timeout redesign. It makes actively-progressing foreground turns far more likely to outlive the Bash ceiling, which is what surfaced this issue, but it does not create any link in the chain above.
Verification
/codex:statusmust not report an idle broker while a turn is live.- After a kill that orphans a turn,
/codex:cancel <id>must successfully interrupt it.
Activity
fix: let /codex:cancel reach turns orphaned by a SIGKILLed companion