Files
fusion/plugins/fusion-plugin-acp-runtime/src/provider.ts
gsxdsm cae7847085 fix(FN-8004): retry ACP provider blips in auto-merge instead of parking failed (#2157)
## What happened

FN-8004's implementation work finished and passed review. The auto-merge
then failed with `Grok ACP turn failed: Internal error` — a ~20 second
provider blip — and the task was parked `status: "failed"` with 8 files
of complete, reviewed work stranded on its branch.

The park is the interesting part: `status: "failed"` is precisely what
tells recovery to stop. So a misclassification here isn't a missed
retry, it's **terminal**. Both recovery paths were disabled by the same
wrong verdict:

- `maybeRetryTransientMerge` (inline, 3 retries w/ backoff) — never
fired once (`mergeTransientRetryCount: 0`).
- `recoverTransientMergeFailures` (self-healing sweep, exists exactly to
rescue parked in-review tasks) — skipped it, gated on the same
classifier.

## Three defects fixed

**1. No AI-provider failure class existed.** The AI merge drives a real
LLM turn, but `classifyTransientMergeError` only modeled git/lease/spawn
faults. Adds `ai-provider-turn-failure`.

**2. ACP dropped the error detail.** `promptAcpSession` rethrew the SDK
error unchanged, discarding the JSON-RPC `code`/`data` — the only
evidence the fault was provider-side. ("Internal error" is just the
standard text for `-32603`.) It now preserves them, keeping the original
as `cause`:

```
Internal error (acp rpc code -32603, retryable)
```

Classification anchors on that envelope, **not** on the bare `"Internal
error"` — matching that unanchored would disguise genuine application
defects as retryable blips. Only provider-fault codes (`-32603`,
`-32000`..`-32003`) are retryable; caller-fault codes
(`-32600`..`-32602`) stay permanent, since retrying just repeats the
failing call.

**3. Sweep/inline asymmetry** (found while tracing; latent and
unreported). The inline gate accepted `isTransientError(msg) ||
classify(msg)`, but the sweep consulted **only** the classifier. So
`ECONNRESET` / `socket hang up` during a merge earned inline retries and
then went **invisible to the sweep** once parked — stranded forever. The
classifier now delegates to `isTransientError`, so both gates agree by
construction.

To keep that delegation from importing the detector's
`usage-limit-detector → logger` chain (the chain FN-5627 split the
classifier out to avoid, which would break
`notification-service.test.ts`'s partial `vi.mock`), the pure predicates
moved to the import-free leaf `transient-error-patterns.ts`, re-exported
from `transient-error-detector.ts`. All 13 exports preserved, verified
programmatically.

## Loosened budgets

Per request, so more self-heals. Both apply **only** to errors already
proven transient; the ceiling and
`merger:transient-failure-budget-exhausted` audit path remain.

| Budget | Before | After |
|---|---|---|
| `MAX_AUTO_MERGE_TRANSIENT_RETRIES` | 3 | 5 (backoff
5s/10s/20s/40s/80s) |
| `MAX_TRANSIENT_MERGE_RECOVERIES` | 2 | 5 |

The bump broke two suites that had hardcoded the old `3`. Rather than
swap in another magic number, both now derive the cap from the constant
so future tuning doesn't re-break them.

## Verification

- `pnpm test:gate` green · `pnpm lint` clean · engine + ACP typecheck
clean · `pnpm verify:fast` PASS (5/5)
- ACP plugin 230 tests green · Grok plugin 64 green · engine
transient/merge suites 136 green
- Regression tests assert the **invariant across every surface** (per
*Fix the Invariant, Not the Repro*), not just the reported Grok string:
both ACP runtime prefixes, all retryable/non-retryable rpc codes, both
SDK error shapes, network delegation, class-ordering, and negative cases
proving bare `"Internal error"` and real defects stay permanent.
- A test caught a genuine bug in my own code mid-review (nested-shape
message shadowing), now fixed.
- `notifier.test.ts > "awaiting approval"` fails — **confirmed
pre-existing on clean main**, unrelated.

## Note

FN-8004's own branch (`fusion/fn-8004`) is still unmerged and its work
looks complete. Once this lands, its merge should be retried separately.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 20:14:25 -07:00

660 lines
24 KiB
TypeScript

// ACP connection layer: spawn → ClientSideConnection → initialize handshake.
//
// U2 establishes the transport and completes the `initialize` handshake with
// integer protocol-version negotiation (KTD2) and a readiness timeout. Session
// driving (`session/new`, `session/prompt`, cancel, load) is U3 — this unit only
// exposes the live `conn` on the returned handle so later units can drive it.
//
// Security posture (KTD6): filesystem client capabilities are advertised ONLY
// when the caller's `advertiseFs` toggle is true — never hardcoded. Teardown is
// registry-SIGKILL-authoritative (KTD4a): `dispose()` force-kills the child via
// the process registry; that kill is the no-orphan guarantee, not a graceful
// round-trip.
import { Readable, Writable } from "node:stream";
import type { ChildProcess } from "node:child_process";
import {
ClientSideConnection,
ndJsonStream,
PROTOCOL_VERSION,
type Agent,
type AgentCapabilities,
type Client,
type ContentBlock,
type RequestPermissionResponse,
type StopReason,
} from "@agentclientprotocol/sdk";
import { spawnAgent, captureStderr, forceKill, unregisterProcess } from "./process-manager.js";
import { createEventBridge } from "./event-bridge.js";
import { resolvePermission, type ResolvePermissionOptions } from "./control-handler.js";
import { createFsHandlers } from "./fs-capabilities.js";
import { boundIdentifier } from "./sanitize.js";
import type { AcpCallbacks, AcpMcpServer, PermissionGate } from "./types.js";
/** Options enabling the U7 fs client capabilities on the bridging handler. */
export interface FsHandlerBuildOptions {
/** Confinement root — the session cwd / task worktree. */
cwd: string;
/** Register `readTextFile` (advertised iff true). */
allowRead: boolean;
/** Register `writeTextFile` (default OFF — KTD6; advertised iff true). */
allowWrite: boolean;
}
/** Default bound for the `initialize` handshake. */
export const DEFAULT_INITIALIZE_TIMEOUT_MS = 30_000;
/** Thrown when the agent negotiates an integer protocol version we don't support. */
export class IncompatibleProtocolError extends Error {
readonly code = "incompatible_protocol" as const;
constructor(
readonly agentProtocolVersion: number,
readonly expected: number = PROTOCOL_VERSION,
) {
super(
`ACP agent negotiated incompatible protocol version ${agentProtocolVersion} (client supports ${expected})`,
);
this.name = "IncompatibleProtocolError";
}
}
/** Thrown when the `initialize` handshake does not complete within the bound. */
export class HandshakeTimeoutError extends Error {
readonly code = "handshake_timeout" as const;
constructor(readonly timeoutMs: number) {
super(`ACP initialize handshake timed out after ${timeoutMs}ms`);
this.name = "HandshakeTimeoutError";
}
}
/*
FNXC:GrokAcp 2026-07-12-07:00:
Grok ACP emits vendor extension notifications such as `_x.ai/session_notification`
and `_x.ai/session/update` for hook_execution status (post_tool_use, etc.).
@agentclientprotocol/sdk routes non-spec methods to Client.extNotification /
extMethod; when those are absent the SDK logs Method not found (-32601) for
every hook even though hooks themselves succeeded. Accept extensions as no-ops
so Fusion clients stay forward-compatible without claiming Grok-only features.
Do not forward into sessionUpdate: hook_execution is not a standard
sessionUpdate tag and would fail zSessionNotification validation.
*/
/** No-op ACP extension handlers so vendor notifications do not Method-not-found. */
function acceptAcpExtensions(): Pick<Client, "extMethod" | "extNotification"> {
return {
async extMethod(_method: string, _params: Record<string, unknown>) {
return {};
},
async extNotification(_method: string, _params: Record<string, unknown>) {
// Intentionally empty — informational Grok hooks / x.ai session extensions.
},
};
}
/**
* Minimal default client handler. Later units (U3/U4/U5/U7) supply the real one
* that bridges `session/update` into Fusion callbacks and routes permission
* requests through the action gate. The default cancels every permission request
* (never auto-allows an untrusted agent) and ignores updates.
*/
export function createDefaultClientHandler(): Client {
return {
async sessionUpdate() {
// no-op until the U4 event bridge is wired
},
async requestPermission() {
return { outcome: { outcome: "cancelled" } };
},
...acceptAcpExtensions(),
};
}
/** A bridging client handler plus a drain control for its in-flight permissions. */
export interface BridgingClientHandler {
/** The ACP `Client` impl handed to `ClientSideConnection`. */
handler: Client;
/**
* Resolve every in-flight `requestPermission` with `{ cancelled }` and mark the
* handler cancelled so any request arriving afterward is answered cancelled
* immediately (U5 cancel-drain — KTD4a). Idempotent.
*/
cancelPending(): void;
/**
* Reset the event bridge's PER-TURN state (tool correlation, delta
* accumulators, cumulative-output counter, output-cap latch). MUST be called
* at the start of each prompt turn so a turn that trips the per-turn output cap
* does not silently suppress every subsequent turn (FIX 1).
*/
resetTurn(): void;
}
/**
* The real client handler (U4 + U5): bridges every `session/update` notification
* into the engine callbacks, AND answers `session/request_permission` through the
* per-category action gate (U5 — the SECURITY FLOOR).
*
* Permission requests are routed to `resolvePermission`, which classifies each
* call per-category against the live `gate` and selects `allow_once` only (never
* `*_always`). When no `gate` is supplied the resolver default-denies.
*
* Cancel-drain (KTD4a / Risk: in-flight permission deadlock): every pending
* `requestPermission` promise is tracked; `cancelPending()` resolves them all
* with `{ cancelled }`. A request that arrives AFTER cancel is answered
* `{ cancelled }` immediately so the agent never blocks on teardown.
*/
export function createBridgingClientHandler(
callbacks: AcpCallbacks,
gate?: PermissionGate,
fsOpts?: FsHandlerBuildOptions,
permissionOpts?: ResolvePermissionOptions,
): BridgingClientHandler {
const bridge = createEventBridge(callbacks);
// U7: build the fs handlers, returning only the enabled ones. They are added
// to the handler below ONLY when present, keeping the advertised-capability /
// registered-handler invariant consistent (KTD6).
const fsHandlers = fsOpts
? createFsHandlers({
cwd: fsOpts.cwd,
gate,
allowRead: fsOpts.allowRead,
allowWrite: fsOpts.allowWrite,
allowUnrestricted: permissionOpts?.allowUnrestricted,
})
: {};
const cancelledResponse: RequestPermissionResponse = {
outcome: { outcome: "cancelled" },
};
let cancelled = false;
// Each entry resolves its pending requestPermission with a cancelled outcome.
const pending = new Set<(response: RequestPermissionResponse) => void>();
function cancelPending(): void {
cancelled = true;
for (const resolveCancelled of [...pending]) {
resolveCancelled(cancelledResponse);
}
pending.clear();
}
const handler: Client = {
async sessionUpdate(params) {
bridge.handleSessionUpdate(params.update);
},
async requestPermission(params): Promise<RequestPermissionResponse> {
// A request arriving after cancel is answered cancelled immediately.
if (cancelled) return cancelledResponse;
// Race the real gate resolution against a cancel-drain so an in-flight
// request is answered the moment teardown drains it (never deadlocks).
return await new Promise<RequestPermissionResponse>((resolve) => {
let settled = false;
const finish = (response: RequestPermissionResponse) => {
if (settled) return;
settled = true;
pending.delete(drain);
resolve(response);
};
const drain = (response: RequestPermissionResponse) => finish(response);
pending.add(drain);
resolvePermission(params.toolCall, params.options, gate, permissionOpts).then(
(response) => finish(response),
// resolvePermission never rejects, but stay safe: deny-by-cancel.
() => finish(cancelledResponse),
);
});
},
// FNXC:GrokAcp 2026-07-12-07:00: swallow `_x.ai/*` extension notifications
// (hook_execution, session admin, …) so ACP SDK does not log -32601.
...acceptAcpExtensions(),
};
// Register fs handlers ONLY when enabled, so the advertised capability and the
// present handler stay consistent (KTD6). If a capability is disabled the
// method is absent → an agent calling it gets a JSON-RPC method-not-found
// error (never a silent success).
if (fsHandlers.readTextFile) handler.readTextFile = fsHandlers.readTextFile;
if (fsHandlers.writeTextFile) handler.writeTextFile = fsHandlers.writeTextFile;
return { handler, cancelPending, resetTurn: () => bridge.reset() };
}
export interface AcpConnection {
/** Live ACP connection — later units drive session/new, prompt, cancel, load. */
conn: ClientSideConnection;
child: ChildProcess;
agentCapabilities?: AgentCapabilities;
/** Auth methods the agent advertised; non-empty means auth is required. */
authMethods: Array<{ id: string }>;
/** Current redacted stderr buffer. */
stderr(): string;
/** Force-kill the agent via the registry (KTD4a — SIGKILL is authoritative). */
dispose(): void;
}
export interface ConnectOptions {
binaryPath: string;
args: string[];
cwd: string;
env: NodeJS.ProcessEnv;
clientHandler?: Client;
/** Advertise fs capabilities ONLY where the toggle is true (KTD6). */
advertiseFs: { read: boolean; write: boolean };
initializeTimeoutMs?: number;
/**
* FNXC:GrokAcp 2026-07-11-15:00:
* Optional post-initialize authenticate (xAI Grok docs: initialize → authenticate
* → session/new). Prefer methods listed in preferMethods that the agent
* advertised; when require is true, missing auth fails closed.
* See https://docs.x.ai/build/cli/headless-scripting#acp
*/
authenticate?: {
preferMethods?: string[];
methodId?: string;
meta?: Record<string, unknown>;
require?: boolean;
};
}
function withTimeout<T>(promise: Promise<T>, ms: number, onTimeout: () => Error): Promise<T> {
return new Promise<T>((resolve, reject) => {
const timer = setTimeout(() => reject(onTimeout()), ms);
timer.unref?.();
promise.then(
(value) => {
clearTimeout(timer);
resolve(value);
},
(err) => {
clearTimeout(timer);
reject(err);
},
);
});
}
/**
* Spawn the agent, establish a `ClientSideConnection` over its stdio, and
* complete the `initialize` handshake under a timeout.
*
* Throws `HandshakeTimeoutError` on timeout, `IncompatibleProtocolError` when
* the negotiated integer protocol version mismatches — in both cases the
* subprocess is force-killed before throwing (no orphans, KTD4a). On `initialize`
* the fs capability flags are gated by `advertiseFs` and never hardcoded (KTD6).
*/
export async function connect(opts: ConnectOptions): Promise<AcpConnection> {
const timeoutMs = opts.initializeTimeoutMs ?? DEFAULT_INITIALIZE_TIMEOUT_MS;
const child = spawnAgent({
binaryPath: opts.binaryPath,
args: opts.args,
cwd: opts.cwd,
env: opts.env,
});
const stderr = captureStderr(child);
let disposed = false;
const dispose = () => {
if (disposed) return;
disposed = true;
forceKill(child);
unregisterProcess(child);
};
// If the binary is missing, spawn emits "error" asynchronously. Surface that
// as a rejection of the handshake rather than an unhandled event-loop error.
let spawnError: Error | undefined;
const spawnErrored = new Promise<never>((_resolve, reject) => {
child.once("error", (err: Error) => {
spawnError = err;
reject(err);
});
});
// Avoid an unhandled rejection if the handshake resolves/throws first.
spawnErrored.catch(() => undefined);
// output = the agent's stdin; input = the agent's stdout.
const stream = ndJsonStream(
Writable.toWeb(child.stdin!) as unknown as WritableStream<Uint8Array>,
Readable.toWeb(child.stdout!) as unknown as ReadableStream<Uint8Array>,
);
const handler = opts.clientHandler ?? createDefaultClientHandler();
const conn = new ClientSideConnection((_agent: Agent) => handler, stream);
let initResult: Awaited<ReturnType<ClientSideConnection["initialize"]>>;
try {
initResult = await Promise.race([
withTimeout(
conn.initialize({
protocolVersion: PROTOCOL_VERSION,
clientCapabilities: {
fs: {
readTextFile: opts.advertiseFs.read === true,
writeTextFile: opts.advertiseFs.write === true,
},
},
}),
timeoutMs,
() => new HandshakeTimeoutError(timeoutMs),
),
spawnErrored,
]);
} catch (err) {
dispose();
if (spawnError && err === spawnError) throw spawnError;
throw err;
}
// Compare the negotiated integer protocol version; do NOT assume the agent
// errors first (KTD2).
if (initResult.protocolVersion !== PROTOCOL_VERSION) {
dispose();
throw new IncompatibleProtocolError(initResult.protocolVersion);
}
const authMethods = Array.isArray(initResult.authMethods)
? initResult.authMethods.map((m) => ({ id: m.id }))
: [];
/*
FNXC:GrokAcp 2026-07-11-15:00:
Official Grok ACP scripting requires authenticate after initialize (method
xai.api_key when XAI_API_KEY is set, else cached_token) with
`_meta: { headless: true }` before session/new. Generic ACP agents that
advertise no preferred methods skip this step.
*/
if (opts.authenticate) {
try {
await authenticateAcpConnection(
{ conn, authMethods },
opts.authenticate,
);
} catch (err) {
dispose();
throw err;
}
}
return {
conn,
child,
agentCapabilities: initResult.agentCapabilities,
authMethods,
stderr,
dispose,
};
}
export class AcpAuthRequiredError extends Error {
readonly code = "acp_auth_required" as const;
constructor(readonly availableMethodIds: string[]) {
super(
availableMethodIds.length > 0
? `ACP agent requires authentication but no preferred method matched (available: ${availableMethodIds.join(", ")})`
: "ACP agent requires authentication but advertised no auth methods",
);
this.name = "AcpAuthRequiredError";
}
}
/**
* Call ACP `authenticate` with the first preferred method the agent advertised.
* No-ops when neither methodId nor a preferred method is available and require
* is false.
*/
export async function authenticateAcpConnection(
connection: Pick<AcpConnection, "conn" | "authMethods">,
opts: {
preferMethods?: string[];
methodId?: string;
meta?: Record<string, unknown>;
require?: boolean;
},
): Promise<{ methodId: string } | undefined> {
const available = connection.authMethods.map((m) => m.id);
const availableSet = new Set(available);
let methodId = opts.methodId?.trim();
if (methodId && !availableSet.has(methodId)) {
methodId = undefined;
}
if (!methodId) {
for (const candidate of opts.preferMethods ?? []) {
if (availableSet.has(candidate)) {
methodId = candidate;
break;
}
}
}
if (!methodId) {
if (opts.require) {
throw new AcpAuthRequiredError(available);
}
return undefined;
}
await connection.conn.authenticate({
methodId,
_meta: opts.meta ?? { headless: true },
});
return { methodId };
}
// --- U3: session driving on top of connect() -------------------------------
//
// These helpers wrap the `ClientSideConnection` session methods so the runtime
// adapter drives one shape (open → prompt → cancel/resume) without touching SDK
// types directly. v1 always sends an empty `mcpServers` (KTD5).
function readsLoadSession(connection: AcpConnection): boolean {
// `agentCapabilities` is already typed as `AgentCapabilities | undefined`.
return connection.agentCapabilities?.loadSession === true;
}
export interface NewAcpSessionResult {
sessionId: string;
/** Initial session mode state, when the agent reports one. */
modes?: unknown;
}
/**
* Open a fresh ACP session via `session/new`. Forwards `opts.mcpServers` (U10 —
* Route A): when present and non-empty, the agent can call those Fusion tools and
* each call still routes through the U5 permission floor. Defaults to `[]` so
* Route B read-only ask turns keep their no-tools posture.
*/
export async function newAcpSession(
connection: AcpConnection,
opts: {
cwd: string;
mcpServers?: AcpMcpServer[];
/**
* FNXC:GrokAcp 2026-07-11-14:00:
* Optional ACP `_meta` bag for agent-specific session setup (Grok uses
* `pluginDirs`, `rules`, `systemPromptOverride`). Opaque to the generic
* ACP client — agents interpret their own keys.
*/
meta?: Record<string, unknown>;
},
): Promise<NewAcpSessionResult> {
const res = await connection.conn.newSession({
cwd: opts.cwd,
mcpServers: (opts.mcpServers ?? []) as never,
...(opts.meta && Object.keys(opts.meta).length > 0 ? { _meta: opts.meta } : {}),
});
// `sessionId` is agent-supplied/untrusted (U6/Risk S7): bound its length and
// strip path separators / NUL bytes before it is stored on the session or
// could ever touch a resume-file path.
return { sessionId: boundIdentifier(res.sessionId), modes: res.modes ?? undefined };
}
/*
FNXC:AcpRuntime 2026-07-15-18:20:
An ACP turn that fails server-side arrives as a JSON-RPC error object whose `message` is the
bare protocol-standard text — `-32603` renders as literally "Internal error". Rethrowing the
SDK error as-is discards `code`/`data`, the only fields identifying the fault as provider-side
and retryable. Downstream transient classification then sees an unclassifiable string and parks
the task permanently (FN-8004: a 20s Grok blip terminally failed an auto-merge).
`describeAcpTurnError` re-shapes the error into a stable, greppable diagnostic carrying the
numeric code, so classifiers anchor on an ACP-specific signature instead of pattern-matching the
dangerously generic phrase "Internal error" (which could appear in unrelated application output).
Format is load-bearing — `ACP_TRANSIENT_ERROR_PATTERNS` in the engine's transient-error-detector
matches it. Keep the two in sync.
*/
/** JSON-RPC error codes that indicate a provider-side, retryable fault rather than a bad request. */
const RETRYABLE_JSONRPC_CODES = new Set([
-32603, // Internal error — the agent blew up server-side.
-32000, // Server error (generic, reserved implementation-defined range).
-32001,
-32002,
-32003,
]);
/** Structured view of a thrown ACP/JSON-RPC error; all fields best-effort. */
export interface AcpTurnErrorDetail {
message: string;
code?: number;
data?: unknown;
retryable: boolean;
}
/**
* Extract JSON-RPC `code`/`data` from a thrown ACP error.
*
* The SDK surfaces request errors in more than one shape depending on build: a flat
* `{ code, message, data }` and a nested `{ error: { code, message, data } }`. Read both.
*/
export function inspectAcpTurnError(error: unknown): AcpTurnErrorDetail {
type RpcShape = { code?: unknown; data?: unknown; message?: unknown };
const raw = error as (RpcShape & { error?: RpcShape }) | null;
const nested = raw && typeof raw === "object" ? raw.error : undefined;
/*
Read message/code/data from ONE source. Reading `code` from the nested payload while taking
`message` from the outer Error yields "request failed (acp rpc code -32603)" — the outer
wrapper text, not the provider's actual reason. The flat shape wins when it carries a numeric
code; otherwise the nested envelope is authoritative.
*/
const source: RpcShape | undefined =
typeof raw?.code === "number" ? raw : typeof nested?.code === "number" ? nested : undefined;
const code = typeof source?.code === "number" ? source.code : undefined;
const data = source?.data;
const message =
(typeof source?.message === "string" && source.message)
|| (typeof raw?.message === "string" && raw.message)
|| (error instanceof Error ? error.message : String(error ?? "unknown error"));
return { message, code, data, retryable: code !== undefined && RETRYABLE_JSONRPC_CODES.has(code) };
}
/**
* Render a thrown ACP turn error as a single-line diagnostic that preserves the JSON-RPC code
* and any `data` payload, so transient classification has something to anchor on.
*
* Shape: `Internal error (acp rpc code -32603, retryable) [data: {...}]`
*/
export function describeAcpTurnError(error: unknown): string {
const { message, code, data, retryable } = inspectAcpTurnError(error);
if (code === undefined) return message;
const suffix = retryable ? ", retryable" : "";
let out = `${message} (acp rpc code ${code}${suffix})`;
if (data !== undefined) {
let rendered: string;
try {
rendered = typeof data === "string" ? data : JSON.stringify(data);
} catch {
rendered = String(data);
}
if (rendered && rendered !== "{}") out += ` [data: ${rendered.slice(0, 500)}]`;
}
return out;
}
/**
* Send a prompt turn via `session/prompt` and return the terminal `stopReason`.
*
* The SDK prompt promise resolves only AFTER every `session/update` for the turn
* has been delivered to the client handler — so resolving here is the correct
* "turn complete" signal (no extra draining required).
*
* FNXC:AcpRuntime 2026-07-15-18:20: rethrows with `describeAcpTurnError` so the JSON-RPC code
* survives to the engine's transient classifier (FN-8004). `cause` retains the original error.
*/
export async function promptAcpSession(
connection: AcpConnection,
sessionId: string,
blocks: ContentBlock[],
): Promise<StopReason> {
try {
const res = await connection.conn.prompt({ sessionId, prompt: blocks });
return res.stopReason;
} catch (error) {
throw new Error(describeAcpTurnError(error), { cause: error });
}
}
/**
* Best-effort cancel of the active turn via the `session/cancel` notification.
*
* This is fire-and-forget (no ack in the protocol). Errors are swallowed — it
* runs during teardown where the registry SIGKILL is the authoritative guarantee
* (KTD4a).
*/
/** Upper bound on how long `cancelAcpSession` waits on the cancel write (FIX 7). */
const CANCEL_TIMEOUT_MS = 2_000;
export async function cancelAcpSession(
connection: AcpConnection,
sessionId: string,
): Promise<void> {
// `conn.cancel` writes to the agent's stdin pipe; a dead or full pipe can
// back-pressure and stall teardown (the adapter awaits this BEFORE the
// authoritative registry SIGKILL). Bound it so the kill still runs promptly
// (FIX 7). Errors are swallowed — this is already best-effort.
try {
await Promise.race([
connection.conn.cancel({ sessionId }),
new Promise<void>((resolve) => {
const timer = setTimeout(resolve, CANCEL_TIMEOUT_MS);
timer.unref?.();
}),
]);
} catch {
// fire-and-forget; teardown's SIGKILL is authoritative
}
}
/**
* Resume a session. Prefers `session/load` (history replay) when the agent
* advertised the `loadSession` capability; otherwise falls back to opening a
* fresh `session/new`. There is no separate `resume` method in this SDK build —
* `loadSession` IS the resume path.
*
* NOTE (v1): engine-driven resume wiring is intentionally deferred — the
* runtime adapter always opens a fresh session via `newAcpSession`. This helper
* exists (and is unit-tested for the id-sanitization invariant) so resume can be
* wired in by passing a `sessionId` through `AgentRuntimeOptions` later without
* building new resume machinery.
*/
export async function loadAcpSession(
connection: AcpConnection,
opts: { sessionId: string; cwd: string },
): Promise<NewAcpSessionResult> {
if (readsLoadSession(connection)) {
// Bound the (agent-originated) resume id before it is used as a protocol /
// potential path component (U6/Risk S7).
const safeId = boundIdentifier(opts.sessionId);
const res = await connection.conn.loadSession({
sessionId: safeId,
cwd: opts.cwd,
mcpServers: [],
});
return { sessionId: safeId, modes: res.modes ?? undefined };
}
return newAcpSession(connection, { cwd: opts.cwd });
}