Skip to content

Commit ad064f9

Browse files
Merge pull request #1484 from corbitsdev/cl-10776-drop-snapshot-debounce
refactor(session): drop the run.json snapshot debounce
2 parents b0a6227 + 340bbe8 commit ad064f9

7 files changed

Lines changed: 85 additions & 189 deletions

File tree

‎src/exec/runner.ts‎

Lines changed: 1 addition & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -102,7 +102,6 @@ import {
102102
clearActiveDisposeHost,
103103
} from "../session/active-host.js";
104104
import {
105-
createRunSnapshotDebouncer,
106105
finalizeRunState,
107106
loadState,
108107
saveState,
@@ -536,18 +535,13 @@ export async function runExec(config: Config): Promise<ExecResult> {
536535
: {}),
537536
};
538537
let stopHeartbeat: (() => void) | undefined;
539-
// CL-10667: debounced per-turn boundary snapshots; terminal exposes flush.
540-
let runSnapshotDebouncer:
541-
| ReturnType<typeof createRunSnapshotDebouncer>
542-
| undefined;
543538

544539
const persist = async (
545540
status: "running" | "done" | "failed" | "cancelled",
546541
extra?: { error?: string },
547542
): Promise<void> => {
548543
if (finalized && status === "running") return;
549544
if (status !== "running") {
550-
await runSnapshotDebouncer?.flush();
551545
finalized = true;
552546
stopHeartbeat?.();
553547
stopHeartbeat = undefined;
@@ -601,9 +595,6 @@ export async function runExec(config: Config): Promise<ExecResult> {
601595
};
602596

603597
await persist("running");
604-
runSnapshotDebouncer = createRunSnapshotDebouncer({
605-
write: () => persist("running"),
606-
});
607598
stopHeartbeat = startRunHeartbeat({
608599
shouldTick: () => !finalized,
609600
tick: () => persist("running"),
@@ -1057,8 +1048,7 @@ export async function runExec(config: Config): Promise<ExecResult> {
10571048
activeRunHandle.cacheWriteModel = `${event.data.source.provider}:${event.data.source.model}`;
10581049
}
10591050
}
1060-
// CL-10667: debounced — the write recomputes state when it fires.
1061-
runSnapshotDebouncer?.schedule();
1051+
void persist("running");
10621052
},
10631053
resolveContextDir: () => workdir,
10641054
});

‎src/session/run-snapshot-debounce.test.ts‎

Lines changed: 0 additions & 82 deletions
This file was deleted.

‎src/session/state.ts‎

Lines changed: 0 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -131,62 +131,6 @@ export async function saveState(
131131
return write;
132132
}
133133

134-
/**
135-
* Debounce window for mid-run `running` snapshots (CL-10667): coalesces the
136-
* per-turn boundary write to ~0.2/turn at a ~1s/turn cadence, while the
137-
* run-liveness heartbeat keeps mtime freshness over longer gaps.
138-
*/
139-
export const RUN_SNAPSHOT_DEBOUNCE_MS = 5_000;
140-
141-
export interface RunSnapshotDebouncer {
142-
/** Mark a pending write and arm the one-shot timer once. */
143-
schedule: () => void;
144-
/** Cancel the timer and run any pending write immediately. */
145-
flush: () => Promise<void>;
146-
}
147-
148-
/**
149-
* Lazy payoff: the deferred write recomputes its snapshot when it fires, not
150-
* at schedule time, so a front of rapid turns persists the state current at
151-
* fire time. One timer; `schedule` while armed only re-marks dirty. The
152-
* writer maps write failures to undefined so a stalled flush cannot wedge
153-
* the terminal path that awaits it.
154-
*/
155-
export function createRunSnapshotDebouncer(args: {
156-
write: () => Promise<void>;
157-
intervalMs?: number;
158-
}): RunSnapshotDebouncer {
159-
const intervalMs = args.intervalMs ?? RUN_SNAPSHOT_DEBOUNCE_MS;
160-
let timer: ReturnType<typeof setTimeout> | null = null;
161-
let dirty = false;
162-
let inFlight: Promise<void> = Promise.resolve();
163-
164-
const run = (): void => {
165-
dirty = false;
166-
inFlight = args.write().catch(() => undefined);
167-
};
168-
169-
return {
170-
schedule: () => {
171-
dirty = true;
172-
if (timer !== null) return;
173-
timer = setTimeout(() => {
174-
timer = null;
175-
if (dirty) run();
176-
}, intervalMs);
177-
timer.unref?.();
178-
},
179-
flush: async () => {
180-
if (timer !== null) {
181-
clearTimeout(timer);
182-
timer = null;
183-
}
184-
if (dirty) run();
185-
await inFlight;
186-
},
187-
};
188-
}
189-
190134
// Terminal-write path: clears the in-memory active-run handle (active-run.ts)
191135
// alongside the on-disk status so the two cannot drift. Non-terminal
192136
// ("running") snapshots use saveState directly.

‎src/tui/runner/exit.test.ts‎

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { describe, expect, spyOn, test } from "bun:test";
22
import { EventEmitter } from "node:events";
33
import { AgentContextLockError, type Agent } from "@intx/agent";
4+
import { getLogger } from "@intx/log";
45
import type { InferenceSource } from "@intx/types/runtime";
56

67
import * as codexSession from "../../auth/codex/session.js";
@@ -17,6 +18,7 @@ import {
1718
} from "../../subagent/session-store.js";
1819
import type { ReactorInboundEvent } from "@intx/types/runtime";
1920
import { defined } from "../../../testkit/defined.js";
21+
import { LOG_NAMESPACE_ROOT } from "../../branding.js";
2022
import {
2123
stubReactorCapabilities,
2224
stubReactorState,
@@ -31,10 +33,12 @@ import {
3133
createDeliveryGeneration,
3234
createSessionOperationQueue,
3335
} from "../delivery-queue.js";
36+
import * as sessionState from "../../session/state.js";
3437
import {
3538
agentRebuildFailure,
3639
closeAgentForRebuild,
3740
createRunLifecycle,
41+
createRunPersistence,
3842
finalizeTUIRun,
3943
observeRecoveryAttempts,
4044
resetSessionForRotation,
@@ -976,6 +980,62 @@ describe("rebuild close helpers", () => {
976980
).toMatch(/restart/i);
977981
});
978982

983+
test("a rejecting saveState on the turn-boundary snapshot does not emit unhandledRejection", async () => {
984+
const save = spyOn(sessionState, "saveState").mockRejectedValue(
985+
new Error("disk full"),
986+
);
987+
const warn = spyOn(getLogger([LOG_NAMESPACE_ROOT, "tui"]), "warn");
988+
const state = {
989+
runTaskTitle: "t",
990+
liveSource: { id: "p", model: "m", credentialId: "cred" },
991+
startedAt: 1,
992+
connectedMcpServers: [],
993+
config: { cwd: "/tmp" },
994+
sessionId: "sess-boundary",
995+
} as unknown as RunnerState;
996+
const services = {
997+
runSink: { getTurnCount: () => 2 },
998+
activatedToolNames: { list: () => [] },
999+
activeRunHandle: {
1000+
sessionId: "sess-boundary",
1001+
cwd: "/tmp",
1002+
task: "t",
1003+
startedAt: 1,
1004+
turnsUsed: 0,
1005+
},
1006+
crashGuard: { isFinalized: () => false },
1007+
} as unknown as RunnerServices;
1008+
state.persistRunSnapshot = createRunPersistence(
1009+
state,
1010+
services,
1011+
).persistRunSnapshot;
1012+
1013+
let unhandled: unknown = null;
1014+
const onUnhandledRejection = (reason: unknown): void => {
1015+
unhandled = reason;
1016+
};
1017+
process.on("unhandledRejection", onUnhandledRejection);
1018+
try {
1019+
// session.ts onTurnBoundarySnapshot: fire-and-forget running snapshot.
1020+
void state.persistRunSnapshot?.("running");
1021+
await new Promise((resolve) => setTimeout(resolve, 0));
1022+
expect(unhandled).toBeNull();
1023+
expect(save).toHaveBeenCalled();
1024+
expect(warn).toHaveBeenCalledWith(
1025+
"saveState failed for session {sessionId} status={status}: {error}",
1026+
{
1027+
sessionId: "sess-boundary",
1028+
status: "running",
1029+
error: "disk full",
1030+
},
1031+
);
1032+
} finally {
1033+
process.off("unhandledRejection", onUnhandledRejection);
1034+
save.mockRestore();
1035+
warn.mockRestore();
1036+
}
1037+
});
1038+
9791039
// Overlay accept/decline tests stub bump() inside resolveSuspended, so
9801040
// deleting the interrupt-site bump would not fail them. Drive the interrupt
9811041
// helper itself.

0 commit comments

Comments
 (0)