Skip to content

Commit 0e93e44

Browse files
committed
Assert interrupt bumps delivery generation before rebuild
1 parent 7333c1e commit 0e93e44

2 files changed

Lines changed: 73 additions & 28 deletions

File tree

src/tui/runner/exit.ts

Lines changed: 47 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,22 @@ export function agentRebuildFailure(err: unknown): Error {
120120
: new Error(String(err));
121121
}
122122

123+
/**
124+
* Hard-stop interrupt: bump delivery generation, then enqueue the agent rebuild.
125+
* Overlay stays open across interrupt; the bump must happen before enqueue so a
126+
* later accept/decline cannot late-bind into the rebuilt agent.
127+
*/
128+
export function startInterruptRebuild(args: {
129+
deliveryGeneration: { bump: () => void };
130+
markSendAborted: () => void;
131+
enqueue: (op: () => Promise<void>) => unknown;
132+
rebuild: () => Promise<void>;
133+
}): void {
134+
args.deliveryGeneration.bump();
135+
args.markSendAborted();
136+
void args.enqueue(args.rebuild);
137+
}
138+
123139
export function clearsActiveRun(kind: SnapshotKind): boolean {
124140
return kind === "run-end";
125141
}
@@ -401,36 +417,39 @@ export async function createRunLifecycle(
401417
// Close it, drain the old stream, and rebuild a fresh agent so the next send
402418
// works.
403419
const interrupt = (): void => {
404-
// Overlay stays open across interrupt; bump so a later accept/decline
405-
// cannot late-bind into the rebuilt agent.
406-
services.deliveryGeneration.bump();
407-
state.sendAborted = true;
408-
void enqueueOp(async () => {
409-
try {
410-
// close() tears down stream consumers before the aborted cycle's
411-
// inference.error is delivered, so the recorder never sees a terminal
412-
// event for the dead cycle — dispose closes it against stray deltas
413-
// and salvages the buffer before that teardown, so it is never lost
414-
// or misattributed to the rebuilt agent's next cycle.
415-
await services.cycleRecorder.dispose("interrupted");
416-
const closedCleanly = await closeAgentForRebuild(liveAgent(state), "interrupt");
417-
await state.streamPromise?.catch((err: unknown) => {
418-
tuiLogger.debug("stream drain during interrupt teardown failed: {error}", {
419-
error: err instanceof Error ? err.message : String(err),
420+
startInterruptRebuild({
421+
deliveryGeneration: services.deliveryGeneration,
422+
markSendAborted: () => {
423+
state.sendAborted = true;
424+
},
425+
enqueue: enqueueOp,
426+
rebuild: async () => {
427+
try {
428+
// close() tears down stream consumers before the aborted cycle's
429+
// inference.error is delivered, so the recorder never sees a terminal
430+
// event for the dead cycle — dispose closes it against stray deltas
431+
// and salvages the buffer before that teardown, so it is never lost
432+
// or misattributed to the rebuilt agent's next cycle.
433+
await services.cycleRecorder.dispose("interrupted");
434+
const closedCleanly = await closeAgentForRebuild(liveAgent(state), "interrupt");
435+
await state.streamPromise?.catch((err: unknown) => {
436+
tuiLogger.debug("stream drain during interrupt teardown failed: {error}", {
437+
error: err instanceof Error ? err.message : String(err),
438+
});
420439
});
421-
});
422-
if (!closedCleanly) {
423-
throw new AgentContextLockError(state.workdir);
440+
if (!closedCleanly) {
441+
throw new AgentContextLockError(state.workdir);
442+
}
443+
state.currentAgent = await services.buildAgent();
444+
services.cycleRecorder.reset();
445+
state.streamPromise = consumeStream(liveAgent(state).stream(), streamSink);
446+
services.workflowController.reattach();
447+
state.fatalBuildError = null;
448+
} catch (err) {
449+
recordRunError(state, err);
450+
state.fatalBuildError = agentRebuildFailure(err);
424451
}
425-
state.currentAgent = await services.buildAgent();
426-
services.cycleRecorder.reset();
427-
state.streamPromise = consumeStream(liveAgent(state).stream(), streamSink);
428-
services.workflowController.reattach();
429-
state.fatalBuildError = null;
430-
} catch (err) {
431-
recordRunError(state, err);
432-
state.fatalBuildError = agentRebuildFailure(err);
433-
}
452+
},
434453
});
435454
};
436455
state.interrupt = interrupt;

tests/unit/tui/runner.test.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {
55
agentRebuildFailure,
66
closeAgentForRebuild,
77
resumeTranscriptLoadErrorBlock,
8+
startInterruptRebuild,
89
} from "../../../src/tui/runner/exit.js";
910
import { createTUIEventEmitter, getTUIRunSummaryStatus } from "../../../src/tui/runner/index.js";
1011
import { loadLocalSettingsWriteBase } from "../../../src/tui/runner/settings.js";
@@ -287,3 +288,28 @@ test("a rejecting reload op through the real session-operation-queue never trigg
287288
// failing a single assertion. The test above is the harness-compatible half
288289
// of that pair: same real queue, same real helpers, proving the fixed shape
289290
// produces no such failure.
291+
292+
// Overlay accept/decline tests stub bump() inside resolveSuspended, so deleting
293+
// the interrupt-site bump would not fail them. Drive the interrupt helper itself.
294+
test("interrupt bumps delivery generation before enqueueing rebuild", () => {
295+
const order: string[] = [];
296+
startInterruptRebuild({
297+
deliveryGeneration: {
298+
bump: () => {
299+
order.push("bump");
300+
},
301+
},
302+
markSendAborted: () => {
303+
order.push("abort");
304+
},
305+
enqueue: (op) => {
306+
order.push("enqueue");
307+
return op();
308+
},
309+
rebuild: async () => {
310+
order.push("rebuild");
311+
},
312+
});
313+
expect(order[0]).toBe("bump");
314+
expect(order.indexOf("enqueue")).toBeGreaterThan(0);
315+
});

0 commit comments

Comments
 (0)