@@ -169,7 +169,7 @@ import {
169169import { createRunSink } from "../session/run-sink.js" ;
170170import { generateSessionId , initSessionDir , renameSession , sessionContextDir , sessionDir } from "../session/index.js" ;
171171import { resolveSessionLabel , truncateSessionLabel } from "../session/session-label.js" ;
172- import { loadState , saveState , type ConnectedMcpServer , type RunState } from "../session/state.js" ;
172+ import { finalizeRunState , loadState , saveState , type ConnectedMcpServer , type RunState } from "../session/state.js" ;
173173import { setActiveRun , clearActiveRun , type RunStateHandle } from "../session/active-run.js" ;
174174import { setActiveDisposeHost , clearActiveDisposeHost } from "../session/active-host.js" ;
175175import { openInBrowser } from "../auth/oauth/browser.js" ;
@@ -244,6 +244,24 @@ export function resolveResumeSeed(pickedState: RunState | null): ResumeSeed {
244244 } ;
245245}
246246
247+ /**
248+ * Why a run.json snapshot is being written. Only "run-end" ends the run
249+ * itself and so clears the active-run handle that the crash handler in
250+ * index.ts reads.
251+ *
252+ * RunState.status cannot stand in for this. A /clear or /new rotation
253+ * persists a terminal "done" for the outgoing session while the process
254+ * keeps running under a fresh session id, so inferring "the run is over"
255+ * from a non-"running" status disarms crash finalization for everything
256+ * after the first rotation -- the session that dies then never gets its
257+ * terminal record and reads as "running" forever.
258+ */
259+ export type SnapshotKind = "progress" | "session-rotation" | "run-end" ;
260+
261+ export function clearsActiveRun ( kind : SnapshotKind ) : boolean {
262+ return kind === "run-end" ;
263+ }
264+
247265const GRANT_SCOPE_LABEL : Record < GrantScope , string > = {
248266 session : "This session" ,
249267 project : "This project" ,
@@ -507,7 +525,6 @@ export async function runTUI(initialConfig: Config): Promise<number> {
507525 const activeRunHandle : RunStateHandle = {
508526 sessionId,
509527 cwd : config . cwd ,
510- active : true ,
511528 task : runTaskTitle . trim ( ) . length > 0 ? runTaskTitle . trim ( ) : "(conversation)" ,
512529 startedAt,
513530 model : `${ config . providerName } :${ config . model } ` ,
@@ -540,7 +557,15 @@ export async function runTUI(initialConfig: Config): Promise<number> {
540557 const finalizeOnCrash = async ( err : unknown ) : Promise < void > => {
541558 if ( finalized ) return ;
542559 finalized = true ;
543- activeRunHandle . active = false ;
560+ // Clear the active-run handle up front, before the awaits below. This
561+ // handler isn't the only reader of the handle: index.ts installs its own
562+ // uncaughtException/unhandledRejection listeners that call getActiveRun()
563+ // directly and, if it's still set, write a competing "crashed" record via
564+ // saveCrashState. finalizeRunState (state.ts) also clears the handle
565+ // before its own saveState await, but only once it's called below — an
566+ // escaped throw during the flushPartialOnCrash await just above would
567+ // still reach that listener with the handle live, so it's cleared here
568+ // too to close that earlier window.
544569 clearActiveRun ( ) ;
545570 clearActiveDisposeHost ( ) ;
546571 await flushPartialOnCrash ( ) . catch ( ( flushErr : unknown ) => {
@@ -551,7 +576,7 @@ export async function runTUI(initialConfig: Config): Promise<number> {
551576 process . stderr . write ( `${ COMMAND_NAME } : crash finalize partial flush failed: ${ flushMessage } \n` ) ;
552577 } ) ;
553578 const message = err instanceof Error ? err . message : String ( err ) ;
554- await saveState ( config . cwd , sessionId , {
579+ await finalizeRunState ( config . cwd , sessionId , {
555580 status : "failed" ,
556581 turnsUsed : 0 ,
557582 task : runTaskTitle . trim ( ) . length > 0 ? runTaskTitle . trim ( ) : "(conversation)" ,
@@ -1361,6 +1386,7 @@ export async function runTUI(initialConfig: Config): Promise<number> {
13611386 const writeRunSnapshot = async (
13621387 status : RunState [ "status" ] ,
13631388 extra ?: Pick < RunState , "finishedAt" | "error" > ,
1389+ kind : SnapshotKind = "progress" ,
13641390 ) : Promise < void > => {
13651391 const task = runTaskTitle . trim ( ) . length > 0 ? runTaskTitle . trim ( ) : "(conversation)" ;
13661392 const model = `${ liveSource . id } :${ liveSource . model } ` ;
@@ -1369,28 +1395,38 @@ export async function runTUI(initialConfig: Config): Promise<number> {
13691395 activeRunHandle . task = task ;
13701396 activeRunHandle . startedAt = startedAt ;
13711397 activeRunHandle . model = model ;
1372- await saveState ( config . cwd , sessionId , {
1398+ const state : RunState = {
13731399 status,
13741400 turnsUsed : runSink . getTurnCount ( ) ,
13751401 task,
13761402 startedAt,
13771403 model,
13781404 mcpServers : connectedMcpServers ,
13791405 ...extra ,
1380- } ) ;
1406+ } ;
1407+ if ( clearsActiveRun ( kind ) ) {
1408+ await finalizeRunState ( config . cwd , sessionId , state ) ;
1409+ } else {
1410+ await saveState ( config . cwd , sessionId , state ) ;
1411+ }
13811412 } ;
13821413
13831414 // Progress snapshots are fired unsequenced (model switch, MCP connect, turn
13841415 // completion), so a straggler could otherwise land after the terminal write
13851416 // and resurrect status "running" — atomicWrite is last-rename-wins. Once the
1386- // run is finalized, drop them; the terminal paths write through
1417+ // run is finalized, drop them; the run-ending path writes through
13871418 // writeRunSnapshot directly.
1419+ //
1420+ // Never a "run-end" write: everything routed here happens while the process
1421+ // is still alive and must stay crash-coverable, including the rotation
1422+ // "done" that closes out a session on /clear or /new.
13881423 const persistRunSnapshot = async (
13891424 status : RunState [ "status" ] ,
13901425 extra ?: Pick < RunState , "finishedAt" | "error" > ,
1426+ kind : Exclude < SnapshotKind , "run-end" > = "progress" ,
13911427 ) : Promise < void > => {
13921428 if ( finalized ) return ;
1393- await writeRunSnapshot ( status , extra ) ;
1429+ await writeRunSnapshot ( status , extra , kind ) ;
13941430 } ;
13951431
13961432 // Cycles persist to the context store only on inference.done; the recorder
@@ -1629,8 +1665,10 @@ export async function runTUI(initialConfig: Config): Promise<number> {
16291665 error : err instanceof Error ? err . message : String ( err ) ,
16301666 } ) ;
16311667 } ) ;
1632- await persistRunSnapshot ( "done" , { finishedAt : Date . now ( ) } ) ;
1668+ await persistRunSnapshot ( "done" , { finishedAt : Date . now ( ) } , "session-rotation" ) ;
16331669 sessionId = generateSessionId ( ) ;
1670+ // Repointed, not cleared: the process lives on, so the crash handler
1671+ // must keep finding this handle and close out the *new* session.
16341672 activeRunHandle . sessionId = sessionId ;
16351673 startedAt = Date . now ( ) ;
16361674 runTaskTitle = config . task ;
@@ -2308,13 +2346,22 @@ export async function runTUI(initialConfig: Config): Promise<number> {
23082346 // finished run (finishedAt set) can be left reading as still in progress.
23092347 const persistedStatus : RunState [ "status" ] = summaryStatus ;
23102348 finalized = true ;
2311- activeRunHandle . active = false ;
2312- clearActiveRun ( ) ;
2349+ // The run itself is over here, so this write clears the active-run handle
2350+ // (via finalizeRunState in state.ts) in the same call, rather than pairing
2351+ // the on-disk write with a separate in-memory statement at this call site.
2352+ // The dispose host has no on-disk counterpart to piggyback on, so it still
2353+ // needs its own clear here, mirroring finalizeOnCrash — otherwise a signal
2354+ // arriving after this normal exit would find a handle pointing at a
2355+ // torn-down closure.
23132356 clearActiveDisposeHost ( ) ;
2314- await writeRunSnapshot ( persistedStatus , {
2315- finishedAt,
2316- ...( sinkError !== undefined ? { error : sinkError } : { } ) ,
2317- } ) ;
2357+ await writeRunSnapshot (
2358+ persistedStatus ,
2359+ {
2360+ finishedAt,
2361+ ...( sinkError !== undefined ? { error : sinkError } : { } ) ,
2362+ } ,
2363+ "run-end" ,
2364+ ) ;
23182365 const runSummary = createRunSummary ( {
23192366 task : runTaskTitle . length > 0 ? runTaskTitle : config . task ,
23202367 status : summaryStatus ,
0 commit comments