Skip to content

Commit 810db02

Browse files
Preserve turnsUsed on crash and signal finalizers (#890)
* Preserve turnsUsed on crash and signal finalizers Crash and signal paths previously hard-coded turnsUsed to 0 even when mid-run snapshots had already advanced the live counter. Carry the count on the active-run handle and sync it with every TUI and exec snapshot so terminal writes copy the in-memory value without reading disk. * Reseed the crash handle when rotation repoints the session
1 parent f9eb3f7 commit 810db02

18 files changed

Lines changed: 190 additions & 32 deletions

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,9 @@ parallel copies under `docs/` or `scripts/notes/`. At cut time: rename
4343

4444
### Fixed
4545

46+
- Crash and signal finalizers preserve `turnsUsed` from the in-memory active-run
47+
handle instead of writing `0`, so a signaled or crashed session keeps the
48+
turn count already persisted by mid-run snapshots.
4649
- Occupancy takes one dry-episode shot when the parent settles idle even if the
4750
live fleet 1→0 edge was never observed.
4851
- Dry-fleet transcript and `/status` report the outcome tally only

src/exec/runner.ts

Lines changed: 24 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,11 @@ import {
7070
sessionContextDir,
7171
sessionDir,
7272
} from "../session/index.js";
73-
import { setActiveRun } from "../session/active-run.js";
73+
import {
74+
setActiveRun,
75+
syncRunStateHandle,
76+
type RunStateHandle,
77+
} from "../session/active-run.js";
7478
import {
7579
setActiveDisposeHost,
7680
clearActiveDisposeHost,
@@ -377,19 +381,35 @@ export async function runExec(config: Config): Promise<ExecResult> {
377381
let providerFailureObserved = false;
378382
let providerError: InferenceErrorLike | undefined;
379383
let result: ExecResult | undefined;
384+
const activeRunHandle: RunStateHandle = {
385+
sessionId,
386+
cwd: config.cwd,
387+
task,
388+
startedAt,
389+
turnsUsed: 0,
390+
model: `${config.providerName}:${config.model}`,
391+
};
380392

381393
const persist = async (
382394
status: "running" | "done" | "failed" | "cancelled",
383395
extra?: { error?: string },
384396
): Promise<void> => {
385397
if (finalized && status === "running") return;
386398
if (status !== "running") finalized = true;
399+
const model = `${config.providerName}:${config.model}`;
400+
const nextTurnsUsed = runSink?.getTurnCount() ?? turnsUsed;
401+
syncRunStateHandle(activeRunHandle, {
402+
turnsUsed: nextTurnsUsed,
403+
task,
404+
startedAt,
405+
model,
406+
});
387407
const snapshot = {
388408
status,
389-
turnsUsed: runSink?.getTurnCount() ?? turnsUsed,
409+
turnsUsed: nextTurnsUsed,
390410
task,
391411
startedAt,
392-
model: `${config.providerName}:${config.model}`,
412+
model,
393413
mcpServers: connectedMcp,
394414
...(status !== "running" ? { finishedAt: Date.now() } : {}),
395415
...(extra?.error !== undefined ? { error: extra.error } : {}),
@@ -398,13 +418,7 @@ export async function runExec(config: Config): Promise<ExecResult> {
398418
status === "running"
399419
? saveState(config.cwd, sessionId, snapshot).then(() => {
400420
if (!finalized) {
401-
setActiveRun({
402-
sessionId,
403-
cwd: config.cwd,
404-
task,
405-
startedAt,
406-
model: `${config.providerName}:${config.model}`,
407-
});
421+
setActiveRun(activeRunHandle);
408422
}
409423
})
410424
: finalizeRunState(config.cwd, sessionId, snapshot);

src/index.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -207,8 +207,8 @@ export async function handleFatal(
207207
// (e.g. a throw inside a fire-and-forget `void` call), so run.json was never
208208
// closed out. getActiveRun surfaces the in-flight session set by the in-flight
209209
// runner (TUI or exec), with
210-
// enough (task, startedAt, model) carried on the handle itself that no read
211-
// of run.json is needed — a readFile here would be exactly the kind of
210+
// enough (task, startedAt, model, turnsUsed) carried on the handle itself that
211+
// no read of run.json is needed — a readFile here would be exactly the kind of
212212
// unbounded crash-path I/O primeCrashReporting (src/crash/report.ts) exists
213213
// to avoid for git: a stalled disk or network mount would block process.exit
214214
// forever. The write itself goes through saveCrashState, which bypasses the
@@ -222,7 +222,7 @@ async function finalizeActiveRunOnCrash(error: unknown): Promise<void> {
222222
try {
223223
await saveCrashState(run.cwd, run.sessionId, {
224224
status: "crashed",
225-
turnsUsed: 0,
225+
turnsUsed: run.turnsUsed,
226226
task: run.task,
227227
startedAt: run.startedAt,
228228
finishedAt: Date.now(),
@@ -267,7 +267,7 @@ async function finalizeActiveRunOnSignal(
267267
try {
268268
await saveCrashState(run.cwd, run.sessionId, {
269269
status: "failed",
270-
turnsUsed: 0,
270+
turnsUsed: run.turnsUsed,
271271
task: run.task,
272272
startedAt: run.startedAt,
273273
finishedAt: Date.now(),

src/session/active-run.test.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import { describe, expect, test } from "bun:test";
2+
3+
import {
4+
clearActiveRun,
5+
getActiveRun,
6+
setActiveRun,
7+
syncRunStateHandle,
8+
type RunStateHandle,
9+
} from "./active-run.js";
10+
11+
describe("syncRunStateHandle", () => {
12+
test("updates turnsUsed and identity fields on the live handle", () => {
13+
clearActiveRun();
14+
const handle: RunStateHandle = {
15+
sessionId: "sess",
16+
cwd: "/tmp",
17+
task: "old",
18+
startedAt: 1,
19+
turnsUsed: 0,
20+
model: "provider:old",
21+
};
22+
setActiveRun(handle);
23+
24+
syncRunStateHandle(handle, {
25+
turnsUsed: 7,
26+
task: "new",
27+
startedAt: 42,
28+
model: "provider:new",
29+
});
30+
31+
expect(getActiveRun()).toBe(handle);
32+
expect(handle.turnsUsed).toBe(7);
33+
expect(handle.task).toBe("new");
34+
expect(handle.startedAt).toBe(42);
35+
expect(handle.model).toBe("provider:new");
36+
clearActiveRun();
37+
});
38+
});

src/session/active-run.ts

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,8 @@
44
// consulted from the crash path and from signal handlers: a run that never
55
// crashes and is never signaled never has this read.
66
//
7-
// Carries enough of the live run state (task, startedAt, model) that the
8-
// crash handler can build a full RunState record itself. It must not read
7+
// Carries enough of the live run state (task, startedAt, model, turnsUsed) that
8+
// the crash handler can build a full RunState record itself. It must not read
99
// run.json back off disk to fill these in — an unbounded readFile on the
1010
// crash path has the exact failure mode primeCrashReporting (src/crash/
1111
// report.ts) exists to avoid for git: a stalled disk or network mount would
@@ -20,9 +20,30 @@ export interface RunStateHandle {
2020
cwd: string;
2121
task: string;
2222
startedAt: number;
23+
turnsUsed: number;
2324
model?: string;
2425
}
2526

27+
// Keep the crash/signal handle in step with every persisted snapshot so a
28+
// terminal write never falls back to turnsUsed: 0 when the live run has
29+
// already advanced past that.
30+
export function syncRunStateHandle(
31+
handle: RunStateHandle,
32+
snapshot: {
33+
turnsUsed: number;
34+
task: string;
35+
startedAt: number;
36+
model?: string;
37+
},
38+
): void {
39+
handle.turnsUsed = snapshot.turnsUsed;
40+
handle.task = snapshot.task;
41+
handle.startedAt = snapshot.startedAt;
42+
if (snapshot.model !== undefined) {
43+
handle.model = snapshot.model;
44+
}
45+
}
46+
2647
let activeRun: RunStateHandle | null = null;
2748

2849
export function setActiveRun(handle: RunStateHandle): void {

src/session/state.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,7 @@ test("a straggler snapshot started before a terminal write does not overwrite it
8080

8181
test("a persisted terminal status agrees with the active-run handle without a second call site", async () => {
8282
const sessionId = "sess-terminal";
83-
setActiveRun({ sessionId, cwd, task: "task", startedAt: 1 });
83+
setActiveRun({ sessionId, cwd, task: "task", startedAt: 1, turnsUsed: 0 });
8484

8585
await finalizeRunState(
8686
cwd,

src/tui/run-snapshot-kind.test.ts

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,13 @@ describe("a snapshot write dispatched by kind", () => {
6969
});
7070

7171
test("a rotation still records the outgoing session but leaves the run crash-coverable", async () => {
72-
setActiveRun({ sessionId: "old", cwd, task: "task", startedAt: 1 });
72+
setActiveRun({
73+
sessionId: "old",
74+
cwd,
75+
task: "task",
76+
startedAt: 1,
77+
turnsUsed: 0,
78+
});
7379

7480
await write(
7581
"old",
@@ -87,7 +93,13 @@ describe("a snapshot write dispatched by kind", () => {
8793
});
8894

8995
test("the run-ending write records the session and disarms the handle", async () => {
90-
setActiveRun({ sessionId: "last", cwd, task: "task", startedAt: 1 });
96+
setActiveRun({
97+
sessionId: "last",
98+
cwd,
99+
task: "task",
100+
startedAt: 1,
101+
turnsUsed: 0,
102+
});
91103

92104
await write(
93105
"last",

src/tui/runner/exit.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ function stubQuit(args: {
4949
getToolCallCount: () => 0,
5050
},
5151
crashGuard: { markFinalized: () => undefined, isFinalized: () => false },
52-
activeRunHandle: { task: "", startedAt: 0, model: "" },
52+
activeRunHandle: { task: "", startedAt: 0, turnsUsed: 0, model: "" },
5353
hookManager: { dispatchPostRun: async () => undefined },
5454
liveSessionMode: "orchestrator",
5555
} as unknown as RunnerServices;

src/tui/runner/exit.ts

Lines changed: 26 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import {
2929
truncateSessionLabel,
3030
} from "../../session/session-label.js";
3131
import { clearActiveDisposeHost } from "../../session/active-host.js";
32+
import { syncRunStateHandle } from "../../session/active-run.js";
3233
import { getValidCodexToken } from "../../auth/codex/session.js";
3334
import { getValidXaiToken } from "../../auth/xai/session.js";
3435
import { suppressProviderFailurePresentation } from "../provider/failure-attempt.js";
@@ -185,12 +186,16 @@ function createRunPersistence(state: RunnerState, services: RunnerServices) {
185186
const model = `${state.liveSource.id}:${state.liveSource.model}`;
186187
// Kept in step with every persisted snapshot so the crash handler's copy
187188
// (activeRunHandle, read by index.ts) never lags what's actually on disk.
188-
services.activeRunHandle.task = task;
189-
services.activeRunHandle.startedAt = state.startedAt;
190-
services.activeRunHandle.model = model;
189+
const turnsUsed = services.runSink.getTurnCount();
190+
syncRunStateHandle(services.activeRunHandle, {
191+
turnsUsed,
192+
task,
193+
startedAt: state.startedAt,
194+
model,
195+
});
191196
const persisted: RunState = {
192197
status,
193-
turnsUsed: services.runSink.getTurnCount(),
198+
turnsUsed,
194199
task,
195200
startedAt: state.startedAt,
196201
model,
@@ -586,11 +591,25 @@ export async function createRunLifecycle(
586591
"session-rotation",
587592
);
588593
state.sessionId = generateSessionId();
589-
// Repointed, not cleared: the process lives on, so the crash handler
590-
// must keep finding this handle and close out the *new* session.
591-
services.activeRunHandle.sessionId = state.sessionId;
592594
state.startedAt = Date.now();
593595
state.runTaskTitle = state.config.task;
596+
const rotatedBundle = services.buildSessionSources();
597+
// Repointed, not cleared: the process lives on, so the crash handler
598+
// must keep finding this handle and close out the *new* session. The
599+
// fields it copies reseed with the repoint — a crash inside
600+
// initSessionDir/buildAgent below would otherwise stamp the outgoing
601+
// session's turnsUsed (and task, startedAt, model) onto a session
602+
// that has run zero turns.
603+
services.activeRunHandle.sessionId = state.sessionId;
604+
syncRunStateHandle(services.activeRunHandle, {
605+
turnsUsed: 0,
606+
task:
607+
state.runTaskTitle.trim().length > 0
608+
? state.runTaskTitle.trim()
609+
: "(conversation)",
610+
startedAt: state.startedAt,
611+
model: `${rotatedBundle.selected.id}:${rotatedBundle.selected.model}`,
612+
});
594613
services.emitter.emit(
595614
"session.title",
596615
state.runTaskTitle.trim().length > 0
@@ -599,7 +618,6 @@ export async function createRunLifecycle(
599618
);
600619
state.workdir = sessionContextDir(state.config.cwd, state.sessionId);
601620
await initSessionDir(state.config.cwd, state.sessionId);
602-
const rotatedBundle = services.buildSessionSources();
603621
state.liveSources = rotatedBundle.sources;
604622
state.liveDefaultSource = rotatedBundle.defaultSource;
605623
state.liveSource = rotatedBundle.selected;

src/tui/session-start.test.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { describe, expect, test } from "bun:test";
22

33
import { withMockedModuleDuring } from "../../tests/helpers/mock-module.js";
4+
import { setActiveRun, clearActiveRun } from "../session/active-run.js";
45
import type { RunState } from "../session/state.js";
56

67
describe("createTUICrashGuard", () => {
@@ -20,6 +21,15 @@ describe("createTUICrashGuard", () => {
2021
},
2122
}),
2223
async () => {
24+
clearActiveRun();
25+
setActiveRun({
26+
sessionId: "live-session",
27+
cwd: "/live-cwd",
28+
task: "live task",
29+
startedAt: 99,
30+
turnsUsed: 4,
31+
model: "live-provider:live-model",
32+
});
2333
const { createTUICrashGuard } = await import("./session-start.js");
2434
const guard = createTUICrashGuard(() => ({
2535
cwd: "/boot-cwd",
@@ -64,6 +74,8 @@ describe("createTUICrashGuard", () => {
6474
expect(captured[0]?.state.startedAt).toBe(99);
6575
expect(captured[0]?.state.error).toBe("boom");
6676
expect(captured[0]?.state.model).toBe("live-provider:live-model");
77+
expect(captured[0]?.state.turnsUsed).toBe(4);
78+
clearActiveRun();
6779
},
6880
);
6981
});

0 commit comments

Comments
 (0)