Skip to content

Commit a8ee35b

Browse files
committed
Cover duplicate parked calls and document the heuristic-only lookup
1 parent 7e87d6d commit a8ee35b

4 files changed

Lines changed: 92 additions & 20 deletions

File tree

‎src/exec/runner.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -976,6 +976,8 @@ export async function runExec(config: Config): Promise<ExecResult> {
976976
const sendResult = await activeAgent.send(operatorTaskMessage(task));
977977
// A suspension must not park silently in exec: the approval resume owns
978978
// the terminal prompt flow and delivers the decision to the reactor.
979+
// No resolveParkedCallId: the vendored reactor exposes no
980+
// correlationId-to-call lookup, so the history heuristic is the path.
979981
await createApprovalResume({
980982
getAgent: () => activeAgent,
981983
gate: permissionGate,

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

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -165,4 +165,48 @@ describe("approval-resume parallel-parked approvals", () => {
165165
expect(deliveredCorrelationId(message)).toBe("corr-A");
166166
expect(decisionBody(message).outcome).toBe("approved");
167167
});
168+
169+
test("identical name+args twin: sibling timeout still delivers", async () => {
170+
const { resume, delivered } = setup({
171+
preTurns: [
172+
assistantTurn([
173+
{ id: "call-A", name: "run_shell", command: "echo same" },
174+
{ id: "call-B", name: "run_shell", command: "echo same" },
175+
]),
176+
],
177+
onGate: (turns) => {
178+
turns.push(timeoutTurn("call-B"));
179+
},
180+
});
181+
182+
const handled = await resume.handle(suspension("corr-A", "echo same"));
183+
184+
expect(handled).toBe(true);
185+
expect(delivered).toHaveLength(1);
186+
});
187+
188+
// Known limitation of the exact-one history derivation: with identical
189+
// name+args twins, the answered own call drops out of the candidates and
190+
// the derivation resolves to the unanswered twin, so the settled check
191+
// misses and the genuinely-late decision is delivered. Telling the twins
192+
// apart needs the resolveParkedCallId lookup; this test locks the current
193+
// shape so a future fix can flip it to a drop.
194+
test("identical name+args twin: own timeout with unanswered twin delivers", async () => {
195+
const { resume, delivered } = setup({
196+
preTurns: [
197+
assistantTurn([
198+
{ id: "call-A", name: "run_shell", command: "echo same" },
199+
{ id: "call-B", name: "run_shell", command: "echo same" },
200+
]),
201+
],
202+
onGate: (turns) => {
203+
turns.push(timeoutTurn("call-A"));
204+
},
205+
});
206+
207+
const handled = await resume.handle(suspension("corr-A", "echo same"));
208+
209+
expect(handled).toBe(true);
210+
expect(delivered).toHaveLength(1);
211+
});
168212
});

‎src/session/approval-resume.ts‎

Lines changed: 44 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -78,23 +78,24 @@ export function requestFromApprovalSnapshot(
7878
// along and match block.callId; without it the text-only scan stays as the
7979
// fallback so a genuinely late decision is still dropped.
8080

81-
function settledAfterSuspend(
81+
function timeoutResultAfterSuspend(
8282
turns: Awaited<ReturnType<Agent["history"]>>,
8383
fromIndex: number,
8484
parkedCallId: string | undefined,
85-
): boolean {
86-
return turns
87-
.slice(fromIndex)
88-
.flatMap((turn) => turn.content)
89-
.some(
90-
(block) =>
91-
block.type === "tool_result" &&
92-
(parkedCallId === undefined || block.callId === parkedCallId) &&
93-
block.content.some(
94-
(part) =>
95-
part.type === "text" && part.text === APPROVAL_TIMEOUT_RESULT_TEXT,
96-
),
97-
);
85+
): string | undefined {
86+
for (const block of turns.slice(fromIndex).flatMap((turn) => turn.content)) {
87+
if (
88+
block.type === "tool_result" &&
89+
(parkedCallId === undefined || block.callId === parkedCallId) &&
90+
block.content.some(
91+
(part) =>
92+
part.type === "text" && part.text === APPROVAL_TIMEOUT_RESULT_TEXT,
93+
)
94+
) {
95+
return block.callId;
96+
}
97+
}
98+
return undefined;
9899
}
99100

100101
function stableArgs(value: unknown): string {
@@ -111,10 +112,18 @@ function stableArgs(value: unknown): string {
111112

112113
// Derive this suspension's parked call id from history: the pre-watermark
113114
// tool_call matching the snapshot's name and arguments that has no tool
114-
// result anywhere yet. A parked call is neither run nor answered, so the only
115-
// unanswered match is this suspension's own call; a timed-out sibling is
116-
// answered by its timeout result and drops out of the candidates. Returns
117-
// undefined unless exactly one candidate matches.
115+
// result anywhere yet. A parked call is neither run nor answered, so an
116+
// unanswered match is normally this suspension's own call; a timed-out
117+
// sibling is answered by its timeout result and drops out of the candidates.
118+
// Returns undefined unless exactly one candidate matches.
119+
//
120+
// Known limitation: identical name+args twins are ambiguous. When this
121+
// suspension's own timeout fires while an identical twin sits unanswered,
122+
// the twin is the exact-one survivor, so the settled check misses and the
123+
// late decision is delivered instead of dropped. The sibling-timeout mirror
124+
// (identical args, sibling answered) still resolves to the live call and
125+
// delivers. Telling identical twins apart needs the resolveParkedCallId
126+
// lookup below; the history heuristic cannot do it.
118127
function parkedCallIdFromHistory(
119128
turns: Awaited<ReturnType<Agent["history"]>>,
120129
fromIndex: number,
@@ -193,6 +202,14 @@ export function createApprovalResume(args: {
193202
// the minted correlationId, so this is what ties them together. Takes
194203
// precedence over the history derivation below; absent callers fall back
195204
// to it.
205+
//
206+
// Heuristic-only in production: neither the exec nor the TUI caller wires
207+
// this, because the vendored reactor surface ({ start, deliver, abort })
208+
// exposes no correlationId-to-call lookup (see
209+
// permission/decline-markers.ts), and the suspension snapshot carries
210+
// name+arguments without the call id. Wire this if upstream ever exports
211+
// the pending-operation lookup; until then the history derivation is the
212+
// live path.
196213
resolveParkedCallId?: (correlationId: string) => string | undefined;
197214
gate: PermissionGate;
198215
}): ApprovalResume {
@@ -290,11 +307,18 @@ export function createApprovalResume(args: {
290307
const parkedCallId =
291308
args.resolveParkedCallId?.(correlationId) ??
292309
parkedCallIdFromHistory(history, turnsAtSuspend, approvalSnapshot);
293-
if (settledAfterSuspend(history, turnsAtSuspend, parkedCallId)) {
310+
const matchedTimeoutCallId = timeoutResultAfterSuspend(
311+
history,
312+
turnsAtSuspend,
313+
parkedCallId,
314+
);
315+
if (matchedTimeoutCallId !== undefined) {
294316
// The reactor already answered the parked call (its approval timeout
295317
// fired while the surface was still up). Delivering now would append
296318
// the raw decision JSON as an uncorrelated user turn — drop and log.
297-
logger.warn`late approval decision dropped correlation=${correlationId} outcome=${outcome?.allow === true ? "approved" : "rejected"}`;
319+
// The matched call id rides along so a fallback drop (parkedCallId
320+
// undefined) can still be attributed to the timeout that caused it.
321+
logger.warn`late approval decision dropped correlation=${correlationId} timeoutCall=${matchedTimeoutCallId} outcome=${outcome?.allow === true ? "approved" : "rejected"}`;
298322
return true;
299323
}
300324
if (outcome === undefined || !outcome.allow) {

‎src/tui/runner/session.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -495,6 +495,8 @@ export async function assembleTUISession(
495495
// Reload, interrupt, compaction continuation, and proxy deliver share one queue
496496
// so a rebuild never races an in-flight deliver.
497497
const sessionOps = createSessionOperationQueue();
498+
// No resolveParkedCallId: the vendored reactor exposes no
499+
// correlationId-to-call lookup, so the history heuristic is the path.
498500
const approvalResume = createApprovalResume({
499501
getAgent: () => state.currentAgent,
500502
captureGeneration: deliveryGeneration.capture,

0 commit comments

Comments
 (0)