Skip to content

captureTurn treats the error notification as non-terminal, so a Codex-side failure hangs the turn forever and wedges the job at status: running #698

Description

@navyma9988-svg

Summary

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:

case "error":
  state.error = message.params.error;
  emitProgress(state.onProgress, `Codex error: ${message.params.error.message}`, "failed");
  break;

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:

return await state.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:

git init -b main repro && cd repro
git commit --allow-empty -m "root"
echo x > a.txt && git add -A && git commit -m "content"

node "$CLAUDE_PLUGIN_ROOT/scripts/codex-companion.mjs" review \
  --cwd "$PWD" --base 4b825dc642cb6eb9a060e54bf8d69288fbee4904 --scope branch

Observed — the command never returns:

[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:

| review-mtearxsc-xudirz | review | running | failed | 11m 34s | … |

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"
}

updatedAt freezes 22s in and never advances.

Contrast: the turn/completed path works correctly

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.

Knock-on effects

  1. /codex:cancel is a silent no-op. The job has pid: 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-editing state.json. This is adjacent to /codex:cancel throws 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 the pid: null case: those concern taskkill failing against a dead but non-null pid.
  2. The stop-review gate turns this into a session-level block. With stopReviewGate enabled, stop-review-gate-hook.mjs runs this same path under spawnSync with a 15-minute timeout. A malformed --base in 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 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.

Environment

Plugin 1.0.6 (db52e28)
codex-cli 0.147.0
Node v24.18.0
Git 2.55.0.windows.2
OS Windows 11 26200
Host Claude Code

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions