Skip to content

codex-proxy: a turn.failed event with exit 0 is reported as a successful 'stop' — #677 judged failure by exit code only, no terminal-event check #680

Closed

Description

@dezren39

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 bare stop chunk + [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, then data: [DONE] — no error anywhere
  • Stub prints an item.completed agent_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 answer chunk, then a finish_reason: "stop" chunk + [DONE]
  • Control ( item.completed + turn.completed, exit 0) → 200 with the full text and stop in 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 emits turn.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.mjs tracks a turn.failed event during the run; on close, a run that emitted turn.failed is reported as a failure even when the exit code is 0: non-stream returns HTTP 500 with Codex 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 a data:-framed error event + data: [DONE] with no chunk claiming finish_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 200 stop carrying the truncated text.
  • Successful runs (exit 0, turn.completed, no turn.failed) are unchanged in both modes.
  • New stub modes in bin/codex-proxy.test.mjs pin the no-output and partial-output turn.failed + exit-0 shapes in both modes; ablating only the turn.failed check fails exactly those new tests, and the full codex-proxy + muse-proxy + proxy-port suites pass.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

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