Skip to content

Report aborted turns honestly instead of surfacing the model's preamble - #89

Merged
patriyang merged 4 commits into
mainfrom
fix/88-stalled-turn-report
Aug 7, 2026
Merged

Report aborted turns honestly instead of surfacing the model's preamble#89
patriyang merged 4 commits into
mainfrom
fix/88-stalled-turn-report

Conversation

@patriyang

Copy link
Copy Markdown
Owner

Closes #88

Root cause

Not a phantom interrupt. The plugin's own idle stall watchdog fired. Confirmed from the three failing jobs' log files:

[2026-08-06T08:36:50.822Z] Codex turn stalled (idle): no activity for 900s. Interrupting and aborting the turn.

DEFAULT_TURN_STALL_TIMEOUT_MS is 15 min; the rollouts show 15m01s, 21m00s and 21m06s of complete notification silence before the abort. handleStall calls turn/interrupt, and Codex records every interrupt as turn_aborted / reason: "interrupted" regardless of who issued it — which is why it read as a user cancellation. Two of the three went silent within six seconds of each other on independent turns, which points at an upstream stall rather than the model thinking. The watchdog did its job and is unchanged here.

The duration_ms in the turn_aborted event (247070) is also not the wall clock — completed_at - started_at is 1434s for that same turn — so the coincidence with the 240s status-wait timeout noted in the issue was a red herring.

The actual defect

Everything wrong here is in how the aborted turn is reported:

  • executeTaskRun set rawOutput = result.finalMessage unconditionally. finalMessage is turnState.lastAgentMessage, so on an aborted turn it is the model's opening narration — well-formed prose that /codex:implement parses as the report body.
  • The task JSON payload carried no failure field at all. The stall reason reached only the job log file, never stdout, the payload, or the stored result — renderTaskResult returns rawOutput whenever it is non-empty, so it shadowed the failure message too.

Fix

  • rawOutput is gated on turn completion (result.status === 0). An incomplete turn's last agent message moves to a new partialOutput field, so every downstream consumer that keys off rawOutput correctly sees "no report".
  • The task payload now carries partialOutput and failureMessage.
  • renderTaskResult leads with the failure reason, lists the files the run already modified, and reproduces the partial text under a heading that says plainly it is not the final report. Completed turns render byte-identically to before.
  • Same completion gate on the structured review path, so an aborted review cannot yield a verdict parsed out of a preamble. The raw text stays in payload.codex.stdout.
  • implement.md now warns the controller that a failed dispatch may have already applied edits: check touchedFiles / git status and resume with --resume-id instead of re-dispatching fresh.

Verification

The first commit is a failing repro, committed before any product change:

  • tests/fake-codex-fixture.mjs gains an idle-hung-turn-after-preamble behavior — emits a non-final agentMessage plus a fileChange, then goes silent until the idle watchdog fires.
  • tests/runtime.test.mjs drives a real end-to-end task --write run against it. Before the fix it failed with rawOutput equal to the preamble; after, rawOutput is empty, partialOutput holds the preamble, failureMessage holds the stall reason, and touchedFiles still reports the edit that landed.
  • tests/render.test.mjs pins both branches of renderTaskResult, including exact-string equality on the completed-turn path.

Full suite: 303 passed, 0 failed (node --test --test-concurrency=1 tests/*.test.mjs).

Codex SDD reviewers: spec SPEC_COMPLIANT, code quality APPROVED.

🤖 Generated with Claude Code

patriyang and others added 4 commits August 6, 2026 10:41
… report

The idle stall watchdog interrupts a turn, but executeTaskRun still hands
back result.finalMessage as rawOutput and drops the failure reason from
the JSON payload entirely, so a controller sees exit 1 plus a
plausible-looking status report and no explanation.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
When the idle stall watchdog interrupts a turn, `finalMessage` is the last
agent message that existed at abort time -- typically the model's opening
narration, not its report. `executeTaskRun` handed that straight back as
`rawOutput`, and dropped the stall reason from the JSON payload entirely,
so an SDD controller saw exit 1 plus a plausible-looking status report and
no explanation, and would re-dispatch on top of already-applied edits.

Gate `rawOutput` on turn completion, move the partial text to
`partialOutput`, carry `failureMessage` in the payload, and render the
failure reason plus the files this run already touched. Apply the same
completion gate to the structured review path.

Closes #88

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The recovery note lived under Dispatch and Follow-Through, which governs
every dispatch, so its blanket "resume ${IMPLEMENTER_THREAD_ID}" would
have pointed a failed reviewer at the write-capable implementer thread --
abandoning the review and risking unintended edits. Split it: implementers
resume their own thread after a touched-files check, reviewers re-dispatch
fresh because they changed nothing on disk.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@patriyang
patriyang merged commit 49041f5 into main Aug 7, 2026
3 checks passed
@patriyang
patriyang deleted the fix/88-stalled-turn-report branch August 7, 2026 04:11
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

task dispatches exit 1 with the model's preamble as rawOutput after a phantom "interrupted" turn abort — edits land, the report is lost

1 participant