Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions services/runner/src/engines/sandbox_agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
22 changes: 20 additions & 2 deletions services/runner/src/engines/sandbox_agent/pause.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,13 @@ export class PendingApprovalPauseController {
private pendingApproval = false;
private readonly pausedToolCallIds = new Set<string>();
private resolvePause: (() => void) | undefined;
private eventDrain: Promise<void> = Promise.resolve();

readonly signal: Promise<void>;

constructor(private readonly destroySession: () => Promise<void> | void | undefined) {
constructor(
private readonly destroySession: () => Promise<void> | void | undefined,
) {
this.signal = new Promise<void>((resolve) => {
this.resolvePause = resolve;
});
Expand All @@ -24,8 +27,23 @@ export class PendingApprovalPauseController {
pause(): void {
if (this.pendingApproval) return;
this.pendingApproval = true;
let destroyResult: Promise<void> | void | undefined;
try {
destroyResult = this.destroySession();
} catch {
destroyResult = undefined;
}
this.eventDrain = Promise.resolve(destroyResult)
.catch(() => {})
.then(() => new Promise<void>((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<void> {
return this.eventDrain;
}

/**
Expand Down
56 changes: 56 additions & 0 deletions services/runner/tests/unit/pending-approval-pause.test.ts
Original file line number Diff line number Diff line change
@@ -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<void>((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());
});
});
Loading