Repository navigation
Description
Finding
PR #677 fixed bin/codex-proxy.mjs judging failure only by the child's exit code (code !== 0 in the close handler, bin/codex-proxy.mjs:412 on main 4b85ef69). But codex exec --json also reports failure in-band: it emits a {"type":"turn.failed","error":{"message": …}} event for a fatal error during a turn, and that event can arrive on a run that exits 0. The proxy's event parser (processJsonLine, bin/codex-proxy.mjs:340-380) handles only item.completed and turn.completed — turn.failed is silently ignored, so an exit-0 failed turn is still reported as a successful stop:
- no output before the failure → HTTP 200, empty content,
finish_reason: "stop"(non-stream) / a barestopchunk +[DONE](stream) - partial output before the failure → HTTP 200 carrying the truncated text as a clean
stop(both modes)
That is exactly the masked-failure shape #676 fixed for the exit-code half — and exactly the shape bin/muse-proxy.mjs already guards against by judging failure from the run's authoritative terminal event first and the exit code second (failed = terminalState ? terminalState !== 'completed' : code !== 0, the #672/#673 pattern PR #677's body says it mirrors). The codex half of that pattern — the event-state half — is missing.
Exit 0 does not rule out a failed turn for codex: other codex providers explicitly check for turn-failure events on exit 0 (e.g. the codex-cli AI SDK provider's exit-code logic: "0 | Success | Check for turn failure events; return results if none", with a dedicated event-based error-detection pass for "failures that may not manifest as non-zero exit codes"), and headless-codex guidance is to fail on a turn.failed event rather than trust the exit code. turn.failed (TurnFailedEvent { error }, "on fatal error during a turn") is part of the documented codex exec --json event protocol.
Note on scope: top-level {"type":"error", …} events also exist in the protocol, but some are transient reconnect notices; this issue only requires the unambiguous turn.failed case. Whether to treat non-reconnect error events as failures is a judgement call for the implementer.
Evidence (reproduced on current main 4b85ef6906a25c1a59694c64986cb17828b6c2b6)
Ran the real bin/codex-proxy.mjs (pristine copy from origin/main) with a stub codex at $HOME/.local/bin/codex (the proxy's own binary resolution):
- Stub prints
{"type":"turn.failed","error":{"message":"stub codex: model overloaded"}}, exits 0:- non-stream → 200,
content: "",finish_reason: "stop" - stream → 200, one
finish_reason: "stop"chunk, thendata: [DONE]— no error anywhere
- non-stream → 200,
- Stub prints an
item.completedagent_message (partial answer), then{"type":"turn.failed","error":{"message":"stub codex: run failed midway"}}, exits 0:- non-stream → 200,
content: "partial answer",finish_reason: "stop" - stream → 200, the
partial answerchunk, then afinish_reason: "stop"chunk +[DONE]
- non-stream → 200,
- Control (
item.completed+turn.completed, exit 0) → 200 with the full text andstopin both modes, unchanged. - The existing suites pass on the same main:
node --test bin/codex-proxy.test.mjs bin/muse-proxy.test.mjs→ 10/10 — no existing test emitsturn.failed.
Source PR: #677 (fix(codex-proxy): report failed codex runs as errors, not successful stops, fixes #676, commit a0a1b077, merge 4b85ef69). Its failure predicate is the single line if (code !== 0) at bin/codex-proxy.mjs:412; nothing in the PR's new bin/codex-proxy.test.mjs covers an in-band failure event.
Dedup: searches for turn.failed across all issues (open + closed) return nothing; codex-proxy failed returns only #676 (closed, the exit-code half), #670 (muse-proxy streaming), #496 and the open #678 (agy-proxy's separate exit-code masking — a different proxy and a different mechanism; this finding is codex-only). The bin/codex-proxy.mjs bridge exists only in this repo.
Why it matters
Clients of the proxy (opencode's codex provider) cannot distinguish a failed codex turn from a successful empty or short answer: a rate-limited, overloaded, or otherwise fatally failed turn presents as a clean stop, so callers accept truncated/empty output instead of surfacing an error or retrying — the same silent-corruption failure mode #670/#672/#676 were filed and fixed for.
Acceptance criteria
-
bin/codex-proxy.mjstracks aturn.failedevent during the run; onclose, a run that emittedturn.failedis reported as a failure even when the exit code is 0: non-stream returns HTTP 500 withCodex execution failed: <the turn.failed error message>(falling back to stderr/signal/exit-code detail when the event carries no message), and stream mode writes adata:-framed error event +data: [DONE]with no chunk claimingfinish_reason: "stop"— mirroring the existing fix(codex-proxy): report failed codex runs as errors, not successful stops #677 error framing. - Partial output followed by
turn.failed+ exit 0 is an error in both modes, not a 200stopcarrying the truncated text. - Successful runs (exit 0,
turn.completed, noturn.failed) are unchanged in both modes. - New stub modes in
bin/codex-proxy.test.mjspin the no-output and partial-outputturn.failed+ exit-0 shapes in both modes; ablating only theturn.failedcheck fails exactly those new tests, and the full codex-proxy + muse-proxy + proxy-port suites pass.
Activity