diff --git a/services/runner/src/engines/sandbox_agent.ts b/services/runner/src/engines/sandbox_agent.ts index 3daae64111..d03069325a 100644 --- a/services/runner/src/engines/sandbox_agent.ts +++ b/services/runner/src/engines/sandbox_agent.ts @@ -1835,6 +1835,16 @@ export async function runTurn( } const stopReason = raced === PAUSED || pause.active ? "paused" : (raced as any)?.stopReason; + // Pause notification is immediate, but terminalization must wait for managed cancellation + // and already-queued ACP updates. Re-sweep after the drain so a sibling announced during + // cancellation receives exactly one deterministic terminal result before `done`. + if (stopReason === "paused") { + await pause.waitForEventDrain(); + run.settleOpenToolCalls( + (id) => pause.isPausedToolCall(id), + TOOL_NOT_EXECUTED_PAUSED, + ); + } const result = raced === PAUSED ? undefined : raced; // A parkable pause this turn: hand the still-pending prompt promise to the parked record so a // later resume can await the same continuation. (Set after the race so `promptPromise` exists. diff --git a/services/runner/src/engines/sandbox_agent/pause.ts b/services/runner/src/engines/sandbox_agent/pause.ts index 12156877e8..15bfb4d22b 100644 --- a/services/runner/src/engines/sandbox_agent/pause.ts +++ b/services/runner/src/engines/sandbox_agent/pause.ts @@ -12,10 +12,13 @@ export class PendingApprovalPauseController { private pendingApproval = false; private readonly pausedToolCallIds = new Set(); private resolvePause: (() => void) | undefined; + private eventDrain: Promise = Promise.resolve(); readonly signal: Promise; - constructor(private readonly destroySession: () => Promise | void | undefined) { + constructor( + private readonly destroySession: () => Promise | void | undefined, + ) { this.signal = new Promise((resolve) => { this.resolvePause = resolve; }); @@ -24,8 +27,23 @@ export class PendingApprovalPauseController { pause(): void { if (this.pendingApproval) return; this.pendingApproval = true; + let destroyResult: Promise | void | undefined; + try { + destroyResult = this.destroySession(); + } catch { + destroyResult = undefined; + } + this.eventDrain = Promise.resolve(destroyResult) + .catch(() => {}) + .then(() => new Promise((resolve) => setImmediate(resolve))); + // The turn races this immediate signal; terminalization separately awaits eventDrain so the + // permission callback never holds the human pause open while queued ACP updates still settle. this.resolvePause?.(); - void Promise.resolve(this.destroySession()).catch(() => {}); + } + + /** Wait until managed cancellation and already-queued ACP updates have drained. */ + waitForEventDrain(): Promise { + return this.eventDrain; } /** diff --git a/services/runner/tests/unit/pending-approval-pause.test.ts b/services/runner/tests/unit/pending-approval-pause.test.ts new file mode 100644 index 0000000000..ca70f9a14b --- /dev/null +++ b/services/runner/tests/unit/pending-approval-pause.test.ts @@ -0,0 +1,56 @@ +import assert from "node:assert/strict"; +import { describe, it } from "vitest"; + +import { PendingApprovalPauseController } from "../../src/engines/sandbox_agent/pause.ts"; + +describe("PendingApprovalPauseController", () => { + it("signals the pause before managed cancellation and queued updates drain", async () => { + let finishDestroy: (() => void) | undefined; + const queuedUpdates: string[] = []; + const destroySession = new Promise((resolve) => { + finishDestroy = () => { + setImmediate(() => queuedUpdates.push("session update")); + resolve(); + }; + }); + const pause = new PendingApprovalPauseController(() => destroySession); + + pause.pause(); + await pause.signal; + + let drainFinished = false; + const drain = pause.waitForEventDrain().then(() => { + drainFinished = true; + }); + await Promise.resolve(); + assert.equal(drainFinished, false); + + finishDestroy?.(); + await drain; + + assert.equal(drainFinished, true); + assert.deepEqual(queuedUpdates, ["session update"]); + }); + + it("finishes the event drain when managed cancellation rejects", async () => { + const pause = new PendingApprovalPauseController(() => + Promise.reject(new Error("session already gone")), + ); + + pause.pause(); + + await assert.doesNotReject(pause.signal); + await assert.doesNotReject(pause.waitForEventDrain()); + }); + + it("finishes the event drain when managed cancellation throws", async () => { + const pause = new PendingApprovalPauseController(() => { + throw new Error("session already gone"); + }); + + pause.pause(); + + await assert.doesNotReject(pause.signal); + await assert.doesNotReject(pause.waitForEventDrain()); + }); +});