Skip to content

Sync run.json turn count and idle the shell at every turn boundary - #368

Merged
TheGreatAxios merged 4 commits into
mainfrom
cl-5570-cl-5534-run-state
Aug 8, 2026
Merged

Sync run.json turn count and idle the shell at every turn boundary#368
TheGreatAxios merged 4 commits into
mainfrom
cl-5570-cl-5534-run-state

Conversation

@TheGreatAxios

@TheGreatAxios TheGreatAxios commented Aug 7, 2026

Copy link
Copy Markdown
Collaborator

Summary

Two run-state fixes, both about inference.done being the turn boundary versus reactor.done being reactor shutdown (fires once, never between turns).

Shell idle between turns. shell.session.run returning to idle between turns was already fixed on main (keyed off inference.done in src/tui-opentui/runtime-bridge.ts), but had no test proving it across more than one turn. Added a regression test in src/tui-opentui/runtime-bridge.test.ts that drives two consecutive turns and asserts run returns to idle between them, and that a message typed in between sends immediately instead of queuing.

run.json turn count staying in sync. The mid-run progress snapshot only re-fired on reactor.done (shutdown), so a long interactive session never re-persisted its turn count after the initial resume-time write — turnsUsed sat frozen while turns.jsonl kept growing. Rekeyed the snapshot trigger to inference.done, and moved the trigger itself into src/session/run-sink.ts (which already owns the turn count and already special-cases inference.done there) via an onTurnBoundarySnapshot callback, instead of a second subscription to the same event stream in the renderer.

Rebase note

This branch sat stale since 2026-08-07 while origin/main moved substantially. Today's main independently added a shared onTurnBoundary type-guard predicate in src/agent/reactor-events.ts and wired src/session/run-sink.ts to use it for the same inference.done check this branch was making inline. Rebasing produced a silent naming collision: this branch's own onTurnBoundary callback field on RunSinkArgs shadowed the imported predicate, so if (onTurnBoundary(event)) tried to invoke the optional callback with an argument it doesn't accept — that's the TS2722/TS18048/TS1345/TS2554 failure CI was reporting at src/session/run-sink.ts:127. Fixed by renaming this branch's callback to onTurnBoundarySnapshot throughout, keeping main's onTurnBoundary predicate for the boundary check.

Scope notes

  • src/exec/runner.ts builds its own createRunSink(...) (src/exec/runner.ts:557) and does not pass onTurnBoundarySnapshot — exec is a single-shot run that persists state explicitly via its own persist() helper, not the long-lived interactive session this fix targets, so it's unaffected either way.
  • listSessions (src/session/index.ts:251-256) still fabricates status: "running" forever for a session directory that has context/ but crashed before its first run.json write. This change writes run.json sooner (at the first turn boundary instead of only at start/end), which narrows that window, but doesn't close it for a session that crashes before completing even one turn. Not fixed here — it's a separate, pre-existing gap.

Verification

  • bun run typecheck — clean
  • bun run build — clean
  • bun run test (./src ./tests ./evals) — 4139 pass, 0 fail

@linear-code

linear-code Bot commented Aug 7, 2026

Copy link
Copy Markdown

CL-5570

CL-5534

@TheGreatAxios

Copy link
Copy Markdown
Collaborator Author

Independent review — ready to merge

Verified by running, not by reading. Left open for human review.

CL-5534's fix is real. Drove createRunSink + saveState/loadState through a multi-turn session and read run.json back off disk: turnsUsed observed as [1, 2, 3], final status: "done". Reproducing the pre-fix gate on reactor.done froze the on-disk value at turnsUsed: 0 for the whole session while memory correctly tracked 3 — exactly the reported symptom.

CL-5570 was independently confirmed already fixed. The new two-turn test passes on unmodified main with no source changes, and fails against the commit immediately before the queue-drain fix (expected "idle", received "busy" at the first turn boundary). So it is a genuine regression test, not a tautology.

Full reactor.done sweep — no other misuses found.

Site Verdict
src/tui/runner.ts:1414 Was the bug; fixed here
src/session/run-sink.ts:107 Legitimate — "done" genuinely means clean reactor shutdown
src/agent/renderer.ts:188 Legitimate — one-shot exec terminal output
src/tui-opentui/stream-event-map.ts:443 Legitimate — idempotent re-assertion; idle transitions are driven by inference.done
src/tui-opentui/turn-state.ts:524 Legitimate — idempotent final reset

writeChains/isCrashed() invariant verified behaviorally, not by inspection. src/session/state.ts has zero diff. Under the new higher write frequency: 20 back-to-back turns with no settling delay landed turnsUsed at 20; a late in-flight running write racing a done write left the final on-disk status as done, never resurrected.

Typecheck, build clean. 4039 pass / 0 fail, and identical under --randomize --seed 42.

Two non-blocking notes for the human reviewer

  1. isRunSnapshotTurnBoundary is exported solely for testability — one production caller in the same file. Worth deciding whether it should stay unexported and be tested through streamSink's observable effect instead.
  2. The red/green cycle happened locally and is not evidenced in committed history: cad9a29 (test) then 1962f49 (fix plus its own test). The reviewer independently reproduced the same failure shape, so the claim holds — but the history does not show it.

Also suggested: an end-to-end test driving the real modules to disk would be stronger than the current unit test, which only exercises the predicate in isolation.

@TheGreatAxios

Copy link
Copy Markdown
Collaborator Author

Parking for the release candidate. Typecheck is currently broken on this branch — src/session/run-sink.ts:127 references onTurnBoundary, which collides with a local symbol of the same name (TS2722/TS18048/TS2554). The rework is mid-flight: the private predicate is correctly gone and onTurnBoundary is now on main, so adopting it is unblocked. Finish the move of the snapshot trigger into the session layer, resolve the name collision, and reopen.

runtime-bridge.ts already keys the shell's run-idle transition off
inference.done (the turn boundary), not reactor.done (shutdown, fires
once) -- fixed alongside the queued-message drain in an earlier
commit. That left the multi-turn case unverified: a test only proved
a single turn settled run back to idle, not that a second turn starts
from idle again after the first one closes it out.

Add a regression test that drives two consecutive turns end to end
and asserts run returns to idle, and that a message submitted between
them sends immediately rather than routing through the queue.
The mid-run progress snapshot in the TUI runner only re-fired on
reactor.done, which fires exactly once, at agent shutdown, and never
between turns of a long-lived interactive session. A live monorepo
session showed run.json stuck at turnsUsed: 0 and status: running for
its entire multi-turn lifetime, with dozens of completed turns
already in context/turns.jsonl -- resume pickers and anything else
trusting run.json had no truthful signal until the process closed.

inference.done is the turn boundary every reactor cycle guarantees
(the same one the shell's run-idle transition keys off), so key the
snapshot write off that instead. The terminal write on clean exit and
the crash path both already write through directly with the final
status, so this only changes progress snapshots taken while the run
is still live.
isRunSnapshotTurnBoundary had exactly one production caller in the
same file, so its export existed only for a unit test. Make it
module-private and replace that test with an end-to-end one that
drives createRunSink, saveState, and loadState against a real temp
session directory: turnsUsed incrementing per turn as read back off
disk, 20 rapid back-to-back turns with no settling delay, and a late
running write racing a done write to confirm status never resurrects.
run.json is session domain state, and src/session/run-sink.ts already
owns the turn count and already special-cases inference.done there
(clearing a stale runError). The mid-run snapshot trigger lived in
src/tui/runner.ts instead, keyed off a second subscription to the
same event stream — the exact shape that has already cost this
constraint three renderer swaps. Move the cadence into run-sink via
an onTurnBoundarySnapshot callback, so the renderer only owns how to
persist a snapshot, not when one is due. Named apart from the
onTurnBoundary predicate in src/agent/reactor-events.ts, which
run-sink now uses for the same inference.done check it already made
inline.

The end-to-end test now drives the callback the way production
wiring does, instead of calling saveState directly after each sink
call, so it actually exercises the trigger rather than simulating it.
@TheGreatAxios
TheGreatAxios force-pushed the cl-5570-cl-5534-run-state branch from 22353ee to b0493e6 Compare August 8, 2026 18:20
@TheGreatAxios
TheGreatAxios merged commit d1d9c5a into main Aug 8, 2026
3 checks passed
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.

1 participant