Skip to content

Commit 7753fd5

Browse files
Merge run.json write serialization and resume seeding
2 parents f78c51b + ffe25ac commit 7753fd5

7 files changed

Lines changed: 218 additions & 10 deletions

File tree

src/session/hooks.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -174,6 +174,9 @@ export type TurnContextCollectorOptions = {
174174
// standing copy of recent history, so callers with nothing to hand it to
175175
// (no lifecycle hook) can opt out of retaining it.
176176
retainHistory?: boolean;
177+
// Resuming a session should continue the persisted run.json turn count
178+
// rather than restart it at zero.
179+
initialTurnCount?: number;
177180
};
178181

179182
export function createTurnContextCollector(
@@ -193,7 +196,7 @@ export function createTurnContextCollector(
193196
} {
194197
const retainHistory = options.retainHistory ?? true;
195198
const turns: TurnContext[] = [];
196-
let turnCount = 0;
199+
let turnCount = options.initialTurnCount ?? 0;
197200
let pending: PendingTurn | null = null;
198201
let cycleStartedAt = now();
199202
let tokenUsage: TokenUsage = { ...emptyUsage };

src/session/run-sink.test.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,24 @@ describe("createRunSink", () => {
7777
expect(runSink.getTokenUsage()).toEqual({ input: 1, output: 1, cacheRead: 0, cacheWrite: 0, thinking: 0 });
7878
});
7979

80+
test("seeds the turn count from a resumed session's prior turnsUsed", () => {
81+
const runSink = createRunSink({
82+
emitter: new EventEmitter(),
83+
hookManager: stubHookManager([]),
84+
initialTurnCount: 7,
85+
});
86+
87+
expect(runSink.getTurnCount()).toBe(7);
88+
89+
runSink.sink(event("inference.done", {
90+
turn: { role: "assistant", content: [], model: "test", timestamp: 0 },
91+
usage: { input: 1, output: 1, cacheRead: 0, cacheWrite: 0, thinking: 0 },
92+
source: { provider: "test", model: "test" },
93+
}));
94+
95+
expect(runSink.getTurnCount()).toBe(8);
96+
});
97+
8098
test("getLastTurnUsage reports the latest turn alone, not the running sum", () => {
8199
const runSink = createRunSink({
82100
emitter: new EventEmitter(),

src/session/run-sink.ts

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,9 @@ export type RunSinkArgs = {
1919
// turn actually ran against, so consumers report per-turn provider/model
2020
// even if the live selection changed mid-run.
2121
onTurnComplete?: (ctx: import("./hooks.js").TurnContext) => void;
22+
// Continues a resumed session's persisted run.json turn count instead of
23+
// restarting the collector at zero.
24+
initialTurnCount?: number;
2225
};
2326

2427
export type RunSink = {
@@ -66,7 +69,7 @@ export function resolveExecRunStatus(args: {
6669
}
6770

6871
export function createRunSink(args: RunSinkArgs): RunSink {
69-
const { emitter, hookManager, onTurnComplete } = args;
72+
const { emitter, hookManager, onTurnComplete, initialTurnCount } = args;
7073

7174
function hasConfiguredHooks(): boolean {
7275
return hookManager.getStatuses().length > 0;
@@ -82,15 +85,19 @@ export function createRunSink(args: RunSinkArgs): RunSink {
8285
onTurnComplete?.(ctx);
8386
};
8487

85-
function createCollector(): TurnCollector {
88+
// The initial seed only applies to the run's first collector (a resumed
89+
// session's prior turnsUsed); a later reset() starts a fresh sub-session
90+
// and should count from zero, not re-seed.
91+
function createCollector(seedTurnCount?: number): TurnCollector {
8692
return createTurnContextCollector(handleTurn, Date.now, {
8793
retainHistory: hasConfiguredHooks(),
94+
...(seedTurnCount !== undefined ? { initialTurnCount: seedTurnCount } : {}),
8895
});
8996
}
9097

9198
let runCompleted = false;
9299
let runError: string | undefined;
93-
let turnCollector = createCollector();
100+
let turnCollector = createCollector(initialTurnCount);
94101
// Always-on local PerfTrace: not gated by lifecycle hooks.
95102
let perfObserver = createPerfReactorObserver();
96103

src/session/state.test.ts

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import { afterEach, beforeEach, expect, mock, test } from "bun:test";
2+
import * as realFs from "node:fs/promises";
3+
import { mkdir, rm } from "node:fs/promises";
4+
import { join } from "node:path";
5+
import { tmpdir } from "node:os";
6+
7+
// Simulates the straggler write's real await point (e.g. cycleRecorder.dispose
8+
// during the terminal path) landing its writeFile after a later-issued
9+
// terminal write's writeFile, so rename-order alone would let it win.
10+
const realWriteFile = realFs.writeFile;
11+
let delayNextWrite = false;
12+
mock.module("node:fs/promises", () => ({
13+
...realFs,
14+
writeFile: async (path: string, data: string) => {
15+
if (delayNextWrite) {
16+
delayNextWrite = false;
17+
await new Promise((resolve) => setTimeout(resolve, 30));
18+
}
19+
return realWriteFile(path, data);
20+
},
21+
}));
22+
23+
const { loadState, saveState } = await import("./state.js");
24+
type RunState = Awaited<ReturnType<typeof loadState>>;
25+
26+
let cwd = "";
27+
let home = "";
28+
29+
beforeEach(async () => {
30+
const stamp = `${Date.now()}-${Math.random().toString(16).slice(2)}`;
31+
cwd = join(tmpdir(), `corbits-state-${stamp}`);
32+
home = join(tmpdir(), `corbits-state-home-${stamp}`);
33+
await mkdir(cwd, { recursive: true });
34+
await mkdir(home, { recursive: true });
35+
});
36+
37+
afterEach(async () => {
38+
await rm(cwd, { recursive: true, force: true });
39+
await rm(home, { recursive: true, force: true });
40+
});
41+
42+
function state(overrides: Partial<NonNullable<RunState>>): NonNullable<RunState> {
43+
return {
44+
status: "running",
45+
turnsUsed: 0,
46+
task: "task",
47+
startedAt: 1,
48+
...overrides,
49+
};
50+
}
51+
52+
test("a straggler snapshot started before a terminal write does not overwrite it", async () => {
53+
const sessionId = "sess-race";
54+
55+
delayNextWrite = true;
56+
const straggler = saveState(cwd, sessionId, state({ status: "running" }), home);
57+
const terminal = saveState(cwd, sessionId, state({ status: "done", finishedAt: 999 }), home);
58+
59+
await Promise.all([straggler, terminal]);
60+
61+
const final = await loadState(cwd, sessionId, home);
62+
expect(final?.status).toBe("done");
63+
expect(final?.finishedAt).toBe(999);
64+
});
65+
66+
test("saveState calls for different sessions do not block each other", async () => {
67+
await Promise.all([
68+
saveState(cwd, "session-a", state({ task: "a" }), home),
69+
saveState(cwd, "session-b", state({ task: "b" }), home),
70+
]);
71+
72+
const a = await loadState(cwd, "session-a", home);
73+
const b = await loadState(cwd, "session-b", home);
74+
expect(a?.task).toBe("a");
75+
expect(b?.task).toBe("b");
76+
});

src/session/state.ts

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,13 +55,40 @@ export function warnUnreadableState(path: string, reason: string): void {
5555
process.stderr.write(`${COMMAND_NAME}: ignoring unreadable state at ${path} (${reason}); starting fresh\n`);
5656
}
5757

58+
// Concurrent saveState calls for the same session (a straggler progress
59+
// snapshot racing a terminal finalize write) have no ordering guarantee
60+
// between their underlying rename()s — the later call could still finish
61+
// first and resurrect a closed run.json as "running". Chaining each session's
62+
// writes onto the previous one forces them to apply in call order, so a
63+
// write issued after another always lands after it regardless of how long
64+
// either write's fs calls take. Keyed by sessionId, not path, since callers
65+
// only ever address one file per session.
66+
const writeChains = new Map<string, Promise<void>>();
67+
5868
export async function saveState(
5969
cwd: string,
6070
sessionId: string,
6171
state: RunState,
6272
home?: string,
6373
): Promise<void> {
64-
await atomicWrite(statePath(cwd, sessionId, home), JSON.stringify(state, null, 2));
74+
const path = statePath(cwd, sessionId, home);
75+
const content = JSON.stringify(state, null, 2);
76+
const previous = writeChains.get(sessionId) ?? Promise.resolve();
77+
const write = previous.then(
78+
() => atomicWrite(path, content),
79+
() => atomicWrite(path, content),
80+
);
81+
// Swallow the error in the chain tail (not in `write`, which still rejects
82+
// for this caller) so one failed save doesn't permanently wedge later
83+
// saves for the same session.
84+
const tail = write.catch(() => {});
85+
writeChains.set(sessionId, tail);
86+
// Once this is the last write for the session, drop the entry so a
87+
// long-lived process doesn't retain a chain per session forever.
88+
void tail.then(() => {
89+
if (writeChains.get(sessionId) === tail) writeChains.delete(sessionId);
90+
});
91+
return write;
6592
}
6693

6794

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 & 5 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,13 +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;
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;
409435

410436
if (config.resumePicker) {
411437
const picked = await pickSession(config.cwd, { includeCompleted: config.force });
412438
if (picked === null) return 0;
413439
sessionId = picked.sessionId;
414440
resumeSkipInitialTask = true;
415441
const pickedState = await loadState(config.cwd, sessionId);
442+
resumeSeed = resolveResumeSeed(pickedState);
416443
if (pickedState !== null) {
417444
startedAt = pickedState.startedAt;
418445
runTaskTitle = pickedState.task;
@@ -435,20 +462,28 @@ export async function runTUI(initialConfig: Config): Promise<number> {
435462
// run.json at all.
436463
await saveState(config.cwd, sessionId, {
437464
status: "running",
438-
turnsUsed: 0,
465+
turnsUsed: resumeSeed.turnsUsed,
439466
task: runTaskTitle.trim().length > 0 ? runTaskTitle.trim() : "(conversation)",
440467
startedAt,
441468
model: `${config.providerName}:${config.model}`,
442-
mcpServers: [],
469+
mcpServers: resumeSeed.mcpServers,
443470
});
444471

445472
// Crash guard: if anything from setup onward throws all the way out of
446473
// runTUI instead of reaching the normal finalize block, this still closes
447474
// out run.json so status and finishedAt never disagree. Declared before the
448475
// try so every fallible step after the minimal write above is covered.
449476
// `finalized` is set by the normal finalize path so this never double-writes
450-
// on a clean exit; it also gates straggler snapshot writes (see
451-
// 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.
452487
let finalized = false;
453488
// Bound after the cycle recorder exists (it needs the session workdir); the
454489
// crash guard is declared first so it covers every fallible step below.
@@ -1305,6 +1340,7 @@ export async function runTUI(initialConfig: Config): Promise<number> {
13051340
const runSink = createRunSink({
13061341
emitter,
13071342
hookManager,
1343+
initialTurnCount: resumeSeed.turnsUsed,
13081344
onTurnComplete: (ctx) => {
13091345
// provider_id is the canonical provider kind, never ctx.source.sourceId:
13101346
// sourceId is the user-typed label from onboarding/settings, and free
@@ -1324,7 +1360,7 @@ export async function runTUI(initialConfig: Config): Promise<number> {
13241360

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

0 commit comments

Comments
 (0)