Skip to content

Commit 67ce98c

Browse files
committed
Resolve the resumed run's turnsUsed and mcpServers once at the resume boundary
Reading resumedState?.turnsUsed and resumedState?.mcpServers separately at each of the three sites that needed them meant the empty/zero default was decided three times instead of once. resolveResumeSeed folds a picked session's run.json into a single concrete seed right where it is loaded, so createRunSink, connectedMcpServers, and the immediate post-resume saveState all read a trusted value with no further defaulting. Documents why runner.ts's finalized flag still earns its place now that saveState serializes writes per session: the write chain only orders writes that are already issued, it has no way to know a stale post-finalize snapshot shouldn't be issued at all.
1 parent ad179a9 commit 67ce98c

2 files changed

Lines changed: 82 additions & 10 deletions

File tree

src/tui/resume-seed.test.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import { describe, test, expect } from "bun:test";
2+
import { resolveResumeSeed } from "./runner.js";
3+
import type { RunState } from "../session/state.js";
4+
5+
function pickedState(overrides: Partial<RunState>): RunState {
6+
return {
7+
status: "running",
8+
turnsUsed: 0,
9+
task: "task",
10+
startedAt: 1,
11+
...overrides,
12+
};
13+
}
14+
15+
describe("resolveResumeSeed", () => {
16+
test("a fresh (non-resumed) run seeds zero turns and no servers", () => {
17+
expect(resolveResumeSeed(null)).toEqual({ turnsUsed: 0, mcpServers: [] });
18+
});
19+
20+
test("carries forward a resumed session's non-zero turnsUsed and non-empty mcpServers", () => {
21+
const seed = resolveResumeSeed(
22+
pickedState({
23+
turnsUsed: 12,
24+
mcpServers: [{ name: "filesystem", toolCount: 5 }, { name: "search", toolCount: 2 }],
25+
}),
26+
);
27+
28+
expect(seed.turnsUsed).toBe(12);
29+
expect(seed.mcpServers).toEqual([
30+
{ name: "filesystem", toolCount: 5 },
31+
{ name: "search", toolCount: 2 },
32+
]);
33+
});
34+
35+
test("defaults mcpServers to empty when a resumed record predates that field", () => {
36+
const seed = resolveResumeSeed(pickedState({ turnsUsed: 3 }));
37+
38+
expect(seed.turnsUsed).toBe(3);
39+
expect(seed.mcpServers).toEqual([]);
40+
});
41+
});

src/tui/runner.ts

Lines changed: 41 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -211,6 +211,29 @@ export function resumeTranscriptLoadErrorBlock(err: unknown): {
211211
return { type: "error", message: `Could not load prior session transcript: ${message}` };
212212
}
213213

214+
export type ResumeSeed = {
215+
turnsUsed: number;
216+
mcpServers: ConnectedMcpServer[];
217+
};
218+
219+
const FRESH_RESUME_SEED: ResumeSeed = { turnsUsed: 0, mcpServers: [] };
220+
221+
/**
222+
* Fold a resumed session's run.json into a concrete seed once, at the
223+
* resume boundary, so every downstream reader (the run sink, the
224+
* connected-servers list, the immediate post-resume saveState) trusts a
225+
* fully-populated value instead of each repeating its own `?? 0` / `?? []`
226+
* default. A fresh (non-resumed) run gets the same shape via
227+
* FRESH_RESUME_SEED, so callers never branch on "was this a resume."
228+
*/
229+
export function resolveResumeSeed(pickedState: RunState | null): ResumeSeed {
230+
if (pickedState === null) return FRESH_RESUME_SEED;
231+
return {
232+
turnsUsed: pickedState.turnsUsed,
233+
mcpServers: pickedState.mcpServers ?? [],
234+
};
235+
}
236+
214237
const GRANT_SCOPE_LABEL: Record<GrantScope, string> = {
215238
session: "This session",
216239
project: "This project",
@@ -406,17 +429,17 @@ export async function runTUI(initialConfig: Config): Promise<number> {
406429
let resumeSkipInitialTask = config.skipInitialTask === true;
407430
let startedAt = Date.now();
408431
let runTaskTitle = config.task;
409-
// Carries the resumed session's prior run.json forward so turnsUsed and
410-
// mcpServers can be seeded instead of silently reset to zero/empty.
411-
let resumedState: RunState | null = null;
432+
// Resolved once at the resume boundary so turnsUsed/mcpServers reads
433+
// downstream never repeat their own omission-handling default.
434+
let resumeSeed: ResumeSeed = FRESH_RESUME_SEED;
412435

413436
if (config.resumePicker) {
414437
const picked = await pickSession(config.cwd, { includeCompleted: config.force });
415438
if (picked === null) return 0;
416439
sessionId = picked.sessionId;
417440
resumeSkipInitialTask = true;
418441
const pickedState = await loadState(config.cwd, sessionId);
419-
resumedState = pickedState;
442+
resumeSeed = resolveResumeSeed(pickedState);
420443
if (pickedState !== null) {
421444
startedAt = pickedState.startedAt;
422445
runTaskTitle = pickedState.task;
@@ -439,20 +462,28 @@ export async function runTUI(initialConfig: Config): Promise<number> {
439462
// run.json at all.
440463
await saveState(config.cwd, sessionId, {
441464
status: "running",
442-
turnsUsed: resumedState?.turnsUsed ?? 0,
465+
turnsUsed: resumeSeed.turnsUsed,
443466
task: runTaskTitle.trim().length > 0 ? runTaskTitle.trim() : "(conversation)",
444467
startedAt,
445468
model: `${config.providerName}:${config.model}`,
446-
mcpServers: resumedState?.mcpServers ?? [],
469+
mcpServers: resumeSeed.mcpServers,
447470
});
448471

449472
// Crash guard: if anything from setup onward throws all the way out of
450473
// runTUI instead of reaching the normal finalize block, this still closes
451474
// out run.json so status and finishedAt never disagree. Declared before the
452475
// try so every fallible step after the minimal write above is covered.
453476
// `finalized` is set by the normal finalize path so this never double-writes
454-
// on a clean exit; it also gates straggler snapshot writes (see
455-
// persistRunSnapshot) from resurrecting a closed record.
477+
// on a clean exit. It also gates persistRunSnapshot (below) from *issuing*
478+
// a straggler write at all once the run is closed — a different job from
479+
// saveState's per-session write ordering in state.ts. That ordering only
480+
// decides which already-issued write lands last; it has no way to know a
481+
// "running" snapshot fired after finalize is stale and should never be
482+
// written in the first place. Without this flag such a snapshot would
483+
// still queue behind the terminal write and legitimately "win" the
484+
// ordering, resurrecting a closed run.json. Two different constraints
485+
// (don't issue a stale write vs. order the writes you do issue), each
486+
// owned by its own layer — not a duplicate check.
456487
let finalized = false;
457488
// Bound after the cycle recorder exists (it needs the session workdir); the
458489
// crash guard is declared first so it covers every fallible step below.
@@ -1309,7 +1340,7 @@ export async function runTUI(initialConfig: Config): Promise<number> {
13091340
const runSink = createRunSink({
13101341
emitter,
13111342
hookManager,
1312-
...(resumedState !== null ? { initialTurnCount: resumedState.turnsUsed } : {}),
1343+
initialTurnCount: resumeSeed.turnsUsed,
13131344
onTurnComplete: (ctx) => {
13141345
// provider_id is the canonical provider kind, never ctx.source.sourceId:
13151346
// sourceId is the user-typed label from onboarding/settings, and free
@@ -1329,7 +1360,7 @@ export async function runTUI(initialConfig: Config): Promise<number> {
13291360

13301361
// MCP servers connected so far, keyed by name so a reconnect after a failure
13311362
// replaces rather than duplicates the entry.
1332-
let connectedMcpServers: ConnectedMcpServer[] = resumedState?.mcpServers ?? [];
1363+
let connectedMcpServers: ConnectedMcpServer[] = resumeSeed.mcpServers;
13331364
// Every configured server's latest state, for the /mcp surface. Unlike
13341365
// `connectedMcpServers` (persisted run metadata) this keeps the ones that
13351366
// failed or are still waiting on authorization.

0 commit comments

Comments
 (0)