diff --git a/packages/adapter-utils/src/acpx-engine/execute.test.ts b/packages/adapter-utils/src/acpx-engine/execute.test.ts index 5575b37b46..c47d057e3e 100644 --- a/packages/adapter-utils/src/acpx-engine/execute.test.ts +++ b/packages/adapter-utils/src/acpx-engine/execute.test.ts @@ -6108,4 +6108,64 @@ describe("ACPX engine sandbox bridge run-disposition seam (fail-closed)", () => fake.emitLoss("provider_exit"); expect(fake.readDisposition().failed).toBe(false); }); + + it("releases the runtime locally and places no remote close call once the duplex channel is lost", async () => { + const sandbox = await setupRemoteSandbox(); + const fake = createFakeBridgeHandle(); + let closeCalls = 0; + const runtime = runtimeWithControlledResult(() => fake.emitLoss("provider_exit")); + // A close call with no deadline of its own would hang forever on a dead + // channel. The test times out if the teardown still places the call. + runtime.close = () => { + closeCalls += 1; + return new Promise(() => {}); + }; + + const result = await runRemote(fake.handle, runtime, sandbox); + + expect(result.errorCode).toBe("duplex_channel_lost"); + expect(closeCalls).toBe(0); + }); + + it("skips the remote close when the channel loses during the awaited turn-error finalization, after the settlement snapshot", async () => { + const sandbox = await setupRemoteSandbox(); + const fake = createFakeBridgeHandle(); + let closeCalls = 0; + // The event stream errors out (a `turnFinalize` "error" input, not a + // "terminal" one), so the settlement snapshot never inspects the bridge + // disposition and always sets `skipRemoteClose: false`. Order the channel + // loss inside the event stream, right before it throws, so the loss + // latches strictly after that snapshot and before the end-session step + // reaches its remote-close boundary. + const runtime = { + ensureSession: async () => ({ + backendSessionId: "backend-session", + agentSessionId: "agent-session", + runtimeSessionName: "runtime-session", + }), + startTurn: () => ({ + events: (async function* () { + fake.emitLoss("provider_exit"); + throw new Error("turn upstream boom"); + })(), + result: Promise.resolve({ status: "completed" as const, stopReason: "end_turn" }), + cancel: async () => {}, + }), + setConfigOption: async () => {}, + // A close call with no deadline of its own would hang forever on a dead + // channel. The test fails on a nonzero call count instead of timing out. + close: () => { + closeCalls += 1; + return new Promise(() => {}); + }, + }; + + const result = await runRemote(fake.handle, runtime, sandbox); + + expect(result.errorCode).toBe("acpx_turn_failed"); + // The end-session step re-read the disposition at the remote-close + // boundary and saw the loss the settlement snapshot missed, so it + // released the runtime locally and placed no remote close call. + expect(closeCalls).toBe(0); + }); }); diff --git a/packages/adapter-utils/src/acpx-engine/execute.ts b/packages/adapter-utils/src/acpx-engine/execute.ts index 29261ea596..65a77de060 100644 --- a/packages/adapter-utils/src/acpx-engine/execute.ts +++ b/packages/adapter-utils/src/acpx-engine/execute.ts @@ -2405,6 +2405,10 @@ interface RuntimeSettlementPlan { // Cancel the running turn with this reason before the close (the turn-error // path cancels before it closes). Null on every other path. readonly cancelTurnReason: string | null; + // True when the duplex control channel is already known lost. The settlement + // then releases the runtime locally and places no remote close call, because + // that call has no deadline of its own and would block on the dead channel. + readonly skipRemoteClose: boolean; } function renderPaperclipEnvNote(env: Record): string { @@ -3803,6 +3807,7 @@ export function createAcpxEngineExecutor(deps: AcpxEngineExecutorOptions = {}) { dropWarmEntry: true, recordCloseError: true, cancelTurnReason: null, + skipRemoteClose: false, }; await emitPhase("ensure_session", ensureSessionPhaseStart, "failed"); const { classified, message } = await emitAcpxFailure({ @@ -3839,6 +3844,7 @@ export function createAcpxEngineExecutor(deps: AcpxEngineExecutorOptions = {}) { dropWarmEntry: false, recordCloseError: true, cancelTurnReason: null, + skipRemoteClose: false, }; await emitPhase("ensure_session", ensureSessionPhaseStart, "failed"); capturedResult = { @@ -3922,6 +3928,7 @@ export function createAcpxEngineExecutor(deps: AcpxEngineExecutorOptions = {}) { dropWarmEntry: true, recordCloseError: true, cancelTurnReason: null, + skipRemoteClose: false, }; await emitPhase("configure_session", configureSessionStart, "failed"); const { classified, message } = await emitAcpxFailure({ @@ -4175,6 +4182,7 @@ export function createAcpxEngineExecutor(deps: AcpxEngineExecutorOptions = {}) { dropWarmEntry: false, recordCloseError: false, cancelTurnReason: null, + skipRemoteClose: channelLost, }; const errorMessage = timedOut @@ -4313,6 +4321,7 @@ export function createAcpxEngineExecutor(deps: AcpxEngineExecutorOptions = {}) { dropWarmEntry: true, recordCloseError: true, cancelTurnReason: preEmitMessage, + skipRemoteClose: false, }; // Emit the failure best-effort. `turnFinalize` must not reject, so a // failing emission never propagates: the settlement owns the teardown, and @@ -4420,12 +4429,34 @@ export function createAcpxEngineExecutor(deps: AcpxEngineExecutorOptions = {}) { dropWarmEntry: false, recordCloseError: true, cancelTurnReason: null, + skipRemoteClose: false, }; // Cancel a running turn before the close (the turn-error path). if (settlement.cancelTurnReason && activeTurn) { await activeTurn.cancel({ reason: settlement.cancelTurnReason }).catch(() => {}); } const existing = warmHandles.get(prepared.sessionKey); + // Re-read the duplex control-channel disposition here, at the + // boundary that places the remote call. The settlement snapshot + // above can predate a channel loss the bridge latches during the + // awaited finalization work between the snapshot and this point, so + // a stale `false` on the snapshot must not force a call onto a + // channel that is dead by now. The read is non-mutating and only + // adds a later-observed loss; it never clears the snapshot's `true`. + const remoteChannelLost = + settlement.skipRemoteClose || (prepared.paperclipBridge?.readRunDisposition?.().failed ?? false); + // The control channel is already known lost, so no remote call can + // reach the backend. Release the local bookkeeping only and place no + // `runtime.close(...)` call — that call has no deadline of its own + // and would block on the dead channel. + if (remoteChannelLost) { + if (warmHandleMatches(existing, runtime, settlement.handle) && existing) { + clearWarmHandleTimer(existing); + warmHandles.delete(prepared.sessionKey); + flushChildStderr(existing.childStderrState); + } + return; + } if ( settlement.mode === "warm_or_close" && warmHandleMatches(existing, runtime, settlement.handle) &&