Skip to content

[bug] Every Codex turn exited 1 and the session still reported alive/ready/idle — a non-zero engine exit is a line of prose in the pane and nothing else #545

Description

@serge-ivo

Every Codex turn on this session exited 1, and the platform reports the session healthy

Feedback row 6a84f09a-821f-4ca2-9e8d-fbc8bae1d0fb (2026-08-12 12:16:52, trace eee50f1f,
instance a1d3522f-6b91-4cd9-bf7b-380e470192b7), filed by the agent through record_feedback:

"When starting Codex engine, it doesn't work — the Coding tab shows 'no output yet' and the engine
doesn't get started."

The owner's own words at 12:16:46: "There is nothing there yet, it just says no output yet, send
the engine an instruction to get started."

Codex does start. Every turn it runs exits 1, and nothing in the platform says so.

The pane, read live from production

GET /v1/instances/a1d3522f…/coding/sessions/csess_22d08431-591d-4936-bfd1-95c357667cd6/capture,
fetched 2026-08-12 23:14 UTC:

{"pane":"\n❯ [08:40:52] Run `git pull` in the repository at dev/aipa.\nReading additional input from stdin...\nNot inside a trusted directory and --skip-git-repo-check was not specified.\n[codex exited with code 1]\n\n❯ [08:40:56] Run the following shell command: cd dev/aipa && git pull\nReading additional input from stdin...\nNot inside a trusted directory and --skip-git-repo-check was not specified.\n[codex exited with code 1]\n\n❯ [08:41:00] Run the shell command: cd ~/dev/aipa && git pull\nReading additional input from stdin...\nNot inside a trusted directory and --skip-git-repo-check was not specified.\n[codex exited with code 1]",
 "alive":true,"ready":true,"runState":"idle","engineRuntime":"child-process","runnerConnected":true}

Three turns. Three exit-1s. alive: true, ready: true, runState: "idle".

Reproduced locally, on the same machine, with the shipped preset

codex-cli 0.147.0, RLs-MacBook-Air (the node this session ran on):

$ mkdir /tmp/probe && cd /tmp/probe        # NOT a git repo
$ codex exec --sandbox danger-full-access "say hi" </dev/null; echo "EXIT=$?"
Reading additional input from stdin...
Not inside a trusted directory and --skip-git-repo-check was not specified.
EXIT=1

$ git init -q .                            # the ONLY change
$ codex exec --sandbox danger-full-access "reply with the single word OK" </dev/null; echo "EXIT=$?"
…
codex
OK
EXIT=0

The failure is Codex's own gate: codex exec --help documents
--skip-git-repo-check Allow running Codex outside a Git repository. The shipped preset does not
carry it —

workers/api/src/lib/coding-engines.ts:269

{ id: "codex", label: "Codex", command: "codex exec --sandbox danger-full-access" },

and, grepped, the flag appears nowhere in the repository:

$ grep -rn "skip-git-repo-check" . | grep -v node_modules
(0 results)

(The directory in question is ~/dev/aipa, which has no .git — that half is #548. This issue is
about what the platform does when an engine turn fails, which would be the same for any non-zero
exit: a bad flag, an unauthenticated CLI, a wrong model name.)

The mechanism: a non-zero exit is prose, not a signal

packages/browser-runner/src/coding/headless.ts:528-543 (runOneShot's close handler):

proc.on("close", (code: number | null) => {
    clearTimeout(ceiling);
    if (code) this.push(`[${this.config.clientType} exited with code ${code}]`);
    if (this.proc !== proc) return;
    this.run = "idle";
    this.proc = null;
});

The exit code is pushed into the transcript as a line of text and then discarded. It sets no
field. The only structured failure flag a one-shot session has is spawnFailed
(headless.ts:214,511), and that is set solely from the error event — a binary that is not on
PATH. A binary that runs and refuses is indistinguishable from a binary that runs and succeeds:

headless.ts:294-297

get alive(): boolean {
    if (this.oneShot) return !this.stopped && !this.spawnFailed;

headless.ts:324

if (this.oneShot) return this.procAlive ? "thinking" : "idle";

So the exit sets runState to idle — which is the loop's word for ready for the next
instruction
— and alive stays true. The snapshot the cloud receives
(packages/browser-runner/src/coding/runtime.ts:245-265) carries pane, alive, ready,
runState, authResolved, engineRuntime. There is no field for the outcome of the last turn.

What that costs the Pilot

runCodingLoop (workers/api/src/lib/coding-loop.ts:149-296) opens each step with two liveness
guards — snap.cancelled and !snap.alive — and neither fires. The brain is then handed the pane
as prose (decideCodingAction, :408-414) and left to work it out. In the production run it read
"Not inside a trusted directory" as a path problem and tried three path variants in eight
seconds (22:40:52 / 22:40:56 / 22:41:00), then called request_human. Nobody answered the handoff,
and 180 × 5s later (HANDOFF_WAIT_POLLS, workflows/coding-session.ts:54) the run closed
failed — stuck not resolved in time at 22:56:23. Three BYOK-Claude decisions and 15 minutes spent
on a signal the runner had in hand at 22:40:52.

Note this is not #522: the three instructions differ, so repetitionVerdict correctly did not
fire, and 73aa1c7 had been on main for twelve hours when this ran.

And what it costs the console

agents/coder/web/src/CodingTab.tsx renders the same pane. A fresh one-shot session legitimately
shows "no output yet" until a turn arrives (headless.ts:389-392start() is a no-op for a
one-shot engine, by design). The owner opened the Coding tab on a fresh Codex session at 12:16
and read that message as "Codex is broken". He was wrong about that session and right about the
engine, and nothing on the page could tell the two apart.

What to do, cheapest first

1. Carry the last turn's exit code in the snapshot. One field, one line in runOneShot's
close handler, one field in CodingSnapshot:

lastTurn?: { exitCode: number | null; at: number }

Everything below is a consumer of that one fact, and each is independently shippable.

2. Tell the Pilot, in the step log, not only in the pane. runCodingLoop already has the
channel: it feeds itself notes through actionLog for empty instructions (#504, :205-223) and
merge refusals (#314, :234-246), and the brain demonstrably reacts to them. Push
the engine exited with code 1 and produced no result — this is the engine refusing to run, not an answer to your instruction and the third path-variant retry does not happen.

3. Bound consecutive engine failures, exactly as MAX_EMPTY_INSTRUCTIONS = 3 bounds the
mirror case: N non-zero exits in a row → outcome: "failed" with the engine's own last stderr
line as the detail. failed here, not stuck: no human takeover fixes a CLI that refuses on
every invocation, and 15 minutes of unanswered handoff is the worst available ending.

4. Say it in the console. A repo row / session header that reads "the engine exited with code 1
on its last 3 turns"
instead of a pane the owner has to parse.

5. The preset question — decide it, do not default it. Should codex ship with
--skip-git-repo-check? Arguments both ways, and it is genuinely the owner's call:

Alternatives considered and rejected

  • Parse the pane for [… exited with code N]. The string is already there and a regex would
    work today. Rejected: it is a regex over an engine's prose, which is the class of guess [bug] A one-shot raw engine is judged idle by a 1.5s quiet timer and killed mid-turn — process exit is already the exact signal #391
    removed from runState in favour of the process's own exit. The runner has the exit code; it
    should carry it.
  • Report alive: false after a non-zero exit. Rejected — it would resurrect the exact bug
    headless.ts:271-293 documents: a one-shot session has no process between turns, alive means
    "can take a turn", and a failing turn does not make the session unable to take another. The next
    turn might well succeed (different cwd, different instruction). Outcome and liveness are two
    facts; conflating them once already killed every delegated goal on codex/grok/gemini at
    iteration 0.

Acceptance criteria

  1. CodingSnapshot carries the exit status of the last completed one-shot turn, and a runner test
    asserts it is 1 after a command that exits 1.
  2. runCodingLoop appends a note to actionLog on a failed turn, and a unit test asserts the note
    reaches the next decide call's rendered step log.
  3. N consecutive failed turns end the run as failed with the engine's own message, not as stuck
    → 15-minute handoff → failed.
  4. Replaying this exact production pane through runCodingLoop produces at most one path-variant
    retry, not three.
  5. The Coding tab distinguishes "no output yet (nothing has been sent)" from "the engine failed on
    its last turn".

Regression risk

Feedback row this closes

6a84f09a-821f-4ca2-9e8d-fbc8bae1d0fb (#514 capture, author agent).

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