Skip to content

Commit d0a70d6

Browse files
committed
fix(session): record a decision an interrupt skips as a dropped approval
The parked cancel is unregistered before an operator's decision is delivered, so an interrupt that bumped the generation while the decision waited on the session queue skipped it silently. Nothing reached either agent, the call was marked handed over so a later recovery ignored it, and the rebuilt agent answered it with "approval timed out" at the old deadline.
1 parent ba3c091 commit d0a70d6

9 files changed

Lines changed: 314 additions & 42 deletions

‎e2e/reactor-approval-reload.test.ts‎

Lines changed: 106 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import {
2525
DROPPED_APPROVAL_SETTLED_EVENT,
2626
type DroppedApprovalCalls,
2727
} from "../src/agent/director.js";
28+
import { createQueuedApprovalDeliver } from "../src/tui/approval-delivery.js";
2829
import {
2930
createDeliveryGeneration,
3031
createSessionOperationQueue,
@@ -427,13 +428,25 @@ const ADAPTERS: Adapter[] = [
427428

428429
interface Dropped extends Reloaded {
429430
resume: ApprovalResume;
431+
correlationId: string;
432+
/** Events from the parked agent, up to its close. */
433+
parkedEvents: ReactorEmittedEvent[];
434+
/** The calls the closed agent's store still showed parked at the rebuild. */
435+
parkedAtRebuild: (string | undefined)[];
430436
}
431437

438+
/** Where the interrupt lands relative to the operator's decision. */
439+
type InterruptPoint =
440+
/** Before the prompt opens, while the approval resolves its parked call. */
441+
| "resolving-call"
442+
/** After an approval, while it waits behind a busy session queue. */
443+
| "decision-queued";
444+
432445
/**
433-
* Parks `call` on an ask, then interrupts while the approval is still
434-
* resolving its parked call, so the drop sends the old agent nothing. The
435-
* rebuild settles the drop on the rebuilt agent, as the TUI's interrupt does,
436-
* and no reply is scripted for it.
446+
* Parks `call` on an ask, then interrupts at `opts.interruptWhile` (while
447+
* the approval resolves its parked call, by default), so no decision reaches
448+
* a reactor that runs it. The rebuild settles the drop on the rebuilt agent,
449+
* as the TUI's interrupt does, and no reply is scripted for it.
437450
*/
438451
async function dropThenInterrupt(
439452
call: { name: string; args: Record<string, unknown> },
@@ -445,21 +458,36 @@ async function dropThenInterrupt(
445458
* slower than that reactor's own.
446459
*/
447460
deadlinePassesDuringRebuild?: boolean;
461+
interruptWhile?: InterruptPoint;
448462
} = {},
449463
): Promise<Dropped> {
464+
const interruptWhile = opts.interruptWhile ?? "resolving-call";
450465
// Every build's director claims from the resume, which needs the session.
451466
let claimFrom: ApprovalResume | undefined;
452467
const droppedCalls: DroppedApprovalCalls = {
453468
claim: (callId) => claimFrom?.droppedCalls.claim(callId) === true,
454469
};
455-
const { live, sessionOpts, ctx, clock, suspended, callId, deadline } =
456-
await parkOnAsk(call, { adapter, droppedApprovalCalls: droppedCalls });
470+
const {
471+
live,
472+
sessionOpts,
473+
ctx,
474+
clock,
475+
suspended,
476+
events: parkedEvents,
477+
callId,
478+
deadline,
479+
} = await parkOnAsk(call, { adapter, droppedApprovalCalls: droppedCalls });
457480
const parkedCancel = { fn: undefined as (() => void) | undefined };
458481
const generation = createDeliveryGeneration(() => parkedCancel.fn?.());
459482
let releaseCallId: (() => void) | undefined;
460483
const callIdResolved = new Promise<void>((resolve) => {
461484
releaseCallId = resolve;
462485
});
486+
const { enqueue, awaitTail } = createSessionOperationQueue();
487+
let decisionQueued: (() => void) | undefined;
488+
const queued = new Promise<void>((resolve) => {
489+
decisionQueued = resolve;
490+
});
463491
const resume = createApprovalResume({
464492
getAgent: () => live.session.agent,
465493
gate: ctx.gate,
@@ -468,12 +496,40 @@ async function dropThenInterrupt(
468496
parkedCancel.fn = cancel;
469497
},
470498
resolveParkedCallId: () => callIdResolved.then(() => callId),
499+
// As the TUI does: behind the session's other operations.
500+
deliver: createQueuedApprovalDeliver({
501+
enqueue: (op) => {
502+
const queuedOp = enqueue(op);
503+
decisionQueued?.();
504+
return queuedOp;
505+
},
506+
fatalBuildError: () => null,
507+
deliverer: {
508+
deliver: async (message) => live.session.agent.deliver(message),
509+
},
510+
}),
471511
});
472512
claimFrom = resume;
473513
const handling = resume.handle(suspended);
474514

475-
const { enqueue, awaitTail } = createSessionOperationQueue();
515+
let releaseBusy: (() => void) | undefined;
516+
if (interruptWhile !== "resolving-call") {
517+
releaseCallId?.();
518+
await waitFor(ctx.asked, "approval prompt");
519+
}
520+
if (interruptWhile === "decision-queued") {
521+
// Any earlier operation holds the queue, as a send or another deliver would.
522+
void enqueue(
523+
() =>
524+
new Promise<void>((resolve) => {
525+
releaseBusy = resolve;
526+
}),
527+
);
528+
ctx.approve();
529+
await queued;
530+
}
476531
let events: ReactorEmittedEvent[] = [];
532+
let parkedAtRebuild: (string | undefined)[] = [];
477533
startInterruptRebuild({
478534
deliveryGeneration: generation,
479535
markSendAborted: () => undefined,
@@ -482,6 +538,9 @@ async function dropThenInterrupt(
482538
if (opts.deadlinePassesDuringRebuild === true) {
483539
clock.shift(deadline + 1_000);
484540
}
541+
parkedAtRebuild = (
542+
await live.session.storage.load()
543+
).pendingOperations.map((op) => op.suspendedCall?.id);
485544
await resume.prepareRebuild(live.session.storage);
486545
live.session = await rebuildIntegrationSession(live.session, sessionOpts);
487546
events = collect(live.session);
@@ -500,6 +559,7 @@ async function dropThenInterrupt(
500559
},
501560
});
502561
releaseCallId?.();
562+
releaseBusy?.();
503563
expect(await handling).toBe(true);
504564
await awaitTail();
505565

@@ -524,6 +584,9 @@ async function dropThenInterrupt(
524584
deadline,
525585
clock,
526586
resume,
587+
correlationId: suspended.correlationId,
588+
parkedEvents,
589+
parkedAtRebuild,
527590
};
528591
}
529592

@@ -997,4 +1060,40 @@ describe("integration: an ask dropped by an interrupt, then settled on the rebui
9971060
},
9981061
20_000,
9991062
);
1063+
1064+
/** The approved call never ran, and the rebuilt agent settled it once. */
1065+
async function expectSettledWithoutRunning(reloaded: Dropped): Promise<void> {
1066+
const { session, callId, events, parkedEvents } = reloaded;
1067+
expect(reloaded.parkedAtRebuild).toEqual([callId]);
1068+
expect(parkedEvents.filter((e) => e.type === "tool.start")).toEqual([]);
1069+
expect((await session.storage.load()).pendingOperations).toEqual([]);
1070+
const settled = toolResultsFor(await session.agent.history(), callId);
1071+
expect(settled).toHaveLength(1);
1072+
expect(settled.join("")).toContain(APPROVAL_DROPPED_NOTICE);
1073+
expect(session.harness.scenario.matchedRequests()).toHaveLength(0);
1074+
expect(events.filter((e) => e.type === "inference.start")).toEqual([]);
1075+
1076+
await passDeadline(reloaded, reloaded.deadline + FRESH_WINDOW_PASSED_MS);
1077+
expect(toolResultsFor(await session.agent.history(), callId)).toEqual(
1078+
settled,
1079+
);
1080+
expect(events.filter((e) => e.type === "reactor.error")).toEqual([]);
1081+
}
1082+
1083+
test.serial(
1084+
"an approval the interrupt skips while it waits for delivery settles with no model turn",
1085+
async () => {
1086+
const reloaded = await dropThenInterrupt(
1087+
{ name: "run_shell", args: { command: CLOSED_PORT_PROBE } },
1088+
NATIVE_ANTHROPIC,
1089+
{ interruptWhile: "decision-queued" },
1090+
);
1091+
1092+
await expectSettledWithoutRunning(reloaded);
1093+
expect(reloaded.resume.status(reloaded.correlationId)).not.toBe(
1094+
"handed-over",
1095+
);
1096+
},
1097+
20_000,
1098+
);
10001099
});

‎src/exec/runner.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1214,8 +1214,9 @@ export async function runExec(config: Config): Promise<ExecResult> {
12141214
cwd: config.cwd,
12151215
extraDeniedPaths: [config.globalSettingsPath],
12161216
deliver: async (message, stillCurrent) => {
1217-
if (!stillCurrent()) return;
1217+
if (!stillCurrent()) return "skipped";
12181218
await approvalDeliverer.deliver(message);
1219+
return "delivered";
12191220
},
12201221
}).handle(sendResult);
12211222
sendCompleted = true;

‎src/session/approval-resume-interrupt.test.ts‎

Lines changed: 91 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import {
2727
createSessionOperationQueue,
2828
SESSION_IDENTITY_ABORT_REASON,
2929
} from "../tui/delivery-queue.js";
30+
import { createQueuedApprovalDeliver } from "../tui/approval-delivery.js";
3031
import { createGateRequestApproval } from "../tui/request-approval.js";
3132
import { startInterruptRebuild } from "../tui/runner/exit.js";
3233
import { runWhileAgentBusy } from "../tui/runner/state.js";
@@ -135,8 +136,9 @@ describe("approval resume generation capture", () => {
135136
getAgent: () => agent,
136137
captureGeneration: generation.capture,
137138
deliver: (message, stillCurrent) => {
138-
if (!stillCurrent()) return;
139+
if (!stillCurrent()) return "skipped";
139140
delivered.push(message);
141+
return "delivered";
140142
},
141143
gate: {
142144
resolveSuspended: async () => {
@@ -166,8 +168,9 @@ describe("approval resume generation capture", () => {
166168
getAgent: () => agent,
167169
captureGeneration: generation.capture,
168170
deliver: (message, stillCurrent) => {
169-
if (!stillCurrent()) return;
171+
if (!stillCurrent()) return "skipped";
170172
delivered.push(message);
173+
return "delivered";
171174
},
172175
gate: {
173176
resolveSuspended: async () => ({ allow: true }),
@@ -202,8 +205,9 @@ describe("approval resume generation capture", () => {
202205
getAgent: () => current,
203206
captureGeneration: generation.capture,
204207
deliver: (message, stillCurrent) => {
205-
if (!stillCurrent()) return;
208+
if (!stillCurrent()) return "skipped";
206209
current.deliver(message);
210+
return "delivered";
207211
},
208212
gate: {
209213
resolveSuspended: async () => {
@@ -460,6 +464,81 @@ describe("approval resume dropped approvals", () => {
460464
});
461465
});
462466

467+
describe("interrupt while the operator's decision waits for delivery", () => {
468+
test("the skipped decision is recorded as a drop and settled on the rebuilt agent", async () => {
469+
const parkedCancel = { fn: undefined as (() => void) | undefined };
470+
const generation = createDeliveryGeneration(() => parkedCancel.fn?.());
471+
const sessionOps = createSessionOperationQueue();
472+
const oldAgentDelivered: InboundMessage[] = [];
473+
474+
// Holds the session queue busy, as any earlier deliver or op would.
475+
let releaseBusy: (() => void) | undefined;
476+
void sessionOps.enqueue(
477+
() =>
478+
new Promise<void>((resolve) => {
479+
releaseBusy = resolve;
480+
}),
481+
);
482+
483+
let decisionQueued: (() => void) | undefined;
484+
const queued = new Promise<void>((resolve) => {
485+
decisionQueued = resolve;
486+
});
487+
const resume = createApprovalResume({
488+
resolveParkedCallId: () => "call-ask",
489+
getAgent: () => ({
490+
deliver: (message: InboundMessage) => oldAgentDelivered.push(message),
491+
history: async () => [userTurn()],
492+
}),
493+
captureGeneration: generation.capture,
494+
registerParkedCancel: (cancel) => {
495+
parkedCancel.fn = cancel;
496+
},
497+
deliver: createQueuedApprovalDeliver({
498+
enqueue: (op) => {
499+
const queuedOp = sessionOps.enqueue(op);
500+
decisionQueued?.();
501+
return queuedOp;
502+
},
503+
fatalBuildError: () => null,
504+
deliverer: {
505+
deliver: async (message) => {
506+
oldAgentDelivered.push(message);
507+
},
508+
},
509+
}),
510+
gate: {
511+
resolveSuspended: async () => ({ allow: false, message: "no" }),
512+
} as unknown as PermissionGate,
513+
});
514+
515+
const handling = resume.handle(SUSPENDED);
516+
await queued;
517+
518+
const rebuilt: InboundMessage[] = [];
519+
startInterruptRebuild({
520+
deliveryGeneration: generation,
521+
markSendAborted: () => undefined,
522+
enqueue: sessionOps.enqueue,
523+
rebuild: async () => {
524+
await resume.settleOn(
525+
{ deliver: (message) => rebuilt.push(message) },
526+
parkedStore().storage,
527+
);
528+
},
529+
});
530+
releaseBusy?.();
531+
expect(await handling).toBe(true);
532+
await sessionOps.awaitTail();
533+
534+
expect(oldAgentDelivered).toEqual([]);
535+
expect(
536+
rebuilt.map((message) => message.headers.interchangeCorrelationId),
537+
).toEqual(["corr-1"]);
538+
expect(resume.status("corr-1")).not.toBe("handed-over");
539+
});
540+
});
541+
463542
const persistAllow: ApprovalScope = {
464543
id: "project-curl",
465544
label: "Allow curl *",
@@ -659,8 +738,8 @@ describe("approval resume occupancy until correlation", () => {
659738
const resume = createApprovalResume({
660739
resolveParkedCallId: () => "call-ask",
661740
getAgent: () => agent,
662-
deliver: (message) =>
663-
enqueue(async () => {
741+
deliver: async (message) => {
742+
await enqueue(async () => {
664743
const correlationId = message.headers.interchangeCorrelationId;
665744
const accepted =
666745
correlationId === undefined
@@ -669,7 +748,9 @@ describe("approval resume occupancy until correlation", () => {
669748
agent.deliver(message);
670749
delivered?.();
671750
await accepted;
672-
}),
751+
});
752+
return "delivered" as const;
753+
},
673754
gate: {
674755
resolveSuspended: async () => {
675756
state.pendingReload = true;
@@ -778,12 +859,13 @@ describe("approval resume occupancy until correlation", () => {
778859
history: async () => [userTurn()],
779860
}),
780861
captureGeneration: generation.capture,
781-
deliver: (message) => {
862+
deliver: async (message) => {
782863
const correlationId = message.headers.interchangeCorrelationId;
783-
if (correlationId === undefined) return;
864+
if (correlationId === undefined) return "skipped";
784865
const accepted = correlationAcceptance.wait(correlationId);
785866
waiting?.();
786-
return accepted;
867+
await accepted;
868+
return "delivered";
787869
},
788870
gate: {
789871
resolveSuspended: async () => ({ allow: true }),

‎src/session/approval-resume.test.ts‎

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -257,10 +257,12 @@ describe("approval decision intent headers", () => {
257257
getAgent: () => agent,
258258
gate,
259259
resolveParkedCallId: () => "typed-call",
260-
deliver: (message) =>
261-
queue.enqueue(async () => {
260+
deliver: async (message) => {
261+
await queue.enqueue(async () => {
262262
agent.deliver(message);
263-
}),
263+
});
264+
return "delivered" as const;
265+
},
264266
});
265267
const handling = resume.handle(
266268
suspension("typed-correlation", "echo alpha"),
@@ -1413,6 +1415,7 @@ describe("parked overlay registration after the operator answers", () => {
14131415
deliver: async (message) => {
14141416
promptSignal = binding.identitySignal(identity.signal);
14151417
agent.deliver(message);
1418+
return "delivered" as const;
14161419
},
14171420
});
14181421
expect(

0 commit comments

Comments
 (0)