fix(adapter-utils): fail fast when the sandbox control channel is lost mid-turn (#13158)

## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - Adapter utilities run agent turns and report their results to the
control plane
> - A lost sandbox control channel can leave an agent turn without a
result
> - The host then waits for the full adapter timeout instead of
reporting the loss
> - This pull request adds a push loss signal and a bounded host wait
> - The benefit is a prompt failure terminal when the agent stops
answering

## Linked Issues or Issue Description

**What happened?**

A sandbox control channel loss during an Agent Client Protocol turn left
the host waiting for the four-hour adapter execution timeout.

**Expected behavior**

The host should detect the terminal channel loss, stop the turn, and
report a safe failure without waiting for the agent.

**Steps to reproduce**

1. Start an Agent Client Protocol turn through a sandbox adapter.
2. Close the duplex control channel while the turn remains active.
3. Observe the host response before the adapter timeout expires.

**Paperclip version or commit**

Test the pull request commit set at
`10b6bbc5525a79fd575298607dd5a25ae448fc8a`.

**Deployment mode**

The change applies to sandbox-backed adapter execution.

## What Changed

- Add `onLoss(listener)` to the duplex bridge handle.
- Register the loss listener at turn start and read losses latched
before turn start.
- Cancel the turn on loss and arm a 30-second host deadline.
- Close the stream locally when the deadline wins and create a host
terminal.
- Derive the public error from the closed `DuplexLossReason` enum.
- Add tests for loss order, cancellation, timeout, and safe error
output.

## Verification

- Run `pnpm --filter @paperclipai/adapter-utils exec tsc --noEmit`.
- Run `pnpm --filter @paperclipai/adapter-utils exec vitest run
src/acpx-engine/execute.test.ts -t "run-disposition seam"`.
- Confirm that the full pull request workflow passes.

## Risks

The new deadline changes a lost-channel path from a long wait to a
host-built failure after 30 seconds. Orderly completion keeps its
existing behavior. The deadline race against a pending `turn.result` has
no direct test.

## Model Used

OpenAI Codex, GPT-5, with tool use and code execution. The runtime does
not expose a more specific deployment version or context-window size.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` /
`Closes: #` / `Refs: #` OR (b) described the issue in-PR following the
relevant issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Nicky Leach 2026-09-10 15:37:05 -07:00 committed by GitHub
parent 60ee13a0f7
commit d1ba17eeca
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 470 additions and 73 deletions

View File

@ -18,6 +18,14 @@ export const ACPX_HANDSHAKE_TIMEOUT_MS = 60_000;
// of a channel loss.
export const ACPX_HANDSHAKE_TRANSPORT_POLL_MS = 250;
// The bound on how long the host waits, after a latched terminal sandbox
// duplex-channel loss, for the agent to answer the `turn.cancel()` request.
// `cancel()` only asks the agent to end the turn; it does not end the turn by
// itself. An agent that stopped answering never honors it, so this deadline
// is the host-side bound that ends the run without the agent's help. It is
// much smaller than the whole-adapter execution timeout.
export const ACPX_DUPLEX_LOSS_CANCEL_DEADLINE_MS = 30_000;
export const ACPX_ADAPTER_AGENT_IDS = {
claude_local: "claude",
codex_local: "codex",

View File

@ -6129,6 +6129,7 @@ describe("ACPX engine sandbox bridge run-disposition seam (fail-closed)", () =>
let lossOrdered = false;
let lossReason: string | null = null;
let completionOrdered = false;
let lossListener: ((reason: string) => void) | null = null;
const readDisposition = () => ({ failed: lossOrdered, lossReason });
const markOrderlyCompletion = vi.fn(() => {
if (completionOrdered || lossOrdered) return;
@ -6138,6 +6139,12 @@ describe("ACPX engine sandbox bridge run-disposition seam (fail-closed)", () =>
markOrderlyCompletion();
return readDisposition();
});
const onLoss = vi.fn((listener: (reason: string) => void) => {
lossListener = listener;
return () => {
if (lossListener === listener) lossListener = null;
};
});
const stop = vi.fn(async () => {});
const handle = {
env: {
@ -6148,19 +6155,25 @@ describe("ACPX engine sandbox bridge run-disposition seam (fail-closed)", () =>
readRunDisposition: () => readDisposition(),
settleRunDisposition,
markOrderlyCompletion,
onLoss,
stop,
};
return {
handle,
markOrderlyCompletion,
settleRunDisposition,
onLoss,
readDisposition,
// Record the first ordered loss. A loss ordered after a completion, or a
// second loss, is a no-op — the same rule the real transport applies.
// A loss that latches here (the first ordered call) also pushes the
// reason to the one registered listener, the same way the real HTTP/2
// transport's disposition latch does.
emitLoss: (reason: string) => {
if (lossOrdered || completionOrdered) return;
lossOrdered = true;
lossReason = reason;
lossListener?.(reason);
},
};
}
@ -6215,6 +6228,81 @@ describe("ACPX engine sandbox bridge run-disposition seam (fail-closed)", () =>
};
}
// A runtime whose one turn never resolves on its own — it hangs exactly
// like a turn whose sandbox duplex channel died mid-turn produces no
// terminal result. The turn only ends when something calls `cancel()`, the
// same mechanism the push seam calls. `onCancel` observes each call.
function hangingTurnRuntime(onCancel: (reason: string | undefined) => void) {
let release: (() => void) | null = null;
const released = new Promise<void>((resolve) => {
release = resolve;
});
return {
ensureSession: async () => ({
backendSessionId: "backend-session",
agentSessionId: "agent-session",
runtimeSessionName: "runtime-session",
}),
startTurn: () => ({
events: (async function* () {
await released;
})(),
result: (async () => {
await released;
return { status: "cancelled" as const, stopReason: "cancelled" };
})(),
cancel: async (input?: { reason?: string }) => {
onCancel(input?.reason);
release?.();
},
}),
setConfigOption: async () => {},
close: async () => {},
};
}
// A runtime whose one turn hangs exactly like the real `acpx` shape: its
// `cancel()` only sends the cancel request and returns. It does NOT settle
// the turn — neither `events` nor `result` ever resolves on its own.
// `closeStream()` ends the event drain locally, with no agent cooperation,
// the same way the real runtime's does; it still leaves `result` pending.
// This is the sensitivity control for the fail-fast deadline: only the
// deadline, not the cancel request, can end this turn.
function unresponsiveCancelTurnRuntime(input: {
onCancel: (reason: string | undefined) => void;
onCloseStream: (reason: string | undefined) => void;
}) {
let endEvents: (() => void) | null = null;
const eventsEnded = new Promise<void>((resolve) => {
endEvents = resolve;
});
return {
ensureSession: async () => ({
backendSessionId: "backend-session",
agentSessionId: "agent-session",
runtimeSessionName: "runtime-session",
}),
startTurn: () => ({
events: (async function* () {
await eventsEnded;
})(),
// Never settles on its own. The real acpx result settles only when
// the provider process returns or rejects, bounded by the adapter
// execution timeout — not by a `session/cancel` request.
result: new Promise<never>(() => {}),
cancel: async (reasonInput?: { reason?: string }) => {
input.onCancel(reasonInput?.reason);
},
closeStream: async (reasonInput?: { reason?: string }) => {
input.onCloseStream(reasonInput?.reason);
endEvents?.();
},
}),
setConfigOption: async () => {},
close: async () => {},
};
}
async function setupRemoteSandbox() {
const root = await makeTempRoot();
const stateDir = path.join(root, "state");
@ -6238,6 +6326,7 @@ describe("ACPX engine sandbox bridge run-disposition seam (fail-closed)", () =>
handle: unknown,
runtime: unknown,
sandbox: Awaited<ReturnType<typeof setupRemoteSandbox>>,
deps: Partial<AcpxEngineExecutorOptions> = {},
) {
vi.mocked(startAdapterExecutionTargetPaperclipBridge).mockImplementationOnce(
async () => handle as never,
@ -6247,6 +6336,7 @@ describe("ACPX engine sandbox bridge run-disposition seam (fail-closed)", () =>
);
const execute = createAcpxEngineExecutor({
createRuntime: () => runtime as never,
...deps,
});
return await execute({
runId: "run-duplex-seam",
@ -6342,6 +6432,25 @@ describe("ACPX engine sandbox bridge run-disposition seam (fail-closed)", () =>
expect(fake.readDisposition().failed).toBe(false);
});
it("keeps duplex_channel_lost precedence when the loss latches before a failed terminal", async () => {
const sandbox = await setupRemoteSandbox();
const fake = createFakeBridgeHandle();
// Latch the loss before the ACP terminal resolves, and the terminal
// itself also reports a provider failure.
const runtime = runtimeWithFailedResult(() => fake.emitLoss("provider_exit"));
const result = await runRemote(fake.handle, runtime, sandbox);
expect(result.exitCode).not.toBe(0);
// The duplex loss reason wins over the provider's own failed terminal.
expect(result.errorCode).toBe("duplex_channel_lost");
// The message carries only the typed loss reason, not the raw provider
// failure text.
expect(result.errorMessage).toContain("provider_exit");
expect(result.errorMessage).not.toContain("agent failed");
expect(result.resultJson).toMatchObject({ status: "failed" });
});
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();
@ -6435,6 +6544,170 @@ describe("ACPX engine sandbox bridge run-disposition seam (fail-closed)", () =>
expect(result.errorCode).not.toBe("acpx_session_init_failed");
expect(result.errorCode).not.toBe("acpx_handshake_timeout");
}, 10000);
it("aborts an in-flight turn and fails the run when the duplex channel latches a loss mid-turn", async () => {
const sandbox = await setupRemoteSandbox();
const fake = createFakeBridgeHandle();
const cancelReasons: (string | undefined)[] = [];
// This turn never returns a terminal result on its own: without a push
// seam it would wait for the wall-clock adapter execution timeout. It
// ends only once something calls `cancel()`.
const runtime = hangingTurnRuntime((reason) => cancelReasons.push(reason));
const resultPromise = runRemote(fake.handle, runtime, sandbox);
// Wait until the turn registers its loss listener, then latch the loss —
// the same order a real mid-turn channel death follows: the turn starts,
// then later the channel is lost.
await vi.waitFor(() => expect(fake.onLoss).toHaveBeenCalled());
fake.emitLoss("provider_exit");
const result = await resultPromise;
// The push seam cancelled the hanging turn instead of waiting for the
// turn to return a terminal result on its own, so the run ends promptly
// instead of waiting for the adapter execution timeout.
expect(cancelReasons).toHaveLength(1);
expect(result.exitCode).not.toBe(0);
expect(result.errorCode).toBe("duplex_channel_lost");
// The failure message carries only the closed loss-reason enum, never
// raw provider text.
expect(result.errorMessage).toContain("provider_exit");
expect(result.resultJson).toMatchObject({ status: "failed" });
}, 5000);
it("bounds the wait with a deadline when a latched loss cancel gets no agent cooperation", async () => {
const sandbox = await setupRemoteSandbox();
const fake = createFakeBridgeHandle();
const cancelReasons: (string | undefined)[] = [];
const closeStreamReasons: (string | undefined)[] = [];
// The real acpx shape: `cancel()` only requests cancellation and returns.
// It settles neither `events` nor `result`. Only the fail-fast deadline
// can end this turn.
const runtime = unresponsiveCancelTurnRuntime({
onCancel: (reason) => cancelReasons.push(reason),
onCloseStream: (reason) => closeStreamReasons.push(reason),
});
const resultPromise = runRemote(fake.handle, runtime, sandbox, {
// Small and fake-time-free: real-timer test, so the deadline must stay
// short enough to run fast without waiting 60 real seconds.
duplexLossCancelDeadlineMs: 25,
});
await vi.waitFor(() => expect(fake.onLoss).toHaveBeenCalled());
fake.emitLoss("provider_exit");
const result = await resultPromise;
// The seam still tried the cooperative cancel first.
expect(cancelReasons).toHaveLength(1);
// The agent never answered the cancel, so the deadline ended the event
// drain locally instead of waiting for it.
expect(closeStreamReasons).toHaveLength(1);
// The run reached a failure terminal within the deadline, even though
// neither `events` nor `result` ever settled on their own.
expect(result.exitCode).not.toBe(0);
expect(result.errorCode).toBe("duplex_channel_lost");
// The failure message carries only the closed loss-reason enum, never
// raw provider text.
expect(result.errorMessage).toContain("provider_exit");
expect(result.resultJson).toMatchObject({ status: "failed" });
}, 5000);
it("awaits stream closure and the event drain before finalizing a duplex loss deadline", async () => {
const sandbox = await setupRemoteSandbox();
const fake = createFakeBridgeHandle();
let endEvents: (() => void) | null = null;
const eventsEnded = new Promise<void>((resolve) => {
endEvents = resolve;
});
let releaseCloseStream!: () => void;
const closeStreamGate = new Promise<void>((resolve) => {
releaseCloseStream = resolve;
});
let closeStreamCalls = 0;
// `closeStream()` stays pending on a gate the test controls, and only
// ends the event drain once the test releases that gate. If the run
// finalizes before the gate opens, the seam did not wait for the close
// call, so a late event on this drain could still land after the result.
const runtime = {
ensureSession: async () => ({
backendSessionId: "backend-session",
agentSessionId: "agent-session",
runtimeSessionName: "runtime-session",
}),
startTurn: () => ({
events: (async function* () {
await eventsEnded;
})(),
result: new Promise<never>(() => {}),
cancel: async () => {},
closeStream: async () => {
closeStreamCalls += 1;
await closeStreamGate;
endEvents?.();
},
}),
setConfigOption: async () => {},
close: async () => {},
};
const resultPromise = runRemote(fake.handle, runtime, sandbox, {
duplexLossCancelDeadlineMs: 25,
});
await vi.waitFor(() => expect(fake.onLoss).toHaveBeenCalled());
fake.emitLoss("provider_exit");
await vi.waitFor(() => expect(closeStreamCalls).toBe(1));
// The close call has not resolved yet, so the run must still be pending.
let settled = false;
void resultPromise.then(() => {
settled = true;
});
await new Promise((resolve) => setTimeout(resolve, 50));
expect(settled).toBe(false);
releaseCloseStream();
const result = await resultPromise;
expect(result.errorCode).toBe("duplex_channel_lost");
}, 5000);
it("does not abort or fail an already-completed run when the duplex channel loses after an orderly completion", async () => {
const sandbox = await setupRemoteSandbox();
const fake = createFakeBridgeHandle();
const cancelReasons: (string | undefined)[] = [];
const runtime = {
ensureSession: async () => ({
backendSessionId: "backend-session",
agentSessionId: "agent-session",
runtimeSessionName: "runtime-session",
}),
startTurn: () => ({
events: (async function* () {
yield { type: "done", stopReason: "end_turn" };
})(),
result: Promise.resolve({ status: "completed" as const, stopReason: "end_turn" }),
cancel: async (input?: { reason?: string }) => {
cancelReasons.push(input?.reason);
},
}),
setConfigOption: async () => {},
close: async () => {},
};
const result = await runRemote(fake.handle, runtime, sandbox);
expect(result.exitCode).toBe(0);
expect(result.errorCode ?? null).toBeNull();
// The channel dies only after the turn already completed cleanly. The
// loss listener the turn registered is still live at this point, but the
// latch already marked the orderly completion, so the loss cannot relatch
// and must never reach a cancel call on the (already-finished) turn.
fake.emitLoss("provider_exit");
expect(cancelReasons).toHaveLength(0);
expect(fake.readDisposition().failed).toBe(false);
});
});
describe("ACPX startup handshake guard and late-completion fence", () => {

View File

@ -92,6 +92,7 @@ import {
type AcpSessionStore,
} from "acpx/runtime";
import {
ACPX_DUPLEX_LOSS_CANCEL_DEADLINE_MS,
ACPX_HANDSHAKE_TIMEOUT_MS,
ACPX_HANDSHAKE_TRANSPORT_POLL_MS,
DEFAULT_ACP_ENGINE_AGENT,
@ -342,6 +343,14 @@ export interface AcpxRemoteManagedHomeResult {
export interface AcpxEngineExecutorOptions {
createRuntime?: AcpxRuntimeFactory;
now?: () => number;
/**
* The bound on how long the fail-fast seam waits for a cooperative
* `turn.cancel()` after a latched terminal sandbox duplex-channel loss,
* before it ends the turn without the agent's help. Defaults to
* {@link ACPX_DUPLEX_LOSS_CANCEL_DEADLINE_MS}. Tests inject a small value
* to drive the deadline without real time.
*/
duplexLossCancelDeadlineMs?: number;
warmHandles?: Map<string, RuntimeCacheEntry>;
/**
* Per-session staged-runtime cache for the remote runner-backed lane (PR 3).
@ -3704,6 +3713,7 @@ function openTurnSpan(
export function createAcpxEngineExecutor(deps: AcpxEngineExecutorOptions = {}) {
const createRuntime = deps.createRuntime ?? createAcpRuntime;
const now = deps.now ?? (() => Date.now());
const duplexLossCancelDeadlineMs = deps.duplexLossCancelDeadlineMs ?? ACPX_DUPLEX_LOSS_CANCEL_DEADLINE_MS;
const warmHandles = deps.warmHandles ?? defaultWarmHandles;
const stagedRuntimes = deps.stagedRuntimes ?? defaultStagedRuntimes;
const stagingLocks = deps.stagingLocks ?? defaultStagingLocks;
@ -3800,6 +3810,24 @@ export function createAcpxEngineExecutor(deps: AcpxEngineExecutorOptions = {}) {
let releaseStagingLease: (() => void) | null = null;
let stopTimer: ReturnType<typeof setTimeout> | undefined;
let removeStopListener: (() => void) | undefined;
// Unregisters the sandbox duplex bridge's loss listener (below, in
// `stepTurnStart`). Set only on a sandbox target whose bridge exposes
// `onLoss`; stays undefined everywhere else, so the cleanup call is a
// no-op there.
let removeLossListener: (() => void) | undefined;
// Bounds the wait after a latched terminal duplex loss so a silent agent
// cannot hold the run open on the cooperative `turn.cancel()` request
// alone. `stepTurnStart` arms `lossDeadlineTimer` the moment a loss
// latches; it stays undefined everywhere else, so the cleanup call below
// is a no-op there. `stepEventRelay` races the turn against
// `lossDeadline` and, once it fires, ends the event drain and hands
// `turnFinalize` a host-built terminal instead of the agent's.
let lossDeadlineTimer: ReturnType<typeof setTimeout> | undefined;
let lossDeadlineTripped = false;
let resolveLossDeadline: (() => void) | undefined;
const lossDeadline = new Promise<void>((resolve) => {
resolveLossDeadline = resolve;
});
let forcedStop = false;
let runtimeStopConfirmed = false;
let safeInterruptedSession = false;
@ -4606,6 +4634,36 @@ export function createAcpxEngineExecutor(deps: AcpxEngineExecutorOptions = {}) {
signal,
});
activeTurn = turn;
// A latched sandbox duplex-channel loss otherwise has no way to reach
// this turn: the bridge only exposes a pull read, and the engine
// pulls it at the terminal-finalization boundary, which runs only
// after the turn already returned a terminal result. A channel that
// dies mid-turn then leaves the turn with no terminal result to
// return, so it waits for the wall-clock adapter execution timeout
// instead of failing fast. Cancel the turn the moment a terminal loss
// latches — whether it latches from here on, or already latched
// before this turn started — so the turn returns a terminal result
// right away. `turnFinalize` reads the same latch and builds the
// failure from the typed loss reason alone.
const bridge = prepared.paperclipBridge;
if (bridge?.onLoss) {
const cancelForLoss = (reason: DuplexLossReason) => {
void turn.cancel({ reason: `paperclip sandbox duplex channel lost (${reason})` }).catch(() => {});
// `cancel()` only asks the agent to end the turn; it does not end
// the turn by itself. Start the fail-fast deadline the moment the
// loss latches, so the run does not wait past this bound for an
// agent that stopped answering.
if (!lossDeadlineTimer && !lossDeadlineTripped) {
lossDeadlineTimer = setTimeout(() => {
lossDeadlineTripped = true;
resolveLossDeadline?.();
}, duplexLossCancelDeadlineMs);
}
};
removeLossListener = bridge.onLoss(cancelForLoss);
const alreadyLatched = bridge.readRunDisposition?.();
if (alreadyLatched?.failed) cancelForLoss(alreadyLatched.lossReason ?? "other");
}
// ACP can resolve the turn before its provider exits. Keep the Stop
// deadline armed through settlement, including provider cleanup.
const armStopDeadline = () => {
@ -4631,40 +4689,74 @@ export function createAcpxEngineExecutor(deps: AcpxEngineExecutorOptions = {}) {
},
};
};
// The host-built terminal `stepEventRelay` hands to `turnFinalize` once
// the fail-fast deadline fires with no agent-supplied terminal. Its
// `status` mirrors the shape a real cooperative cancel already
// produces, so `turnFinalize` needs no change: it reads the latched
// loss disposition, not this `stopReason`, to build the reported
// failure and its message.
const LOSS_DEADLINE_TERMINAL: AcpRuntimeTurnResult = {
status: "cancelled",
stopReason: "paperclip_duplex_loss_deadline",
};
const stepEventRelay = async (): Promise<AcpRuntimeTurnResult> => {
const turn = activeTurn as AcpRuntimeTurn;
const toolTitles = new Map<string, string>();
for await (const event of turn.events) {
// ACPX currently flattens client-side filesystem/terminal receipts
// into status text. They cannot establish complete action outcomes.
if (event.type === "status" && /^(fs|terminal)\//.test(event.text)) incompleteToolInventory = true;
if (event.type === "tool_call") {
if (!event.toolCallId) incompleteToolInventory = true;
else {
const previous = interruptionTools.get(event.toolCallId);
interruptionTools.set(event.toolCallId, {
kind: event.kind ?? previous?.kind,
status: event.status ?? previous?.status,
});
const drainEvents = (async (): Promise<void> => {
for await (const event of turn.events) {
// ACPX currently flattens client-side filesystem/terminal receipts
// into status text. They cannot establish complete action outcomes.
if (event.type === "status" && /^(fs|terminal)\//.test(event.text)) incompleteToolInventory = true;
if (event.type === "tool_call") {
if (!event.toolCallId) incompleteToolInventory = true;
else {
const previous = interruptionTools.get(event.toolCallId);
interruptionTools.set(event.toolCallId, {
kind: event.kind ?? previous?.kind,
status: event.status ?? previous?.status,
});
}
}
if (event.type === "text_delta" && event.stream !== "thought") {
currentOutputChunk.push(event.text);
} else if (event.type === "tool_call" && event.tag !== "tool_call_update") {
// ACP makes tool-call status optional. The normalized event tag is
// the reliable boundary between an initial call and its updates,
// so a statusless initial call must still end the preceding output
// segment while updates must not create extra boundaries.
flushOutputSegment();
}
if (event.type === "status" && event.tag === "usage_update") {
eventBreakdown = event.breakdown ?? eventBreakdown;
eventCostUsd = usdCostAmount(event.cost) ?? eventCostUsd;
}
await emitRuntimeEvent(ctx, event, toolTitles, prepared.coalescePlaceholderToolUpdates);
}
if (event.type === "text_delta" && event.stream !== "thought") {
currentOutputChunk.push(event.text);
} else if (event.type === "tool_call" && event.tag !== "tool_call_update") {
// ACP makes tool-call status optional. The normalized event tag is
// the reliable boundary between an initial call and its updates,
// so a statusless initial call must still end the preceding output
// segment while updates must not create extra boundaries.
flushOutputSegment();
}
if (event.type === "status" && event.tag === "usage_update") {
eventBreakdown = event.breakdown ?? eventBreakdown;
eventCostUsd = usdCostAmount(event.cost) ?? eventCostUsd;
}
await emitRuntimeEvent(ctx, event, toolTitles, prepared.coalescePlaceholderToolUpdates);
})();
// A latched loss already asked the agent to cancel (above, in
// `cancelForLoss`); that request settles neither `turn.events` nor
// `turn.result` by itself. Race the event drain against the fail-fast
// deadline so a silent agent cannot hold this wait open.
const eventsEnded = await Promise.race([
drainEvents.then(() => true as const),
lossDeadline.then(() => false as const),
]);
if (!eventsEnded) {
// The deadline won: stop waiting on the agent. `closeStream` ends
// the event drain locally, with no agent cooperation required. Await
// both the close call and the drain it unblocks before this step
// returns, so no late runtime event can still mutate shared state
// (output segments, tool inventory) after finalization reads it.
await turn.closeStream({ reason: "paperclip duplex loss cancel deadline" }).catch(() => {});
await drainEvents.catch(() => {});
flushOutputSegment();
return LOSS_DEADLINE_TERMINAL;
}
flushOutputSegment();
return await turn.result;
// `turn.result` settles only when the agent's provider process
// returns or rejects; a latched loss that armed the deadline after
// the event drain already ended must still bound this wait.
return await Promise.race([turn.result, lossDeadline.then(() => LOSS_DEADLINE_TERMINAL)]);
};
const stepTurnFinalize = async (
input: TurnFinalizeInput<AcpRuntimeTurnResult>,
@ -4673,33 +4765,23 @@ export function createAcpxEngineExecutor(deps: AcpxEngineExecutorOptions = {}) {
const terminal = input.terminal;
const timedOut = input.timedOut;
// Read the sandbox duplex control-channel disposition at the ACP
// terminal-finalization boundary, before the bridge teardown. A control
// channel that died mid-turn latches a failure with a typed loss reason;
// a healthy channel or a normal-teardown loss reports a success. Only a
// nominally completed, non-timed-out terminal is success-eligible, so the
// seam reads the disposition only there. For that success-eligible
// terminal the seam marks the host-observed orderly completion, so a later
// teardown loss cannot flip the run to a failure. The file bridge path
// never sets these methods, so the optional calls no-op there.
// terminal-finalization boundary, before the bridge teardown, on every
// terminal outcome. A control channel that died before this point
// latches a failure with a typed loss reason; a healthy channel or a
// normal-teardown loss reports a success. The read and the mark of the
// host-observed orderly completion happen atomically in one broker
// step, with no `await` between them, so a teardown loss cannot slip
// in between. This stops a later teardown `channel_exit` from latching
// a false loss. The mark no-ops once a loss already latched, so a real
// mid-turn loss still fails the run — including a loss that arrived
// through the in-flight-turn cancel this seam issues, which surfaces
// here as a `cancelled` (not `completed`) terminal, not just through a
// nominally completed terminal. The file bridge path never sets this
// method, so the optional call no-ops there.
let duplexLossReason: DuplexLossReason | null = null;
if (terminal.status === "completed" && !timedOut) {
// Success-eligible terminal. Atomically read the disposition and mark
// the orderly completion in one broker step. No `await` separates the
// read from the mark, so a teardown loss cannot slip in between them. A
// latched loss fails the run closed; a healthy channel marks its
// orderly completion, so a later teardown loss stays a normal teardown.
const disposition = prepared.paperclipBridge?.settleRunDisposition?.() ?? null;
if (disposition?.failed) {
duplexLossReason = disposition.lossReason ?? "other";
}
} else {
// Non-success-eligible terminal (failed, cancelled, or timed out). A
// deliberate host teardown follows, so mark the orderly completion now.
// This stops the teardown `channel_exit` from latching `lossSeq`, from
// emitting a false loss event, and from incrementing the loss counters.
// The mark no-ops once a loss latched, so a real mid-run loss still
// fails the run.
prepared.paperclipBridge?.markOrderlyCompletion?.();
const disposition = prepared.paperclipBridge?.settleRunDisposition?.() ?? null;
if (disposition?.failed) {
duplexLossReason = disposition.lossReason ?? "other";
}
// A terminal that reports "completed" but whose duplex control channel
// died before the completion is not a success. The seam fails it closed.
@ -4778,12 +4860,12 @@ export function createAcpxEngineExecutor(deps: AcpxEngineExecutorOptions = {}) {
signal: timedOut ? "SIGTERM" : null,
timedOut,
errorMessage,
errorCode: terminal.status === "failed"
? "acpx_turn_failed"
: timedOut
? "acpx_timeout"
: channelLost
? DUPLEX_CHANNEL_LOST_ERROR_CODE
errorCode: timedOut
? "acpx_timeout"
: channelLost
? DUPLEX_CHANNEL_LOST_ERROR_CODE
: terminal.status === "failed"
? "acpx_turn_failed"
: null,
sessionId: sessionHandle.backendSessionId ?? sessionHandle.runtimeSessionName,
sessionParams: buildSessionParams({ prepared, handle: sessionHandle }),
@ -4831,16 +4913,6 @@ export function createAcpxEngineExecutor(deps: AcpxEngineExecutorOptions = {}) {
resources: emptyConsumed,
};
}
if (terminal.status === "failed") {
return {
kind: "failed",
cause: {
kind: "turn_failed",
error: terminal.error instanceof Error ? terminal.error : new Error(String(terminal.error)),
},
resources: emptyConsumed,
};
}
if (terminal.status === "cancelled") {
return {
kind: "cancelled",
@ -4848,10 +4920,12 @@ export function createAcpxEngineExecutor(deps: AcpxEngineExecutorOptions = {}) {
resources: emptyConsumed,
};
}
// A completed terminal whose duplex control channel died mid-turn returns
// a failed completion, so the coordinator settles for a failure and the
// reuse decision forbids a save. The message carries only the typed loss
// reason, so no raw provider text rides the cause.
// A duplex control-channel loss outranks a provider-reported failure or
// completion: the loss reason explains why the provider terminal reads
// the way it does, not the other way round. This also covers a
// "completed" terminal whose channel died mid-turn. The message carries
// only the typed loss reason, so no raw provider text rides the cause,
// even when the provider terminal itself reports `failed`.
if (channelLost) {
return {
kind: "failed",
@ -4862,6 +4936,16 @@ export function createAcpxEngineExecutor(deps: AcpxEngineExecutorOptions = {}) {
resources: emptyConsumed,
};
}
if (terminal.status === "failed") {
return {
kind: "failed",
cause: {
kind: "turn_failed",
error: terminal.error instanceof Error ? terminal.error : new Error(String(terminal.error)),
},
resources: emptyConsumed,
};
}
return { kind: "finalized" };
}
const err = input.error;
@ -5195,6 +5279,8 @@ export function createAcpxEngineExecutor(deps: AcpxEngineExecutorOptions = {}) {
} finally {
clearTimeout(stopTimer);
removeStopListener?.();
removeLossListener?.();
clearTimeout(lossDeadlineTimer);
// End the run root span exactly once, on every return and on a throw.
runRootSpan.end(runFailed);
// Release the per-session staging lease as the run's final act, AFTER the

View File

@ -344,6 +344,20 @@ export interface AdapterExecutionTargetPaperclipBridgeHandle {
* bridge path never sets it, so the method is absent there.
*/
markOrderlyCompletion?(): void;
/**
* Register a listener for a newly latched terminal loss. The listener
* fires at most once, and only for a loss that flips the disposition to
* failed never for a clean channel end that orders after a
* host-observed orderly completion. Returns a function that unregisters
* the listener.
*
* The caller uses this to abort an in-flight Agent Client Protocol turn
* the moment the channel dies, instead of waiting for the turn to return
* a terminal result on its own (a dead channel can leave a turn with
* nothing to return). The file bridge path never sets it, so the method
* is absent there.
*/
onLoss?(listener: (reason: DuplexLossReason) => void): () => void;
stop(): Promise<void>;
}
@ -3661,12 +3675,19 @@ interface Http2RunDispositionLatch {
markOrderlyCompletion(): void;
/** Atomically mark the orderly completion and read the disposition. */
settleRunDisposition(): DuplexBrokerRunDisposition;
/**
* Register a listener that fires once, only on the call to `recordLoss`
* that actually latches a new terminal loss. Returns a function that
* unregisters the listener.
*/
onLoss(listener: (reason: DuplexLossReason) => void): () => void;
}
function createHttp2RunDispositionLatch(): Http2RunDispositionLatch {
let lossOrdered = false;
let lossReason: DuplexLossReason | null = null;
let completionOrdered = false;
let lossListener: ((reason: DuplexLossReason) => void) | null = null;
const markOrderlyCompletion = (): void => {
if (completionOrdered || lossOrdered) return;
completionOrdered = true;
@ -3679,6 +3700,7 @@ function createHttp2RunDispositionLatch(): Http2RunDispositionLatch {
if (lossOrdered || completionOrdered) return false;
lossOrdered = true;
lossReason = reason;
lossListener?.(reason);
return true;
},
markOrderlyCompletion,
@ -3686,6 +3708,12 @@ function createHttp2RunDispositionLatch(): Http2RunDispositionLatch {
markOrderlyCompletion();
return { failed: lossOrdered, lossReason };
},
onLoss(listener: (reason: DuplexLossReason) => void): () => void {
lossListener = listener;
return () => {
if (lossListener === listener) lossListener = null;
};
},
};
}
@ -4672,6 +4700,8 @@ export async function startAdapterExecutionTargetPaperclipBridge(input: {
// the mark and a teardown loss cannot slip in between.
settleRunDisposition: (): DuplexBrokerRunDisposition => dispositionLatch.settleRunDisposition(),
markOrderlyCompletion: (): void => dispositionLatch.markOrderlyCompletion(),
onLoss: (listener: (reason: DuplexLossReason) => void): (() => void) =>
dispositionLatch.onLoss(listener),
stop: async () => {
// Close the HTTP/2 server's sessions, then the channel, before
// lease release, so no live provider session remains when the