Skip to content

Finalize run.json when the process crashes - #355

Merged
TheGreatAxios merged 3 commits into
mainfrom
cl-5574-finalize-run-on-crash
Aug 7, 2026
Merged

Finalize run.json when the process crashes#355
TheGreatAxios merged 3 commits into
mainfrom
cl-5574-finalize-run-on-crash

Conversation

@TheGreatAxios

@TheGreatAxios TheGreatAxios commented Aug 7, 2026

Copy link
Copy Markdown
Collaborator

Summary

  • Export a narrow RunStateHandle (session id, cwd, active flag, task, startedAt, model) from runTUI's setup into a module-level slot (src/session/active-run.ts), registered the moment a run starts, kept in sync wherever runTUI already tracks those fields, and cleared on every path that already finalizes run.json itself.
  • handleFatal in src/index.ts reads that slot and, before process.exit, best-effort writes status: "crashed", finishedAt, and the error message through a new saveCrashState in src/session/state.ts — built entirely from the handle, with no disk read. loadState here would be a plain readFile with no timeout, the exact unbounded crash-path I/O primeCrashReporting exists to avoid for git.
  • saveCrashState bypasses the per-session writeChains map (a hung queued write must never block process.exit), but a bypass alone can't stop an in-flight progress snapshot's rename() from landing after it and resurrecting status: "running". handleFatal now calls markCrashed() first, and saveState checks a synchronous isCrashed() flag immediately before each queued write actually fires, so anything still waiting in the chain at that moment steps aside. This can't recall a write whose writeFile/rename is already dispatched to the kernel — that residual window is one atomicWrite call wide, not the process's remaining lifetime.
  • "crashed" is a new terminal RunState status, excluded from isResumableByDefault (like "done"/"failed"), so a crashed session no longer appears as an incomplete resumable run in the picker.
  • installCrashHandlers is factored out of src/index.ts's entry block so an integration test can register the real crash handlers without spinning up the TUI.

Verification

  • bun run typecheck
  • bun run build
  • bun run test — 3984 pass, 0 fail
  • tests/integration/crash-finalize.test.ts spawns a subprocess fixture that registers the active run and crash handlers, then parks two unawaited straggler "running" snapshot writes behind a test-only write gate (setTestWriteGate in active-run.ts) that it only releases after the crash handler has flipped isCrashed() — guaranteeing both writes are still queued, not dispatched to the kernel, at that exact moment, rather than hoping real filesystem timing lines up. Asserts the resulting run.json has status: "crashed", a finishedAt, the error message, the right task/model (recovered from the handle, no read), and is excluded by isResumableByDefault.
    • An earlier version of this test fired 50 unawaited writes with no ordering control and was a coin flip (reviewer measured 5/13 passes with the guard removed, when it should fail every time). With the write gate making the ordering deterministic: 25/25 passes with the guard in place, and removing if (isCrashed()) return; reliably lets a parked write win the race again (9/15 failures observed in that state — still probabilistic without the fix, as expected, since that's a real race).

Closes CL-5574

@linear-code

linear-code Bot commented Aug 7, 2026

Copy link
Copy Markdown

CL-5574

@TheGreatAxios
TheGreatAxios force-pushed the cl-5574-finalize-run-on-crash branch from 08e4cc2 to 9c4f070 Compare August 7, 2026 07:32
Process-level uncaughtException/unhandledRejection handlers wrote a
crash report but left run.json stuck at status: running, so crashed
sessions kept reappearing in the resume picker as in-progress.

runTUI now registers a narrow handle (session id, cwd, active flag)
in a module-level slot the moment a run starts, clearing it on any
finalize path it already owns. The top-level crash handler reads that
slot and writes status: crashed plus finishedAt through a new
saveCrashState that bypasses the per-session write chain entirely, so
a write that never settles can't block process.exit.
The crash handler awaited loadState (a plain readFile) to recover
task/startedAt/model before writing, the same unbounded-I/O hazard
primeCrashReporting exists to avoid for git. active-run.ts now carries
those fields directly, updated by runTUI wherever it already tracks
them, so the handler needs no read.

Bypassing writeChains for the crash write also reopened the exact
race CL-5567 closed: an in-flight progress snapshot for the same
session could still land after the crash write and resurrect
status: running. saveState now checks a synchronous isCrashed() flag
right before each queued write fires, so anything still waiting in
the chain when the crash handler marks the process crashed steps
aside instead of racing it.
Firing 50 unawaited writes and hoping enough were still queued when
isCrashed() flipped was a coin flip in practice (5/13 passed with the
guard removed, when it should fail every time). A test-only write
gate in active-run.ts now lets the fixture park writes before they
reach the isCrashed() check and release them only after the crash
handler has flipped the flag, so the ordering is controlled instead
of hoped for. 25/25 passes with the guard in place; removing the
guard reliably lets the parked writes win the race again.
@TheGreatAxios
TheGreatAxios force-pushed the cl-5574-finalize-run-on-crash branch from 9c4f070 to 13584dd Compare August 7, 2026 07:49
@TheGreatAxios
TheGreatAxios merged commit bfd1595 into main Aug 7, 2026
2 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