Skip to content

Tie active-run liveness to a single write instead of two independent ones - #404

Merged
TheGreatAxios merged 10 commits into
mainfrom
cl-5675-runstatehandleactive-in-memory-and-runstatestatus-on-disk
Aug 9, 2026
Merged

Tie active-run liveness to a single write instead of two independent ones#404
TheGreatAxios merged 10 commits into
mainfrom
cl-5675-runstatehandleactive-in-memory-and-runstatestatus-on-disk

Conversation

@TheGreatAxios

Copy link
Copy Markdown
Collaborator

Summary

  • Deletes the redundant active: boolean field from RunStateHandle (src/session/active-run.ts:12) -- both write sites cleared the handle immediately after setting it false, so the boolean never disagreed with "handle is present"; it was a copy of the same fact, not independent state.
  • Adds finalizeRunState (src/session/state.ts) as the single write path for a terminal RunState: it persists the record and clears the active-run handle in one call, used by both terminal call sites in src/tui/runner.ts (the crash-finalize path and the normal completion path) instead of each remembering to update both facts separately.
  • Updates the crash-handler read in src/index.ts to check handle presence only.

Test plan

  • New unit test in src/session/state.test.ts asserting a terminal finalizeRunState write leaves both the persisted status and the active-run handle in agreement
  • bun run typecheck
  • bun run build
  • bun run test (4172 pass, 0 fail)

@linear-code

linear-code Bot commented Aug 8, 2026

Copy link
Copy Markdown

CL-5675

@TheGreatAxios
TheGreatAxios force-pushed the cl-5675-runstatehandleactive-in-memory-and-runstatestatus-on-disk branch from 1771cde to c435140 Compare August 8, 2026 20:05
RunStateHandle carried its own active boolean alongside
RunState.status on disk, set at two separate call sites in
src/tui/runner.ts (finalizeOnCrash and the normal completion
path). Both sites happened to null the handle immediately after
flipping the flag, so the boolean was always true whenever the
handle existed, making it a redundant copy of the same fact
rather than independent state -- deleted in favor of "the handle
is null" as the sole liveness signal (src/session/active-run.ts).

Terminal RunState writes now go through finalizeRunState
(src/session/state.ts), which persists the record and clears the
active-run handle in one call instead of leaving each terminal
call site to remember both.
Routing every non-"running" run.json write through finalizeRunState
made the terminal status itself the signal that the run had ended,
and so cleared the active-run handle. Session rotation (/clear,
/new) breaks that equivalence: it persists a terminal "done" for
the outgoing session while the process keeps running under a fresh
session id. Clearing liveness there left getActiveRun() null for
the rest of the process, so a crash after the first rotation never
wrote a terminal record and the session read as "running" forever.

Why a write happens is now explicit (SnapshotKind: progress,
session-rotation, run-end) instead of inferred from what it writes.
Only run-end clears the handle; persistRunSnapshot cannot request
it, since everything routed there happens while the process is
still alive.

Also drops the deleted `active` field from the crash fixture, which
sits outside tsconfig's include and so escaped typecheck.
The existing rotation tests covered clearsActiveRun as a pure
function and confirmed a handle survives a rotation write, but
nothing exercised a crash actually occurring afterward. Extend
the crash fixture to optionally rotate the session first, through
the same clearsActiveRun dispatch runner.ts uses, then crash and
assert the crashed record lands under the post-rotation session.
The prior comment only noted that finalizeRunState's own clear
becomes a no-op repeat, which reads as an argument for deleting
the early call rather than keeping it. It is not redundant: index.ts
installs its own crash listeners that read the handle directly
and would otherwise race a competing write during the awaits here.
Document that this is a second terminal write path alongside
finalizeRunState, kept apart because its only callers are the
process-level crash and signal handlers in index.ts, which cannot
afford to wait on saveState's per-session write chain during exit.
@TheGreatAxios
TheGreatAxios force-pushed the cl-5675-runstatehandleactive-in-memory-and-runstatestatus-on-disk branch from c435140 to 3084acc Compare August 8, 2026 21:43
finalizeRunState cleared the handle only after its saveState
await resolved, so a signal or uncaught exception landing during
that write still saw a live run and raced a competing "crashed"
write against the terminal write in progress. That reopened the
crash-after-rotation race on every ordinary run-end write, not
just the crash path's own. Clearing before the await closes it:
by the time anything else can observe the handle, it already
reads as gone.
Deleted along with the standalone active-run clear when that call
site was folded into finalizeRunState's write, but the dispose
host has no on-disk write to piggyback on, so it never got a
replacement. Every normal run was leaving the dispose host
pointing at a torn-down closure that a later signal could still
invoke. Restored to mirror finalizeOnCrash, matching active-host.ts's
stated contract of clearing on either path.
RunStateHandle dropped its own active flag in favor of handle
presence as the sole liveness signal, but this fixture still set
it. tsconfig excludes tests/ from typecheck, so nothing caught
the stale field and the setter silently ignored the excess
property.
The existing crash-after-rotation test only covers a crash
arriving before any terminal write starts, so it stayed green
even with the run-end handle-clearing regression present. This
test parks the run-end write mid-flight and fires an unrelated
uncaughtException during that window, asserting the outcome is
never "crashed" — pinning the fix that moved finalizeRunState's
clear ahead of its own write.
saveCrashState requires markCrashed() first so chained saveState renames cannot clobber the terminal write. handleFatal already did this for uncaughtException; SIGINT/SIGTERM/SIGHUP called saveCrashState without the fence. Flip the flag before the signal write, and park stragglers in the signal fixture so the integration test pins it.
@TheGreatAxios
TheGreatAxios merged commit fd0ca83 into main Aug 9, 2026
2 checks passed
@TheGreatAxios

TheGreatAxios commented Aug 9, 2026

Copy link
Copy Markdown
Collaborator Author

Merged.

Signal-path finalize now mirrors the crash path: on SIGTERM/SIGINT/SIGHUP the process marks the active run crashed before exit, and the signal fixture gates straggler writes so a late write cannot last-write-win over terminal failed. Crash-after-rotation and crash-during-run-end fixtures cover the same class of races.

handle presence remains the liveness signal; no redundant active flag. Dispose-host still clears the active run before await, with the same fence as the crash path so a concurrent finalizer cannot resurrect it.

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