Skip to content

[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

Description

@serge-ivo

A rule that can now only be wrong

HeadlessSession.runState() infers idle for raw engines from three timers
(packages/browser-runner/src/coding/headless.ts:278-288). Rule 1 is "produced output, then went
quiet for 1.5s → settled"
, and its comment explains it was written for a persistent interactive
CLI, where there is no other signal to read.

Raw engines are no longer persistent. :298 states it outright — "Raw engines run ONE-SHOT PER
TURN, not as a persistent interactive process"
oneShot is mode === "raw" (:313), each turn
is its own spawn (runOneShot, :412), and the close handler sets this.run = "idle"
(:455-457). So for exactly the engines rule 1 governs, process exit is an exact, already-wired
end-of-turn signal
, and a timer can only ever fire before it.

The failure is not theoretical, and the codebase already documents both halves:

  • headless.test.ts:412-419 records the consequence — "a long build gets judged idle and the brain
    sends turn 2 while turn 1 is still running"
    .
  • runOneShot (:417-427) then kills the in-flight process, with a comment naming the same
    cause: "the raw idle heuristic declares idle after a >1.5s output pause — so a long build could
    be judged idle, the brain sends turn 2, and TWO engine processes edit the same repo at once."

That kill was the right fix for concurrent writers, but it converts the symptom from two processes
racing
into work silently destroyed: codex exec running a test suite or a build goes quiet for
more than 1.5s, is judged idle, and is killed mid-turn by its own replacement. The Pilot then reasons
about a turn that never finished.

Rules 2 and 3 have the same property for one-shot engines — an 8s silent-turn rule and a 15-minute
backstop are both guesses about a process whose exit is observable.

Suggested fix

Make exit authoritative when oneShot is true: runState() returns "thinking" while procAlive,
and idle is set by close (which it already does). The timer rules stay for any genuinely persistent
non-Claude engine, if one is ever added — but they should be gated on that, not on mode === "raw",
which today means the opposite.

Worth checking as part of it whether runOneShot's pre-emptive kill is still needed once idle cannot
be reported early; if it is, it should say what it killed rather than dying silently, per the same
reasoning that put a non-zero exit code into the transcript at :445.

What this does not claim

It does not claim the 15-minute backstop is useless — a wedged process still needs a ceiling, and
removing it without a replacement would trade an early kill for a permanent hang. It also does not
touch the stream-json path, where a result event already ends the turn as a fact.

Found while assessing #249 (whether tmux should return as a Coder engine backend); the memo's point
was that this specific defect is the one people would blame on "no PTY", and it is not PTY-shaped.
Verified against main at d0e4095 by reading the file, not by reproducing a run.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions