Skip to content

Commit ba5c6b5

Browse files
committed
fix(runner): park immediately when Pi batching blocks an approved call
1 parent ea9f383 commit ba5c6b5

3 files changed

Lines changed: 359 additions & 9 deletions

File tree

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

Lines changed: 84 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ import {
5959
sendLastMessageOnly,
6060
type CurrentTurn,
6161
type ParkedApproval,
62+
type ParkedApprovedExecution,
6263
type RunTurnOptions,
6364
type SessionEnvironment,
6465
} from "./runtime-contracts.ts";
@@ -105,6 +106,11 @@ export async function runTurn(
105106
// Reset the per-turn approval-park bookkeeping. A fresh turn starts with no parked gates; this
106107
// turn re-records them only if it pauses on ACP permission gates. (The dispatch has already
107108
// captured any prior park into `opts.resume` before calling us.)
109+
const carriedApprovedExecutions = opts.resume
110+
? [...(env.parkedApprovedExecutions?.values() ?? [])]
111+
: [];
112+
const parkedApprovedExecutions = new Map<string, ParkedApprovedExecution>();
113+
env.parkedApprovedExecutions = parkedApprovedExecutions;
108114
env.parkedApprovals.clear();
109115
env.parkedApproval = undefined;
110116
env.approvalGateCount = 0;
@@ -213,6 +219,9 @@ export async function runTurn(
213219
void pause.signal.then(() => runLimits.notePaused());
214220

215221
const openToolCallIds = (): string[] => run.openToolCallIds?.() ?? [];
222+
const approvedExecutionSeeds = new Map<string, ParkedApprovedExecution>(
223+
carriedApprovedExecutions.map((seed) => [seed.toolCallId, seed]),
224+
);
216225
const bufferedPausedCompletedFrames = new Map<string, unknown>();
217226
const toolCallClosureWaiters = new Map<string, Set<() => void>>();
218227
const notifyToolCallClosed = (toolCallId: string): void => {
@@ -288,6 +297,30 @@ export async function runTurn(
288297
) {
289298
env.lastTurnToolCallIds.push(frame.toolCallId);
290299
}
300+
if (
301+
frame?.sessionUpdate === "tool_call" &&
302+
typeof frame.toolCallId === "string" &&
303+
frame.toolCallId
304+
) {
305+
const announced = update as {
306+
name?: unknown;
307+
title?: unknown;
308+
kind?: unknown;
309+
rawInput?: unknown;
310+
input?: unknown;
311+
};
312+
const existing = approvedExecutionSeeds.get(frame.toolCallId);
313+
const toolName = [announced.name, announced.title, announced.kind]
314+
.find(
315+
(value): value is string =>
316+
typeof value === "string" && value.length > 0,
317+
) ?? existing?.toolName;
318+
approvedExecutionSeeds.set(frame.toolCallId, {
319+
toolCallId: frame.toolCallId,
320+
toolName,
321+
args: announced.rawInput ?? announced.input ?? existing?.args,
322+
});
323+
}
291324
const toolCallId =
292325
typeof rawFrame.toolCallId === "string"
293326
? rawFrame.toolCallId
@@ -339,6 +372,18 @@ export async function runTurn(
339372
extractClientToolOutputs(request),
340373
);
341374
const executionGrants = new ApprovedExecutionGrants();
375+
const seedApprovedExecution = (seed: ParkedApprovedExecution): void => {
376+
approvedExecutionSeeds.set(seed.toolCallId, seed);
377+
run.handleUpdate({
378+
sessionUpdate: "tool_call",
379+
toolCallId: seed.toolCallId,
380+
title: seed.toolName,
381+
kind: seed.toolName,
382+
rawInput: seed.args,
383+
});
384+
pause.markAllowedExecution(seed.toolCallId);
385+
executionGrants.grant(seed.toolName, seed.args);
386+
};
342387
const responder =
343388
deps.responderFactory?.(request) ??
344389
new ApprovalResponder(permissionPlan, decisions, logger);
@@ -606,6 +651,9 @@ export async function runTurn(
606651
const decisions = opts.resume.decisions;
607652
promptPromise = Promise.resolve(decisions[0]?.promptPromise);
608653
promptPromise.catch(() => {});
654+
for (const seed of carriedApprovedExecutions) {
655+
seedApprovedExecution(seed);
656+
}
609657
for (const decision of decisions) {
610658
// Seed this run's trace with the parked tool call so the completing `tool_call_update`
611659
// closes it and the FE approval part flips to output-available even if the adapter
@@ -622,6 +670,11 @@ export async function runTurn(
622670
// confirm resolves) passes the relay guard. Claude resumes grant too — harmlessly, no
623671
// guard consults it.
624672
if (decision.reply === "once") {
673+
approvedExecutionSeeds.set(decision.toolCallId, {
674+
toolCallId: decision.toolCallId,
675+
toolName: decision.toolName,
676+
args: decision.args,
677+
});
625678
pause.markAllowedExecution(decision.toolCallId);
626679
executionGrants.grant(decision.toolName, decision.args);
627680
}
@@ -674,19 +727,42 @@ export async function runTurn(
674727
settleBufferedPausedCompletions();
675728
const openAllowedExecutions = openToolCallIds()
676729
.filter((id) => pause.isAllowedExecution(id));
677-
await Promise.all(
678-
openAllowedExecutions.map(async (toolCallId) => {
679-
const closed = await waitForToolCallClosure(
730+
const piBatchBlockedByApproval = Boolean(
731+
opts.resume &&
732+
plan.isPi &&
733+
opts.approvalParkMode &&
734+
env.parkedApprovals.size > 0,
735+
);
736+
if (piBatchBlockedByApproval) {
737+
// Pi prepares every call in a parallel batch before it executes any of them. While a
738+
// sibling gate is pending, closure is impossible, so carry the approved spans and park.
739+
for (const toolCallId of openAllowedExecutions) {
740+
const seed = approvedExecutionSeeds.get(toolCallId) ?? {
680741
toolCallId,
681-
resolvedRunLimits.toolCallMs,
682-
);
683-
if (closed) return;
742+
toolName: undefined,
743+
args: undefined,
744+
};
745+
parkedApprovedExecutions.set(toolCallId, seed);
684746
run.settleOpenToolCalls(
685747
(id) => id !== toolCallId,
686748
APPROVED_EXECUTION_RESULT_UNKNOWN,
687749
);
688-
}),
689-
);
750+
}
751+
} else {
752+
await Promise.all(
753+
openAllowedExecutions.map(async (toolCallId) => {
754+
const closed = await waitForToolCallClosure(
755+
toolCallId,
756+
resolvedRunLimits.toolCallMs,
757+
);
758+
if (closed) return;
759+
run.settleOpenToolCalls(
760+
(id) => id !== toolCallId,
761+
APPROVED_EXECUTION_RESULT_UNKNOWN,
762+
);
763+
}),
764+
);
765+
}
690766
settleBufferedPausedCompletions();
691767
run.settleOpenToolCalls(
692768
(id) =>

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

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,17 @@ export interface ResumeApprovalInput {
137137
promptPromise?: Promise<unknown>;
138138
}
139139

140+
/**
141+
* An approved Pi call whose batched execution is still blocked by a sibling approval. The next
142+
* resume seeds this context into its tracer and execution-grant ledger before Pi can emit the
143+
* batch's real terminal frame.
144+
*/
145+
export interface ParkedApprovedExecution {
146+
toolCallId: string;
147+
toolName: string | undefined;
148+
args: unknown;
149+
}
150+
140151
/** Per-turn options for `runTurn`. Absent (flag off / cold) means today's byte-identical path. */
141152
export interface RunTurnOptions {
142153
/** A live continuation: send only the new user text instead of the full cold transcript. */
@@ -240,6 +251,11 @@ export interface SessionEnvironment {
240251
* The multi-answer resume and the all-parkable park check read `parkedApprovals`, not this.
241252
*/
242253
parkedApproval?: ParkedApproval;
254+
/**
255+
* Approved Pi calls settled with the non-retry unknown-result sentinel while a sibling gate was
256+
* parked. Consumed and re-seeded on the next live resume; empty outside that internal carry.
257+
*/
258+
parkedApprovedExecutions?: Map<string, ParkedApprovedExecution>;
243259
/**
244260
* How many ACP permission gates resolved to pendingApproval THIS turn (reset at turn start).
245261
* Equals `parkedApprovals.size` when every gate carried a resumable tool-call id; a larger

0 commit comments

Comments
 (0)