Skip to content

Commit 4cdc95b

Browse files
committed
feat(runner): dispatch each approval independently and accept partial answer sets
1 parent 6645ee4 commit 4cdc95b

7 files changed

Lines changed: 304 additions & 78 deletions

File tree

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

Lines changed: 15 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -200,6 +200,14 @@ export async function runTurn(
200200
env.sessionDestroyRequested = true;
201201
return env.sandbox.destroySession?.(env.session.id);
202202
});
203+
if (opts.resume?.carriedForward.length) {
204+
for (const gate of opts.resume.carriedForward) {
205+
env.parkedApprovals.set(gate.toolCallId, gate);
206+
env.parkedApproval ??= gate;
207+
pause.markPausedToolCall(gate.toolCallId);
208+
}
209+
env.approvalGateCount = env.parkedApprovals.size;
210+
}
203211
// A human pause resolves this signal exactly once, the moment the turn parks for input — the one
204212
// place every pause path converges, so the one place to retire the run-limits deadlines for good.
205213
void pause.signal.then(() => runLimits.notePaused());
@@ -592,11 +600,8 @@ export async function runTurn(
592600
// resolves, and the pause signal ends the turn.
593601
let promptPromise: Promise<unknown>;
594602
if (opts.resume) {
595-
// The new (resume) turn owns streaming + tracing; the environment is already wired to route
596-
// continued events into this turn's sink (env.currentTurn was set above). Every parked gate
597-
// of the turn is answered here, one `respondPermission` per gate keyed by its tool-call id.
598-
// All decisions share the ONE held prompt promise (one prompt per turn), so it is set once;
599-
// answering the last gate lets the original prompt continue from here.
603+
// The resume turn owns continued events; each decision answers one parked gate by id.
604+
// Carried gates keep the shared original prompt pending until a later answer.
600605
const decisions = opts.resume.decisions;
601606
promptPromise = Promise.resolve(decisions[0]?.promptPromise);
602607
promptPromise.catch(() => {});
@@ -629,9 +634,8 @@ export async function runTurn(
629634
// independently — an approve and a deny in the same turn each land on the right call.
630635
await env.session.respondPermission(decision.permissionId, decision.reply);
631636
// The gate is answered: resolve its durable interaction row (the parked pending row the
632-
// cold path would otherwise resolve via its decision map). The fresh per-turn pause
633-
// controller starts with an EMPTY pausedToolCallIds set, so the resumed calls'
634-
// `tool_call_update` frames are no longer suppressed and stream through.
637+
// cold path would otherwise resolve via its decision map). Only carried-forward ids were
638+
// re-marked paused, so answered calls stream their terminal frames normally.
635639
resolveInteractionToken(decision.interactionToken, {
636640
approved: decision.reply === "once",
637641
toolCallId: decision.toolCallId,
@@ -640,6 +644,9 @@ export async function runTurn(
640644
`[keepalive] resume answered gate reply=${decision.reply} tool=${decision.toolName ?? "?"}`,
641645
);
642646
}
647+
// The harness still holds carried gates inside the original prompt, so re-arm the pause after
648+
// this answer batch and let the normal park path refresh their approval TTL.
649+
if (opts.resume.carriedForward.length > 0) pause.pause();
643650
} else {
644651
promptPromise = Promise.resolve(
645652
env.session.prompt([{ type: "text", text: turnText }]),

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

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -157,13 +157,13 @@ export interface RunTurnOptions {
157157
*/
158158
approvalParkMode?: boolean;
159159
/**
160-
* A live approval resume: answer every parked gate of the turn (keyed by tool-call id) and
161-
* stream the continued prompt's events. A turn can hold more than one parked gate, so the
162-
* resume carries a LIST — the server builds it from the inbound approval replies, one decision
163-
* per parked gate. All decisions share the one held prompt promise (there is one prompt per
164-
* turn); `runTurn` answers each gate then awaits that single continuation.
160+
* A live approval resume: answer the matching parked gates and carry the untouched gates into
161+
* the next park. All decisions share the one held prompt promise (there is one prompt per turn).
165162
*/
166-
resume?: { decisions: ResumeApprovalInput[] };
163+
resume?: {
164+
decisions: ResumeApprovalInput[];
165+
carriedForward: ParkedApproval[];
166+
};
167167
}
168168

169169
/**

services/runner/src/server.ts

Lines changed: 15 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ import {
3737
runSandboxAgent,
3838
runTurn,
3939
shouldPark,
40+
type ParkedApproval,
4041
type ResumeApprovalInput,
4142
type RunTurnOptions,
4243
type SessionEnvironment,
@@ -678,15 +679,12 @@ export async function runWithKeepalive(
678679
// history fingerprint (an edited transcript must not continue wrongly), and a hard mount-expiry
679680
// bound — if the parked session's mount credentials are past expiry, its durable cwd can no
680681
// longer be written, so evict to cold.
681-
// The turn may hold several parked gates (parallel gated tool calls). Build one resume
682-
// decision per parked gate, keyed by tool-call id. The whole turn resumes live ONLY when the
683-
// request answers EVERY parked gate — the frontend's all-settled rule guarantees a resume /run
684-
// is sent only after the human answered all cards, so a partial set is a mismatch that
685-
// degrades to cold (which re-raises and multiplexes whatever subset). `parked` (the first
686-
// gate) is the representative for logging and the per-turn-uniform checks.
682+
// Split parallel parked gates by tool-call id. Any non-empty answer set resumes live; untouched
683+
// gates stay pending in the same process and are carried into the next approval park.
687684
const parked = existing.environment.parkedApproval;
688685
const parkedList = [...existing.environment.parkedApprovals.values()];
689686
const resumeDecisions: ResumeApprovalInput[] = [];
687+
const carriedForward: ParkedApproval[] = [];
690688
let mismatch: string | undefined;
691689
if (parkedList.length === 0) {
692690
mismatch = "no-parked-gate";
@@ -703,9 +701,8 @@ export async function runWithKeepalive(
703701
}
704702
const decision = approvalDecisionForToolCall(request, gate.toolCallId);
705703
if (!decision) {
706-
// A gate with no matching reply: fresh user text, or the human answered only some cards.
707-
mismatch = "no-matching-approval";
708-
break;
704+
carriedForward.push(gate);
705+
continue;
709706
}
710707
resumeDecisions.push({
711708
permissionId: gate.permissionId,
@@ -746,7 +743,8 @@ export async function runWithKeepalive(
746743
).length;
747744
const rejectCount = resumeDecisions.length - approveCount;
748745
klog(
749-
`resume key=${key} gates=${resumeDecisions.length} ` +
746+
`resume key=${key} gates=${parkedList.length} answered=${resumeDecisions.length} ` +
747+
`carried=${carriedForward.length} ` +
750748
`approve=${approveCount} reject=${rejectCount} tool=${parked?.toolName ?? "?"}`,
751749
);
752750
let result: AgentRunResult;
@@ -761,7 +759,7 @@ export async function runWithKeepalive(
761759
signal,
762760
{
763761
approvalParkMode: true,
764-
resume: { decisions: resumeDecisions },
762+
resume: { decisions: resumeDecisions, carriedForward },
765763
},
766764
);
767765
} catch (err) {
@@ -839,8 +837,8 @@ const runAgent: RunAgent = (request, emit, signal, options) => {
839837
};
840838

841839
/**
842-
* The durable interaction token of a parked approval gate this request answers in-band, if
843-
* any. The turn-start cancel-stale sweep must spare it: the gate belongs to the PREVIOUS turn
840+
* The durable interaction tokens of parked approval gates this request answers in-band, if
841+
* any. The turn-start cancel-stale sweep must spare them: each gate belongs to the PREVIOUS turn
844842
* (so the sweep's own `turn_id` exemption misses it), an in-band answer never transitions the
845843
* row off `pending` (only the interactions-plane respond endpoint does), and the resume
846844
* resolves the token after consuming the decision. Swept first, the granted gate's record
@@ -855,18 +853,13 @@ function inBandAnswerTokens(request: AgentRunRequest): string[] | undefined {
855853
keepalivePools[provider].awaitingApproval(sessionId)?.environment
856854
.parkedApprovals;
857855
if (!parked || parked.size === 0) return undefined;
858-
// A turn can park several gates. Spare tokens from the stale sweep ONLY when this request
859-
// answers EVERY parked gate — that is exactly when the dispatch resumes live and resolves them.
860-
// If any gate is unanswered the turn degrades to cold (the all-or-cold resume rule), and sparing
861-
// an answered subset would strand those interaction rows as pending forever (no live resume ever
862-
// resolves them). In that case spare nothing: the cold path re-raises and the sweep correctly
863-
// cancels the old rows.
856+
// A partial resume resolves only the answered rows. Carried-forward rows stay pending and receive
857+
// a fresh approval park, so only matching answer tokens are exempt from stale cancellation.
864858
const tokens: string[] = [];
865859
for (const gate of parked.values()) {
866-
if (approvalDecisionForToolCall(request, gate.toolCallId) === undefined) {
867-
return undefined;
860+
if (approvalDecisionForToolCall(request, gate.toolCallId) !== undefined) {
861+
tokens.push(gate.interactionToken);
868862
}
869-
tokens.push(gate.interactionToken);
870863
}
871864
return tokens.length > 0 ? tokens : undefined;
872865
}

0 commit comments

Comments
 (0)