Skip to content

Commit 447924a

Browse files
committed
fix(runner): only real executor evidence settles a paused turn's tool calls
1 parent d2a46d9 commit 447924a

10 files changed

Lines changed: 651 additions & 39 deletions

File tree

services/runner/src/engines/sandbox_agent/acp-interactions.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,8 @@ export interface AttachPermissionResponderInput {
5252
log?: (msg: string) => void;
5353
/** Called with the ACP tool-call id when a gate pauses the turn. */
5454
onPausedToolCall?: (id: string) => void;
55+
/** Called before an allow reply can release the harness to execute this tool call. */
56+
onAllowedExecution?: (id: string) => void;
5557
/**
5658
* Called when a NON-parkable pause happens this turn (a client-tool ACP gate, which cannot be
5759
* answered across a turn boundary on the live session). The keep-alive dispatch reads this to
@@ -118,6 +120,7 @@ export function attachPermissionResponder({
118120
onPause,
119121
log,
120122
onPausedToolCall,
123+
onAllowedExecution,
121124
onNonParkablePause,
122125
onCreateInteraction,
123126
onResolveInteraction,
@@ -236,6 +239,9 @@ export function attachPermissionResponder({
236239
// breakage. (A malformed-envelope / unknown-builtin fail-closed reject goes through
237240
// `rejectRequest`, not here, so it stays a plain error — it is not a user/policy denial.)
238241
if (decision === "deny") run.markToolCallDenied?.(toolCallId);
242+
// Mark before replying because an allow can release the harness synchronously and its first
243+
// execution frame must already be protected from a concurrently active pause sweep.
244+
if (decision === "allow" && toolCallId) onAllowedExecution?.(toolCallId);
239245
try {
240246
await session.respondPermission(
241247
id,
@@ -518,7 +524,7 @@ function recordedToolName(
518524
* strip that prefix so the lookup hits). This is the ONLY source of a tool's true
519525
* `permission`/`readOnly`, so both the approval card and this descriptor come from one lookup.
520526
*/
521-
function buildGateDescriptor(
527+
export function buildGateDescriptor(
522528
toolCall: any,
523529
run: { events?: () => AgentEvent[] },
524530
serverPermissions: ReadonlyMap<string, ToolPermission>,

services/runner/src/engines/sandbox_agent/pause.ts

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,15 @@
33
*
44
* F-040: an unanswered approval must end the turn, destroy the session, and never reply to the
55
* harness gate. Historical docs call this "park"; this module uses pause/pendingApproval names.
6-
* The destroy callback also settles every announced-but-unresolved sibling tool call with a
7-
* deterministic `tool_result` before teardown, so the client never holds an orphaned part.
6+
* Terminalization classifies every announced-but-unresolved sibling after managed cancellation
7+
* drains, so the client never holds an orphaned part or an invented execution result.
88
*/
99
export const PAUSED = Symbol("paused");
1010

1111
export class PendingApprovalPauseController {
1212
private pendingApproval = false;
1313
private readonly pausedToolCallIds = new Set<string>();
14+
private readonly allowedExecutionToolCallIds = new Set<string>();
1415
private resolvePause: (() => void) | undefined;
1516
private eventDrain: Promise<void> = Promise.resolve();
1617

@@ -60,6 +61,19 @@ export class PendingApprovalPauseController {
6061
return toolCallId !== undefined && this.pausedToolCallIds.has(toolCallId);
6162
}
6263

64+
/** Record that this turn answered allow for the call, so pause cleanup preserves its result. */
65+
markAllowedExecution(toolCallId: string): void {
66+
if (!toolCallId) return;
67+
this.allowedExecutionToolCallIds.add(toolCallId);
68+
}
69+
70+
isAllowedExecution(toolCallId: string | undefined): boolean {
71+
return (
72+
toolCallId !== undefined &&
73+
this.allowedExecutionToolCallIds.has(toolCallId)
74+
);
75+
}
76+
6377
get active(): boolean {
6478
return this.pendingApproval;
6579
}

services/runner/src/engines/sandbox_agent/run-turn.ts

Lines changed: 163 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
1-
import { permissionsFromRequest } from "../../permission-plan.ts";
1+
import {
2+
effectivePermission,
3+
permissionsFromRequest,
4+
} from "../../permission-plan.ts";
25
import {
36
resolvePromptText,
47
type AgentRunRequest,
@@ -27,10 +30,14 @@ import {
2730
type RelayExecutionGuard,
2831
} from "../../tools/relay.ts";
2932
import {
33+
APPROVED_EXECUTION_RESULT_UNKNOWN,
3034
createSandboxAgentOtel,
3135
TOOL_NOT_EXECUTED_PAUSED,
3236
} from "../../tracing/otel.ts";
33-
import { attachPermissionResponder } from "./acp-interactions.ts";
37+
import {
38+
attachPermissionResponder,
39+
buildGateDescriptor,
40+
} from "./acp-interactions.ts";
3441
import {
3542
buildClientToolRelay,
3643
relayWritesPausedAnswer,
@@ -114,8 +121,9 @@ export async function runTurn(
114121
// caller's teardown (`runSandboxAgent`'s `finally`, or the keep-alive dispatch's evict-on-failure)
115122
// reclaims the sandbox exactly as any other error does. Disposed in the `finally` on every path.
116123
// A human pause retires the deadlines (`notePaused`): a HITL wait is legitimate, not a wedge.
124+
const resolvedRunLimits = (deps.resolveRunLimits ?? resolveRunLimits)(logger);
117125
const runLimits = (deps.createRunLimits ?? createRunLimits)(
118-
(deps.resolveRunLimits ?? resolveRunLimits)(logger),
126+
resolvedRunLimits,
119127
{ log: logger },
120128
);
121129
let runLimitTrip: (() => void) | undefined;
@@ -176,8 +184,8 @@ export async function runTurn(
176184
// emit its own approval card. The orphan settle is deferred to the post-drain sweep below
177185
// (which runs on every paused turn, after `waitForEventDrain` lets every pending gate mark
178186
// its call paused) plus the in-band re-sweep in `handleUpdate` for a sibling announced after
179-
// the pause. Both exclude paused (gated) calls, so every carded gate stays open for its
180-
// resume and only a genuine orphan (announced, never gated) settles.
187+
// the pause. Both exclude paused gates and allowed executions, so each keeps its own
188+
// terminal outcome while only a genuine orphan settles.
181189
// Park mode: at least one parkable permission gate (Claude ACP or Pi ACP) recorded into
182190
// `env.parkedApprovals` BEFORE firing this pause (the onUserApprovalGate hook runs as each
183191
// gate resolves). Keep the live session — the gated tools run on the resume — so skip ONLY
@@ -196,6 +204,43 @@ export async function runTurn(
196204
// place every pause path converges, so the one place to retire the run-limits deadlines for good.
197205
void pause.signal.then(() => runLimits.notePaused());
198206

207+
const openToolCallIds = (): string[] => run.openToolCallIds?.() ?? [];
208+
const bufferedPausedCompletedFrames = new Map<string, unknown>();
209+
const toolCallClosureWaiters = new Map<string, Set<() => void>>();
210+
const notifyToolCallClosed = (toolCallId: string): void => {
211+
if (openToolCallIds().includes(toolCallId)) return;
212+
const waiters = toolCallClosureWaiters.get(toolCallId);
213+
if (!waiters) return;
214+
toolCallClosureWaiters.delete(toolCallId);
215+
for (const waiter of waiters) waiter();
216+
};
217+
const waitForToolCallClosure = (
218+
toolCallId: string,
219+
timeoutMs: number,
220+
): Promise<boolean> => {
221+
if (!openToolCallIds().includes(toolCallId)) {
222+
return Promise.resolve(true);
223+
}
224+
return new Promise<boolean>((resolve) => {
225+
let timeout: NodeJS.Timeout | undefined;
226+
let finished = false;
227+
const finish = (closed: boolean): void => {
228+
if (finished) return;
229+
finished = true;
230+
if (timeout) clearTimeout(timeout);
231+
const waiters = toolCallClosureWaiters.get(toolCallId);
232+
waiters?.delete(onClosed);
233+
if (waiters?.size === 0) toolCallClosureWaiters.delete(toolCallId);
234+
resolve(closed);
235+
};
236+
const onClosed = (): void => finish(true);
237+
const waiters = toolCallClosureWaiters.get(toolCallId) ?? new Set();
238+
waiters.add(onClosed);
239+
toolCallClosureWaiters.set(toolCallId, waiters);
240+
timeout = setTimeout(() => finish(false), timeoutMs);
241+
});
242+
};
243+
199244
// Publish this turn's sink so the session-lifetime listeners route into it. handleUpdate
200245
// reproduces the old per-event routing (suppress paused frames, handleUpdate, pause re-sweep).
201246
const turn: CurrentTurn = {
@@ -235,12 +280,35 @@ export async function runTurn(
235280
) {
236281
env.lastTurnToolCallIds.push(frame.toolCallId);
237282
}
283+
const toolCallId =
284+
typeof rawFrame.toolCallId === "string"
285+
? rawFrame.toolCallId
286+
: undefined;
287+
if (
288+
pause.active &&
289+
(rawFrame.sessionUpdate === "tool_call" ||
290+
rawFrame.sessionUpdate === "tool_call_update") &&
291+
rawFrame.status === "completed" &&
292+
toolCallId &&
293+
!pause.isPausedToolCall(toolCallId) &&
294+
!pause.isAllowedExecution(toolCallId)
295+
) {
296+
bufferedPausedCompletedFrames.set(toolCallId, update);
297+
return;
298+
}
238299
run.handleUpdate(update);
239-
// A non-gated sibling announced AFTER the pause can never execute this turn; settle it
240-
// immediately so the client never holds an orphaned part (idempotent re-sweep).
300+
if (
301+
toolCallId &&
302+
(rawFrame.status === "completed" || rawFrame.status === "failed")
303+
) {
304+
notifyToolCallClosed(toolCallId);
305+
}
306+
// A sibling announced after the pause with neither a gate nor an allow cannot execute;
307+
// the idempotent re-sweep closes it so the client never holds an orphaned part.
241308
if (pause.active) {
242309
run.settleOpenToolCalls(
243-
(id) => pause.isPausedToolCall(id),
310+
(id) =>
311+
pause.isPausedToolCall(id) || pause.isAllowedExecution(id),
244312
TOOL_NOT_EXECUTED_PAUSED,
245313
);
246314
}
@@ -307,6 +375,62 @@ export async function runTurn(
307375
// The SAME name->spec index the relay execute loop hands to the relay execution guard, so
308376
// the approval card and the guard cannot disagree about a tool's permission/readOnly.
309377
const specsByName = toolSpecsByName(plan.toolSpecs);
378+
const settleBufferedPausedCompletions = (): void => {
379+
for (const [toolCallId, update] of [
380+
...bufferedPausedCompletedFrames.entries(),
381+
]) {
382+
bufferedPausedCompletedFrames.delete(toolCallId);
383+
if (pause.isPausedToolCall(toolCallId)) continue;
384+
if (pause.isAllowedExecution(toolCallId)) {
385+
run.handleUpdate(update);
386+
notifyToolCallClosed(toolCallId);
387+
continue;
388+
}
389+
const frame = update as {
390+
sessionUpdate?: unknown;
391+
name?: unknown;
392+
title?: unknown;
393+
kind?: unknown;
394+
rawInput?: unknown;
395+
input?: unknown;
396+
};
397+
const { gate } = buildGateDescriptor(
398+
{
399+
toolCallId,
400+
name: frame.name,
401+
title: frame.title,
402+
kind: frame.kind,
403+
rawInput: frame.rawInput,
404+
input: frame.input,
405+
},
406+
run,
407+
serverPermissions,
408+
specsByName,
409+
);
410+
const permission = effectivePermission(gate, permissionPlan);
411+
if (permission === "allow") {
412+
run.handleUpdate(update);
413+
notifyToolCallClosed(toolCallId);
414+
continue;
415+
}
416+
// Execution of an ask-policy call requires an answered allow; both harness gate paths fail
417+
// closed. A completed frame during a pause for an unanswered ask-policy call is therefore
418+
// a cancellation-closure artifact, not evidence of execution.
419+
if (
420+
frame.sessionUpdate === "tool_call" &&
421+
!openToolCallIds().includes(toolCallId)
422+
) {
423+
run.handleUpdate({
424+
...(update as Record<string, unknown>),
425+
status: undefined,
426+
});
427+
}
428+
run.settleOpenToolCalls(
429+
(id) => id !== toolCallId,
430+
TOOL_NOT_EXECUTED_PAUSED,
431+
);
432+
}
433+
};
310434
// Build the per-turn permission handler WITHOUT attaching to the live session: the
311435
// session-lifetime `onPermissionRequest` (in acquireEnvironment) routes into it via
312436
// `currentTurn`. A capturing shim reuses attachPermissionResponder unchanged; its
@@ -325,6 +449,7 @@ export async function runTurn(
325449
log: logger,
326450
onPause: () => pause.pause(),
327451
onPausedToolCall: (id) => pause.markPausedToolCall(id),
452+
onAllowedExecution: (id) => pause.markAllowedExecution(id),
328453
onNonParkablePause: () => {
329454
env.nonParkablePauseCount += 1;
330455
},
@@ -470,6 +595,7 @@ export async function runTurn(
470595
// confirm resolves) passes the relay guard. Claude resumes grant too — harmlessly, no
471596
// guard consults it.
472597
if (decision.reply === "once") {
598+
pause.markAllowedExecution(decision.toolCallId);
473599
executionGrants.grant(decision.toolName, decision.args);
474600
}
475601
// A live-resume deny closes the seeded call as a failed tool call; flag it so the egress
@@ -508,15 +634,40 @@ export async function runTurn(
508634
}
509635
const stopReason =
510636
raced === PAUSED || pause.active ? "paused" : (raced as any)?.stopReason;
511-
// Pause notification is immediate, but terminalization must wait for managed cancellation
512-
// and already-queued ACP updates. Re-sweep after the drain so a sibling announced during
513-
// cancellation receives exactly one deterministic terminal result before `done`.
637+
// Terminalization drains queued gates, classifies pause-time completions, and gives allowed
638+
// executions their original per-call bound before the orphan sweep closes the turn.
514639
if (stopReason === "paused") {
515640
await pause.waitForEventDrain();
641+
settleBufferedPausedCompletions();
642+
const openAllowedExecutions = openToolCallIds()
643+
.filter((id) => pause.isAllowedExecution(id));
644+
await Promise.all(
645+
openAllowedExecutions.map(async (toolCallId) => {
646+
const closed = await waitForToolCallClosure(
647+
toolCallId,
648+
resolvedRunLimits.toolCallMs,
649+
);
650+
if (closed) return;
651+
run.settleOpenToolCalls(
652+
(id) => id !== toolCallId,
653+
APPROVED_EXECUTION_RESULT_UNKNOWN,
654+
);
655+
}),
656+
);
657+
settleBufferedPausedCompletions();
516658
run.settleOpenToolCalls(
517-
(id) => pause.isPausedToolCall(id),
659+
(id) =>
660+
pause.isPausedToolCall(id) || pause.isAllowedExecution(id),
518661
TOOL_NOT_EXECUTED_PAUSED,
519662
);
663+
const unexpectedOpenToolCallIds = openToolCallIds()
664+
.filter((id) => !pause.isPausedToolCall(id));
665+
if (unexpectedOpenToolCallIds.length > 0) {
666+
logger(
667+
"[HITL] paused-turn transcript invariant left non-gated calls open: " +
668+
unexpectedOpenToolCallIds.join(","),
669+
);
670+
}
520671
}
521672
const result = raced === PAUSED ? undefined : raced;
522673
// A parkable pause this turn: hand the still-pending prompt promise to EVERY parked record so a

services/runner/src/engines/sandbox_agent/runtime-policy.ts

Lines changed: 8 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -42,18 +42,14 @@ export function shouldSuppressPausedToolCallUpdate(
4242
// F-024: a paused (gated) tool call's later frames are teardown artifacts and never reach the
4343
// stream.
4444
if (pause.isPausedToolCall(toolCallId)) return true;
45-
// Once the turn is pausing, a FAILED update for any other open call is a managed-cancel
46-
// artifact (a sibling that will not run this turn), so suppress it and let the deterministic
47-
// post-drain sweep settle that call as TOOL_NOT_EXECUTED_PAUSED — the human sees "paused", not a
48-
// spurious cancellation error. A `completed` update is NOT suppressed: an auto-allowed sibling
49-
// that legitimately finishes while another gate holds the (kept-alive) warm session must show
50-
// its real result, not be overwritten as paused. Announcements (`tool_call`) are never
51-
// suppressed: the call must be recorded so the sweep knows which calls to settle. (Removing the
52-
// eager first-pause settle is what lets concurrent gates each mark their own call paused before
53-
// the sweep runs; this narrow suppression only masks the cancel artifact.) Residual: a genuine
54-
// mid-pause tool error also arrives as `failed` and reads as paused here — acceptable, since the
55-
// paused result invites a retry and the runner has no adapter provenance to tell the two apart.
56-
if (kind === "tool_call_update" && pause.active && frame?.status === "failed") {
45+
// While pausing, a failed frame without an allow is a managed-cancel artifact that the sweep
46+
// records as deferred; an allowed call failure is genuine terminal evidence and must pass.
47+
if (
48+
kind === "tool_call_update" &&
49+
pause.active &&
50+
frame?.status === "failed" &&
51+
!pause.isAllowedExecution(toolCallId)
52+
) {
5753
return true;
5854
}
5955
return false;

0 commit comments

Comments
 (0)