You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
When the app-server emits a bare error notification without a following turn/completed, captureTurn never settles state.completion. The awaiting call at lib/codex.mjs:606 hangs indefinitely, the companion process never exits, and the job record is left at status: "running" with phase: "failed" and pid: null.
This is distinct from #391 (Codex drops terminal events) and #598 (connection drops mid-turn): here the app-server delivers a well-formed, fully diagnostic terminal error, and the client discards it as a progress string.
Root cause
lib/codex.mjs:537-540 — the error case records the error and emits progress, then breaks:
completeTurn(state, ...) is only ever reached from case "turn/completed" (:552), so state.error is written but nothing resolves or rejects the promise awaited at :606:
returnawaitstate.completion;
The "failed" argument to emitProgress sets only the cosmetic phase. Job status is never transitioned, so /codex:status reports the contradictory pair running / failed indefinitely.
Deterministic reproduction
Any --base that resolves to a valid object that is not a commit triggers it, because Codex's reviewer runs merge-base internally. Git's empty-tree hash is the convenient case:
[codex] Starting Codex review thread.
[codex] Thread ready (01a04d47-…).
[codex] Codex error: git command `git -c safe.bareRepository=explicit -c core.hooksPath=NUL merge-base <head> 4b825dc…` failed with status exit code: 128: error: object 4b825dc… is a tree, not a commit
fatal: Not a valid commit name 4b825dc…
/codex:status 11 minutes later, with no process still alive:
A usage-limit failure on the same repo minutes later terminated cleanly, because that error arrives as a completed turn with a failure status rather than as a bare error notification:
[codex] Codex error: You've hit your usage limit. …
[codex] Turn failed.
and the record correctly reached "status": "failed" with a completedAt. So the defect is specific to the error-notification path, not to error handling generally.
Treat error as terminal: settle the promise in the error case, preserving state.error as the failure reason, so the job reaches failed and the process exits — for example by calling completeTurn with a synthesized failed turn, or by rejecting state.completion and letting runTrackedJob record the failure it already handles for other error paths.
Separately, handleCancel should transition a job whose pid is null to cancelled rather than returning silently, so a wedged record is recoverable without hand-editing state.
Summary
When the app-server emits a bare
errornotification without a followingturn/completed,captureTurnnever settlesstate.completion. The awaiting call atlib/codex.mjs:606hangs indefinitely, the companion process never exits, and the job record is left atstatus: "running"withphase: "failed"andpid: null.This is distinct from #391 (Codex drops terminal events) and #598 (connection drops mid-turn): here the app-server delivers a well-formed, fully diagnostic terminal error, and the client discards it as a progress string.
Root cause
lib/codex.mjs:537-540— theerrorcase records the error and emits progress, thenbreaks:completeTurn(state, ...)is only ever reached fromcase "turn/completed"(:552), sostate.erroris written but nothing resolves or rejects the promise awaited at:606:The
"failed"argument toemitProgresssets only the cosmetic phase. Job status is never transitioned, so/codex:statusreports the contradictory pairrunning/failedindefinitely.Deterministic reproduction
Any
--basethat resolves to a valid object that is not a commit triggers it, because Codex's reviewer runsmerge-baseinternally. Git's empty-tree hash is the convenient case:Observed — the command never returns:
/codex:status11 minutes later, with no process still alive:The persisted record (
%TEMP%/codex-companion/<workspace-hash>/state.json):{ "id": "review-mtearxsc-xudirz", "status": "running", "phase": "failed", "pid": null, "startedAt": "2026-08-29T11:28:44.096Z", "updatedAt": "2026-08-29T11:29:06.360Z" }updatedAtfreezes 22s in and never advances.Contrast: the
turn/completedpath works correctlyA usage-limit failure on the same repo minutes later terminated cleanly, because that error arrives as a completed turn with a failure status rather than as a bare
errornotification:and the record correctly reached
"status": "failed"with acompletedAt. So the defect is specific to theerror-notification path, not to error handling generally.Knock-on effects
/codex:cancelis a silent no-op. The job haspid: null— nothing was ever spawned — so there is no process to terminate and the handler returns without transitioning status or printing anything. The wedged record can only be cleared by hand-editingstate.json. This is adjacent to/codex:cancelthrows and leaves the job stuck as "running" when the pid is already dead on non-English Windows #423 and Windows: SHELL env var (Git Bash) breaks taskkill; handleCancel swallows terminateProcessTree exceptions, leaving jobs stuck in running/finalizing #647, but neither covers thepid: nullcase: those concerntaskkillfailing against a dead but non-null pid.stopReviewGateenabled,stop-review-gate-hook.mjsruns this same path underspawnSyncwith a 15-minute timeout. A malformed--basein a gated repo therefore blocks session exit for the full 900s before failing open — see also Stop-review gate: hung jobs pile up into livelock; review --wait can exit 0 without a verdict #611.Suggested fix
Treat
erroras terminal: settle the promise in theerrorcase, preservingstate.erroras the failure reason, so the job reachesfailedand the process exits — for example by callingcompleteTurnwith a synthesized failed turn, or by rejectingstate.completionand lettingrunTrackedJobrecord the failure it already handles for other error paths.Separately,
handleCancelshould transition a job whosepidisnulltocancelledrather than returning silently, so a wedged record is recoverable without hand-editing state.Environment
db52e28)