@@ -248,6 +248,24 @@ export function resolveResumeSeed(pickedState: RunState | null): ResumeSeed {
248248 } ;
249249}
250250
251+ /**
252+ * Why a run.json snapshot is being written. Only "run-end" ends the run
253+ * itself and so clears the active-run handle that the crash handler in
254+ * index.ts reads.
255+ *
256+ * RunState.status cannot stand in for this. A /clear or /new rotation
257+ * persists a terminal "done" for the outgoing session while the process
258+ * keeps running under a fresh session id, so inferring "the run is over"
259+ * from a non-"running" status disarms crash finalization for everything
260+ * after the first rotation -- the session that dies then never gets its
261+ * terminal record and reads as "running" forever.
262+ */
263+ export type SnapshotKind = "progress" | "session-rotation" | "run-end" ;
264+
265+ export function clearsActiveRun ( kind : SnapshotKind ) : boolean {
266+ return kind === "run-end" ;
267+ }
268+
251269const GRANT_SCOPE_LABEL : Record < GrantScope , string > = {
252270 session : "This session" ,
253271 project : "This project" ,
@@ -1441,6 +1459,7 @@ export async function runTUI(initialConfig: Config): Promise<number> {
14411459 const writeRunSnapshot = async (
14421460 status : RunState [ "status" ] ,
14431461 extra ?: Pick < RunState , "finishedAt" | "error" > ,
1462+ kind : SnapshotKind = "progress" ,
14441463 ) : Promise < void > => {
14451464 const task = runTaskTitle . trim ( ) . length > 0 ? runTaskTitle . trim ( ) : "(conversation)" ;
14461465 const model = `${ liveSource . id } :${ liveSource . model } ` ;
@@ -1458,28 +1477,29 @@ export async function runTUI(initialConfig: Config): Promise<number> {
14581477 mcpServers : connectedMcpServers ,
14591478 ...extra ,
14601479 } ;
1461- // "running" is the only non-terminal status writeRunSnapshot ever
1462- // receives (progress snapshots); anything else closes the run out, so
1463- // the active-run handle is cleared in the same call as the disk write
1464- // rather than by a separate statement at each terminal call site.
1465- if ( status === "running" ) {
1466- await saveState ( config . cwd , sessionId , state ) ;
1467- } else {
1480+ if ( clearsActiveRun ( kind ) ) {
14681481 await finalizeRunState ( config . cwd , sessionId , state ) ;
1482+ } else {
1483+ await saveState ( config . cwd , sessionId , state ) ;
14691484 }
14701485 } ;
14711486
14721487 // Progress snapshots are fired unsequenced (model switch, MCP connect, turn
14731488 // completion), so a straggler could otherwise land after the terminal write
14741489 // and resurrect status "running" — atomicWrite is last-rename-wins. Once the
1475- // run is finalized, drop them; the terminal paths write through
1490+ // run is finalized, drop them; the run-ending path writes through
14761491 // writeRunSnapshot directly.
1492+ //
1493+ // Never a "run-end" write: everything routed here happens while the process
1494+ // is still alive and must stay crash-coverable, including the rotation
1495+ // "done" that closes out a session on /clear or /new.
14771496 const persistRunSnapshot = async (
14781497 status : RunState [ "status" ] ,
14791498 extra ?: Pick < RunState , "finishedAt" | "error" > ,
1499+ kind : Exclude < SnapshotKind , "run-end" > = "progress" ,
14801500 ) : Promise < void > => {
14811501 if ( finalized ) return ;
1482- await writeRunSnapshot ( status , extra ) ;
1502+ await writeRunSnapshot ( status , extra , kind ) ;
14831503 } ;
14841504
14851505 // Cycles persist to the context store only on inference.done; the recorder
@@ -1718,8 +1738,10 @@ export async function runTUI(initialConfig: Config): Promise<number> {
17181738 error : err instanceof Error ? err . message : String ( err ) ,
17191739 } ) ;
17201740 } ) ;
1721- await persistRunSnapshot ( "done" , { finishedAt : Date . now ( ) } ) ;
1741+ await persistRunSnapshot ( "done" , { finishedAt : Date . now ( ) } , "session-rotation" ) ;
17221742 sessionId = generateSessionId ( ) ;
1743+ // Repointed, not cleared: the process lives on, so the crash handler
1744+ // must keep finding this handle and close out the *new* session.
17231745 activeRunHandle . sessionId = sessionId ;
17241746 startedAt = Date . now ( ) ;
17251747 runTaskTitle = config . task ;
@@ -2388,13 +2410,17 @@ export async function runTUI(initialConfig: Config): Promise<number> {
23882410 // finished run (finishedAt set) can be left reading as still in progress.
23892411 const persistedStatus : RunState [ "status" ] = summaryStatus ;
23902412 finalized = true ;
2391- // writeRunSnapshot clears the active-run handle itself for a terminal
2392- // status (via finalizeRunState in state.ts), pairing the on-disk write
2393- // with the in-memory one instead of setting them at two call sites.
2394- await writeRunSnapshot ( persistedStatus , {
2395- finishedAt,
2396- ...( sinkError !== undefined ? { error : sinkError } : { } ) ,
2397- } ) ;
2413+ // The run itself is over here, so this write clears the active-run handle
2414+ // (via finalizeRunState in state.ts) in the same call, rather than pairing
2415+ // the on-disk write with a separate in-memory statement at this call site.
2416+ await writeRunSnapshot (
2417+ persistedStatus ,
2418+ {
2419+ finishedAt,
2420+ ...( sinkError !== undefined ? { error : sinkError } : { } ) ,
2421+ } ,
2422+ "run-end" ,
2423+ ) ;
23982424 const runSummary = createRunSummary ( {
23992425 task : runTaskTitle . length > 0 ? runTaskTitle : config . task ,
24002426 status : summaryStatus ,
0 commit comments