Preserve Claude ACP quota classification and reset time (#13651)

Typed Claude ACP quota failures lost their recovery classification and reset
time when the runtime reduced provider metadata to a generic category error.
Inspect terminal metadata in memory and retain only safe recovery labels and
a parsed reset timestamp. Preserve the existing handling of other limits.

Verified real child processes on both pinned ACPX runtimes, adapter and
server recovery regressions, all PR CI gates, and Greptile 5/5. Also isolate
a pre-existing chat regression from unrelated fixtures’ retry work.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Devin Foley
2026-09-18 19:42:28 -07:00
committed by GitHub
co-authored by Paperclip
parent 685d4faba3
commit d54b750111
10 changed files with 392 additions and 47 deletions
+10
View File
@@ -5,6 +5,16 @@ summary: Claude Code local adapter setup and configuration
The `claude_local` adapter runs Anthropic's Claude Code CLI locally. It supports session persistence, skills injection, and structured output parsing.
## Quota waits
Claude ACP runs that end with a typed provider-quota error retain the quota
classification and any parsed reset time. Recovery waits until that time, or
uses its existing one-hour quota backoff when no reset time is available.
The adapter inspects the terminal provider message in memory; the run result
and run log retain only the generic failure message, recovery labels, and reset
timestamp. Context, turn, rate, and configured budget limits are not treated as
subscription quota exhaustion merely because ACP labels them `limit`.
## Prerequisites
- Claude Code CLI installed (`claude` command available)
@@ -355,9 +355,25 @@ export interface AcpxRemoteManagedHomeResult {
disposeStaged?: () => Promise<void>;
}
export interface AcpxTerminalSessionFailure {
category: string;
title?: string;
details?: string;
}
export type AcpxTerminalFailureClassification = Pick<
AdapterExecutionResult,
"errorCode" | "errorFamily" | "retryNotBefore"
>;
export interface AcpxEngineExecutorOptions {
createRuntime?: AcpxRuntimeFactory;
now?: () => number;
/** Inspect terminal provider text in memory; return only recovery labels and a timestamp. */
classifyTerminalSessionFailure?: (
failure: AcpxTerminalSessionFailure,
now: Date,
) => AcpxTerminalFailureClassification | null;
/**
* The bound on how long the fail-fast seam waits for a cooperative
* `turn.cancel()` after a latched terminal sandbox duplex-channel loss,
@@ -4059,6 +4075,7 @@ export function createAcpxEngineExecutor(deps: AcpxEngineExecutorOptions = {}) {
// `endSession` step can cancel a running turn before it closes the runtime
// (the cancel-before-close order). The turn wrapper assigns it in `turnStart`.
let activeTurn: AcpRuntimeTurn | null = null;
let terminalFailureClassification: AcpxTerminalFailureClassification | null = null;
// How the settlement `endSession` step must release the runtime for the path
// this run took. Each exit path that acquired the runtime records it before it
// returns; a build or create-runtime failure never registers the runtime, so
@@ -4739,6 +4756,15 @@ export function createAcpxEngineExecutor(deps: AcpxEngineExecutorOptions = {}) {
requestId: ctx.runId,
timeoutMs: startTimeoutMs,
signal,
// The callback belongs to this turn, including when a runtime is reused.
// Raw provider text must never enter the result or the run log.
...(deps.classifyTerminalSessionFailure
? {
onTerminalSessionFailure: (failure: AcpxTerminalSessionFailure) => {
terminalFailureClassification = deps.classifyTerminalSessionFailure!(failure, new Date(now()));
},
}
: {}),
});
activeTurn = turn;
// A latched sandbox duplex-channel loss otherwise has no way to reach
@@ -4957,6 +4983,9 @@ export function createAcpxEngineExecutor(deps: AcpxEngineExecutorOptions = {}) {
? channelLostMessage
: resultErrorMessage(terminal);
const terminalStopReason = terminal.status === "failed" ? terminal.error.message : terminal.stopReason;
const classifiedFailure = !timedOut && !channelLost && terminal.status === "failed"
? terminalFailureClassification
: null;
await emitAcpxLog(ctx, {
type: turnSucceeded ? "acpx.result" : "acpx.error",
summary: channelLost ? "duplex_channel_lost" : terminal.status,
@@ -4977,8 +5006,10 @@ export function createAcpxEngineExecutor(deps: AcpxEngineExecutorOptions = {}) {
: channelLost
? DUPLEX_CHANNEL_LOST_ERROR_CODE
: terminal.status === "failed"
? "acpx_turn_failed"
? classifiedFailure?.errorCode ?? "acpx_turn_failed"
: null,
...(classifiedFailure?.errorFamily ? { errorFamily: classifiedFailure.errorFamily } : {}),
...(classifiedFailure?.retryNotBefore ? { retryNotBefore: classifiedFailure.retryNotBefore } : {}),
sessionId: sessionHandle.backendSessionId ?? sessionHandle.runtimeSessionName,
sessionParams: buildSessionParams({ prepared, handle: sessionHandle }),
sessionDisplayId: sessionHandle.agentSessionId ?? sessionHandle.backendSessionId ?? sessionHandle.runtimeSessionName,
@@ -4989,6 +5020,15 @@ export function createAcpxEngineExecutor(deps: AcpxEngineExecutorOptions = {}) {
costUsd: turnUsage.costUsd,
resultJson: {
status: channelLost ? "failed" : terminal.status,
...(classifiedFailure?.errorFamily ? { errorFamily: classifiedFailure.errorFamily } : {}),
...(classifiedFailure?.retryNotBefore
? {
retryNotBefore: classifiedFailure.retryNotBefore,
...(classifiedFailure.errorFamily === "provider_quota"
? { providerQuotaRetryNotBefore: classifiedFailure.retryNotBefore }
: {}),
}
: {}),
stopReason: terminalStopReason,
permissionMode: prepared.permissionMode,
mode: prepared.mode,
@@ -3,7 +3,7 @@ import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { afterEach, expect, it } from "vitest";
import { afterEach, expect, it, vi } from "vitest";
import { createAcpxEngineExecutor } from "./execute.js";
const repoRoot = fileURLToPath(new URL("../../../..", import.meta.url));
@@ -166,7 +166,8 @@ it("keeps a typed retry warning nonfatal when the turn produces an answer", asyn
const answer = "Recovered after the transient connection warning.";
const warningCanary = "typed-warning-is-not-terminal";
const logs: string[] = [];
const execute = createAcpxEngineExecutor();
const classifyTerminalSessionFailure = vi.fn(() => null);
const execute = createAcpxEngineExecutor({ classifyTerminalSessionFailure });
const result = await execute({
runId: "typed-warning-smoke",
@@ -190,6 +191,7 @@ it("keeps a typed retry warning nonfatal when the turn produces an answer", asyn
expect(result.exitCode).toBe(0);
expect(result.summary).toBe(answer);
expect(classifyTerminalSessionFailure).not.toHaveBeenCalled();
expect(JSON.stringify(result)).not.toContain(warningCanary);
expect(logs.join("\n")).not.toContain(warningCanary);
});
@@ -0,0 +1,99 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { createRequire } from "node:module";
import { fileURLToPath } from "node:url";
import { afterEach, expect, it } from "vitest";
import { createClaudeAcpExecutor } from "./acp.js";
import type { AcpxEngineExecutorOptions } from "@paperclipai/adapter-utils/acpx-engine/execute";
const repoRoot = fileURLToPath(new URL("../../../../..", import.meta.url));
const fixture = path.join(repoRoot, "scripts/mcp-fixtures/servers/acp-echo-agent.mjs");
const roots: string[] = [];
const now = new Date("2026-07-15T20:00:00.000Z");
// Exercise both pinned dependency patches through the same real ACP child.
const runnerRequire = createRequire(path.join(repoRoot, "packages/paperclip-runner/package.json"));
const runnerAcpx = await import(runnerRequire.resolve("acpx/runtime"));
afterEach(async () => {
await Promise.all(roots.splice(0).map((root) => fs.rm(root, { recursive: true, force: true })));
});
async function executeFailure(
title: string,
category = "limit",
mode = "oneshot",
createRuntime?: AcpxEngineExecutorOptions["createRuntime"],
) {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-claude-acp-quota-"));
roots.push(root);
const logs: string[] = [];
const execute = createClaudeAcpExecutor({ now: () => now.getTime(), createRuntime });
const result = await execute({
runId: "typed-quota",
agent: { id: "quota-agent", companyId: "quota-company" },
runtime: {},
config: {
agentCommand: `${JSON.stringify(process.execPath.replaceAll("\\", "/"))} ${JSON.stringify(fixture.replaceAll("\\", "/"))}`,
mode,
warmHandleIdleMs: 0,
cwd: repoRoot,
stateDir: path.join(root, "state"),
env: {
PAPERCLIP_ACPX_TYPED_FAILURE_CANARY: title,
PAPERCLIP_ACPX_TYPED_FAILURE_CATEGORY: category,
},
},
context: {},
onLog: async (_stream: string, text: string) => logs.push(text),
onMeta: async () => {},
} as never);
return { result, logs: logs.join("\n") };
}
it.each([
["0.12.0", "oneshot"],
["0.12.0", "persistent"],
["0.13.1", "oneshot"],
["0.13.1", "persistent"],
])("waits for a typed Claude quota reset with ACPX %s in %s mode without exposing provider text", async (version, mode) => {
const title = "You've hit your session limit · resets 4:30pm (America/Chicago)";
const { result, logs } = await executeFailure(
title, "limit", mode, version === "0.13.1" ? runnerAcpx.createAcpRuntime : undefined,
);
expect(result).toMatchObject({
exitCode: 1,
errorCode: "provider_quota",
errorFamily: "provider_quota",
retryNotBefore: "2026-07-15T21:30:00.000Z",
resultJson: {
errorFamily: "provider_quota",
retryNotBefore: "2026-07-15T21:30:00.000Z",
providerQuotaRetryNotBefore: "2026-07-15T21:30:00.000Z",
},
});
expect(JSON.stringify(result)).not.toContain(title);
expect(logs).not.toContain(title);
});
it("classifies quota without a reset time for the existing recovery backoff", async () => {
const { result } = await executeFailure("You've hit your weekly limit");
expect(result).toMatchObject({ errorCode: "provider_quota", errorFamily: "provider_quota" });
expect(result.retryNotBefore).toBeUndefined();
});
it.each([
["Context window limit exceeded", "limit"],
["Maximum number of turns reached", "limit"],
["Configured budget limit reached", "limit"],
["Rate limit exceeded; retry later", "limit"],
["You've hit your session limit", "request"],
["The worker connection closed", "connection"],
])("keeps a non-quota typed failure out of quota recovery: %s", async (title, category) => {
const { result, logs } = await executeFailure(title, category);
expect(result).toMatchObject({ exitCode: 1, errorCode: "acpx_turn_failed" });
expect(result.errorFamily).not.toBe("provider_quota");
expect(result.retryNotBefore).toBeUndefined();
expect(JSON.stringify(result)).not.toContain(title);
expect(logs).not.toContain(title);
});
@@ -31,6 +31,8 @@ import type {
AcpxEngineExecutorOptions,
AcpxRemoteManagedHomeContext,
AcpxRemoteManagedHomeResult,
AcpxTerminalSessionFailure,
AcpxTerminalFailureClassification,
} from "@paperclipai/adapter-utils/acpx-engine/execute";
import {
asBoolean,
@@ -51,7 +53,7 @@ import {
} from "./probe-diagnostics.js";
import { createWorkspaceRestoreTeardown } from "@paperclipai/adapter-utils/workspace-restore-teardown";
import { buildLocalAdapterTestProbeEnv } from "./probe-env.js";
import { detectClaudeLoginRequired, parseClaudeStreamJson } from "./parse.js";
import { detectClaudeLoginRequired, extractClaudeRetryNotBefore, isClaudeProviderQuotaError, parseClaudeStreamJson } from "./parse.js";
import { buildClaudeProbePermissionArgs } from "./permissions.js";
import { ADAPTER_AUTH_MISSING_CHECK_CODE } from "./auth-check.js";
import { resolveClaudeModel, SANDBOX_INSTALL_COMMAND } from "../index.js";
@@ -314,10 +316,28 @@ async function prepareClaudeRemoteManagedHome(
return { stagedRuntime, teardown: registerWorkspaceSyncBack(stagedRuntime) };
}
export function classifyClaudeTerminalSessionFailure(
failure: AcpxTerminalSessionFailure,
now: Date,
): AcpxTerminalFailureClassification | null {
// `limit` also includes context, turn, rate and configured budget limits.
// Only the provider's quota wording qualifies for a quota wait.
if (failure.category !== "limit") return null;
const surface = { errorMessage: [failure.title, failure.details].filter(Boolean).join("\n") };
if (!isClaudeProviderQuotaError(surface)) return null;
const retryNotBefore = extractClaudeRetryNotBefore(surface, now)?.toISOString();
return {
errorCode: "provider_quota",
errorFamily: "provider_quota",
...(retryNotBefore ? { retryNotBefore } : {}),
};
}
function withClaudeAcpDefaults(options: ClaudeAcpExecutorOptions): AcpxEngineExecutorOptions {
return {
resolveBillingIdentity: resolveClaudeAcpBillingIdentity,
prepareRemoteManagedHome: prepareClaudeRemoteManagedHome,
classifyTerminalSessionFailure: classifyClaudeTerminalSessionFailure,
...options,
adapterType: "claude_local",
moduleDir,
+64 -6
View File
@@ -1,5 +1,5 @@
diff --git a/dist/live-checkpoint-ClPCSdrW.js b/dist/live-checkpoint-ClPCSdrW.js
index 243c9d13bcba520923b63adfddad75cf2d94362d..643315c1e9b529768695c863eae7a29e9f5a3dac 100644
index 243c9d13bcba520923b63adfddad75cf2d94362d..4c39306189bcb9b53f95ff7ce0be55e160b2d1a3 100644
--- a/dist/live-checkpoint-ClPCSdrW.js
+++ b/dist/live-checkpoint-ClPCSdrW.js
@@ -1532,7 +1532,7 @@ const ZED_TAG_KEYS = /* @__PURE__ */ new Set([
@@ -124,21 +124,39 @@ index 243c9d13bcba520923b63adfddad75cf2d94362d..643315c1e9b529768695c863eae7a29e
async function runPromptTurn(params) {
try {
const promptPromise = params.client.prompt(params.sessionId, params.prompt);
@@ -6079,6 +6120,9 @@ async function runPromptTurn(params) {
@@ -6079,6 +6120,19 @@ async function runPromptTurn(params) {
idleMs: SESSION_REPLY_IDLE_MS,
timeoutMs: SESSION_REPLY_DRAIN_TIMEOUT_MS
}).catch(() => {});
+ const terminalFailureCategory = typedTerminalSessionFailureCategory(response);
+ if (terminalFailureCategory !== null)
+ if (terminalFailureCategory !== null) {
+ // Inspect provider text only in memory, before emitting the safe category.
+ const failure = response._meta.jetbrains.air.sessionFailure;
+ try {
+ params.onTerminalSessionFailure?.({
+ category: terminalFailureCategory,
+ ...(typeof failure.title === "string" ? { title: failure.title.slice(0, 4096) } : {}),
+ ...(typeof failure.details === "string" ? { details: failure.details.slice(0, 4096) } : {})
+ });
+ } catch {}
+ throw new Error(`ACP agent reported a terminal ${terminalFailureCategory} failure.`);
+ }
recordPromptResponseUsage(params.conversation, response.usage, params.promptMessageId);
return {
stopReason: response.stopReason,
diff --git a/dist/runtime.d.ts b/dist/runtime.d.ts
index ccdbe5b032521518022223733049b8b38793473b..a04948e2b1ab8e169778e0d839ab5bf680573266 100644
index ccdbe5b032521518022223733049b8b38793473b..416bdaae1450c6e85b34afd154349770f9f7b0a9 100644
--- a/dist/runtime.d.ts
+++ b/dist/runtime.d.ts
@@ -266,6 +266,10 @@ type AcpRuntimeOptions = {
@@ -45,6 +45,7 @@ type AcpRuntimeTurnAttachment = {
data: string;
};
type AcpRuntimeTurnInput = {
+ onTerminalSessionFailure?: (failure: { category: string; title?: string; details?: string }) => void;
handle: AcpRuntimeHandle;
text: string;
attachments?: AcpRuntimeTurnAttachment[];
@@ -266,6 +267,10 @@ type AcpRuntimeOptions = {
timeoutMs?: number;
probeAgent?: string;
verbose?: boolean;
@@ -149,8 +167,24 @@ index ccdbe5b032521518022223733049b8b38793473b..a04948e2b1ab8e169778e0d839ab5bf6
onPermissionRequest?: (req: AcpPermissionRequest, ctx: {
signal: AbortSignal;
}) => Promise<AcpPermissionDecision | undefined>;
@@ -302,6 +307,7 @@ declare class AcpRuntimeManager {
private createAndSaveRuntimeRecord;
private keepPersistentClient;
startTurn(input: {
+ onTerminalSessionFailure?: (failure: { category: string; title?: string; details?: string }) => void;
handle: AcpRuntimeHandle;
text: string;
attachments?: AcpRuntimeTurnAttachment[];
@@ -334,6 +340,7 @@ declare class AcpRuntimeManager {
private finalizeRuntimeTurn;
private finalizeRuntimeTurnRecord;
runTurn(input: {
+ onTerminalSessionFailure?: (failure: { category: string; title?: string; details?: string }) => void;
handle: AcpRuntimeHandle;
text: string;
attachments?: AcpRuntimeTurnAttachment[];
diff --git a/dist/runtime.js b/dist/runtime.js
index 6c9cc999e50a11c399c68b3a0f1b7af4bc2317c0..7ec28ecf778623c6e63a2149a599172c184c49bd 100644
index 6c9cc999e50a11c399c68b3a0f1b7af4bc2317c0..e06bf4fdee534f3ef56be0387a2f5f8626e697ad 100644
--- a/dist/runtime.js
+++ b/dist/runtime.js
@@ -744,7 +744,8 @@ var AcpRuntimeManager = class {
@@ -163,6 +197,30 @@ index 6c9cc999e50a11c399c68b3a0f1b7af4bc2317c0..7ec28ecf778623c6e63a2149a599172c
}
async readPendingPersistentClient(record, options) {
const pendingClient = this.pendingPersistentClients.get(record.acpxRecordId);
@@ -982,6 +983,7 @@ var AcpRuntimeManager = class {
client: turn.client,
sessionId,
prompt: task.promptInput,
+ onTerminalSessionFailure: task.input.onTerminalSessionFailure,
timeoutMs: task.input.timeoutMs ?? this.options.timeoutMs,
conversation: turn.conversation,
promptMessageId: turn.promptMessageId
@@ -1586,6 +1588,7 @@ var AcpxRuntime = class {
const turnPromise = this.getManager().then((manager) => manager.startTurn({
handle,
text: input.text,
+ onTerminalSessionFailure: input.onTerminalSessionFailure,
attachments: input.attachments,
mode: input.mode,
sessionMode: state.mode,
@@ -1614,6 +1617,7 @@ var AcpxRuntime = class {
yield* (await this.getManager()).runTurn({
handle,
text: input.text,
+ onTerminalSessionFailure: input.onTerminalSessionFailure,
attachments: input.attachments,
mode: input.mode,
sessionMode: state.mode,
diff --git a/dist/session-options-jkYbBxGE.d.ts b/dist/session-options-jkYbBxGE.d.ts
index 9d37f377fb6a0828e0d2bc5a48754f3aa71509a4..22dda59e0b7616a20ad45218913e5d8f884bae49 100644
--- a/dist/session-options-jkYbBxGE.d.ts
+90 -32
View File
@@ -1,5 +1,5 @@
diff --git a/dist/client-CxNllqui.d.ts b/dist/client-CxNllqui.d.ts
index 5e2113a..b7b5151 100644
index 5e2113ac0a1a92c99322cf01e5c106760a0b0dbf..b7b5151f3da45b5e0d12aea55e9f3b071ab54a8d 100644
--- a/dist/client-CxNllqui.d.ts
+++ b/dist/client-CxNllqui.d.ts
@@ -135,6 +135,7 @@ declare class AcpClient {
@@ -11,7 +11,7 @@ index 5e2113a..b7b5151 100644
private setSessionModelThroughConfig;
private setSessionModelThroughLegacyMethod;
diff --git a/dist/live-checkpoint-BSIrfgVo.js b/dist/live-checkpoint-BSIrfgVo.js
index d454fd7c5bf742b469be75eb8c3988b694a0ffaa..17f0739318e61744af3eaa760cfbdc84dc5d3c1b 100644
index d454fd7c5bf742b469be75eb8c3988b694a0ffaa..e930a933d259e79a6b03b1148aa58d686b781129 100644
--- a/dist/live-checkpoint-BSIrfgVo.js
+++ b/dist/live-checkpoint-BSIrfgVo.js
@@ -1068,6 +1068,7 @@ function serializeSessionRecordForDisk(record) {
@@ -39,7 +39,7 @@ index d454fd7c5bf742b469be75eb8c3988b694a0ffaa..17f0739318e61744af3eaa760cfbdc84
const OPAQUE_VALUE_PATHS = /* @__PURE__ */ new Set([
"agent_capabilities",
"messages.Agent.content.ToolUse.input",
@@ -3135,8 +3135,18 @@ function promotePrefixedAuthEnvironment(env) {
@@ -3135,8 +3137,18 @@ function promotePrefixedAuthEnvironment(env) {
}
return protectedKeys;
}
@@ -60,7 +60,7 @@ index d454fd7c5bf742b469be75eb8c3988b694a0ffaa..17f0739318e61744af3eaa760cfbdc84
const protectedAuthEnvKeys = promotePrefixedAuthEnvironment(env);
if (authCredentials) for (const [methodId, credential] of Object.entries(authCredentials)) {
addAuthCredentialEnvKeys(protectedAuthEnvKeys, methodId, credential);
@@ -3178,10 +3188,10 @@ function resolveConfiguredAuthCredential(methodId, authCredentials) {
@@ -3178,10 +3190,10 @@ function resolveConfiguredAuthCredential(methodId, authCredentials) {
const configCredentials = authCredentials ?? {};
return configCredentials[methodId] ?? configCredentials[toEnvToken(methodId)];
}
@@ -73,7 +73,7 @@ index d454fd7c5bf742b469be75eb8c3988b694a0ffaa..17f0739318e61744af3eaa760cfbdc84
stdio: [
"pipe",
"pipe",
@@ -3961,10 +3971,13 @@ function resolveClientCapabilities(params) {
@@ -3961,10 +3973,13 @@ function resolveClientCapabilities(params) {
...params.elicitationModes.includes("url") ? { url: {} } : {}
} } : {}
};
@@ -89,7 +89,7 @@ index d454fd7c5bf742b469be75eb8c3988b694a0ffaa..17f0739318e61744af3eaa760cfbdc84
};
}
function hasResponseField(response, field) {
@@ -4216,9 +4229,21 @@ var AcpClient = class {
@@ -4216,9 +4231,21 @@ var AcpClient = class {
this.lastAgentExit = void 0;
this.lastKnownPid = child.pid ?? void 0;
this.attachAgentLifecycleObservers(child);
@@ -111,7 +111,7 @@ index d454fd7c5bf742b469be75eb8c3988b694a0ffaa..17f0739318e61744af3eaa760cfbdc84
if (!this.options.verbose) return;
process.stderr.write(chunk);
});
@@ -4253,7 +4278,12 @@ var AcpClient = class {
@@ -4253,7 +4280,12 @@ var AcpClient = class {
geminiAcp: isGeminiAcpCommand(spawnCommand, args),
copilotAcp: isCopilotAcpCommand(spawnCommand, args),
claudeAcp: isClaudeAcpCommand(spawnCommand, args),
@@ -125,7 +125,7 @@ index d454fd7c5bf742b469be75eb8c3988b694a0ffaa..17f0739318e61744af3eaa760cfbdc84
};
}
logAgentLaunch(plan) {
@@ -4279,10 +4309,17 @@ var AcpClient = class {
@@ -4279,10 +4311,17 @@ var AcpClient = class {
}
async spawnAgentProcess(plan) {
const spawnCommand = buildAgentSpawnCommand(plan.spawnCommand, plan.args, process.platform, plan.spawnOptions.env);
@@ -145,7 +145,7 @@ index d454fd7c5bf742b469be75eb8c3988b694a0ffaa..17f0739318e61744af3eaa760cfbdc84
try {
await waitForSpawn$1(spawnedChild);
} catch (error) {
@@ -5028,6 +5065,12 @@ var AcpClient = class {
@@ -5028,6 +5067,12 @@ var AcpClient = class {
attachAgentLifecycleObservers(child) {
child.once("exit", (exitCode, signal) => {
this.recordAgentExit("process_exit", exitCode, signal);
@@ -158,7 +158,7 @@ index d454fd7c5bf742b469be75eb8c3988b694a0ffaa..17f0739318e61744af3eaa760cfbdc84
});
child.once("close", (exitCode, signal) => {
this.recordAgentExit("process_close", exitCode, signal);
@@ -6435,6 +6478,27 @@ async function withConnectedSession(options) {
@@ -6435,6 +6480,27 @@ async function withConnectedSession(options) {
//#region src/runtime/engine/prompt-turn.ts
const SESSION_REPLY_IDLE_MS = 1e3;
const SESSION_REPLY_DRAIN_TIMEOUT_MS = 5e3;
@@ -186,17 +186,27 @@ index d454fd7c5bf742b469be75eb8c3988b694a0ffaa..17f0739318e61744af3eaa760cfbdc84
async function runPromptTurn(params) {
try {
const promptPromise = params.client.prompt(params.sessionId, params.prompt, params.onPromptRequestStarted, params.onElicitation);
@@ -6444,6 +6508,9 @@ async function runPromptTurn(params) {
@@ -6444,6 +6510,19 @@ async function runPromptTurn(params) {
idleMs: SESSION_REPLY_IDLE_MS,
timeoutMs: SESSION_REPLY_DRAIN_TIMEOUT_MS
}).catch(() => {});
+ const terminalFailureCategory = typedTerminalSessionFailureCategory(response);
+ if (terminalFailureCategory !== null)
+ if (terminalFailureCategory !== null) {
+ // Inspect provider text only in memory, before emitting the safe category.
+ const failure = response._meta.jetbrains.air.sessionFailure;
+ try {
+ params.onTerminalSessionFailure?.({
+ category: terminalFailureCategory,
+ ...(typeof failure.title === "string" ? { title: failure.title.slice(0, 4096) } : {}),
+ ...(typeof failure.details === "string" ? { details: failure.details.slice(0, 4096) } : {})
+ });
+ } catch {}
+ throw new Error(`ACP agent reported a terminal ${terminalFailureCategory} failure.`);
+ }
recordPromptResponseUsage(params.conversation, response.usage, params.promptMessageId);
return {
stopReason: response.stopReason,
@@ -6518,4 +6547,4 @@ var LiveSessionCheckpoint = class {
@@ -6518,4 +6597,4 @@ var LiveSessionCheckpoint = class {
//#endregion
export { writeSessionRecord as $, PERMISSION_POLICY_ACTIONS as $t, REQUESTED_MODEL_UNSUPPORTED_ERROR_CODE as A, TimeoutError as At, getAcpxVersion as B, formatErrorMessage as Bt, mergeSessionOptions as C, PromptInputValidationError as Ct, applyLifecycleSnapshotToRecord as D, promptToDisplayText as Dt, applyConversation as E, parsePromptSource as Et, modelStateFromConfigOptions as F, normalizeAgentName$1 as Ft, findSession as G, toAcpErrorPayload as Gt, DEFAULT_HISTORY_LIMIT as H, normalizeOutputError as Ht, normalizeAgentCommandInput as I, resolveAgentArgv as It, listSessions as J, NON_INTERACTIVE_PERMISSION_POLICIES as Jt, findSessionByDirectoryWalk as K, AUTH_POLICIES as Kt, renderArgvIdentity as L, resolveAgentCommand as Lt, RequestedModelUnsupportedError as M, withTimeout as Mt, assertRequestedModelSupported as N, DEFAULT_AGENT_NAME as Nt, reconcileAgentSessionId as O, textPrompt as Ot, isRequestedModelUnsupportedError as P, listBuiltInAgents as Pt, resolveSessionRecord as Q, PERMISSION_MODES as Qt, runTimedExecFile as R, resolveCanonicalAgentName as Rt, advertisedModelState as S, parsePromptStopReason as St, sessionOptionsFromRecord as T, mergePromptSourceWithText as Tt, absolutePath as U, extractAcpError as Ut, permissionModeSatisfies as V, isRetryablePromptError as Vt, findGitRepositoryRoot as W, isAcpResourceNotFoundError as Wt, normalizeName as X, OUTPUT_ERROR_ORIGINS as Xt, listSessionsForAgent as Y, OUTPUT_ERROR_CODES as Yt, pruneSessions as Z, OUTPUT_FORMATS as Zt, createSessionConversation as _, sessionEventLockPath as _t, applyRequestedModelIfAdvertised as a, measurePerf as at, recordSessionUpdate as b, isAcpJsonRpcMessage as bt, setCurrentModelId as c, setPerfGauge as ct, setDesiredModelId as d, serializeSessionRecordForDisk as dt, SESSION_RECORD_SCHEMA as en, createAtomicWriteTempPath as et, syncAdvertisedModelState as f, normalizeRuntimeSessionId as ft, cloneSessionConversation as g, sessionEventActivePath as gt, cloneSessionAcpxState as h, sessionBaseDir$1 as ht, connectAndLoadSession as i, QueueProtocolError as in, incrementPerfCounter as it, REQUESTED_MODEL_UNSUPPORTED_REASONS as j, withInterrupt as jt, AcpClient as k, InterruptedError as kt, setDesiredConfigOption as l, startPerfTimer as lt, applyConfigOptionsToState as m, defaultSessionEventLog as mt, runPromptTurn as n, AgentSpawnError as nn, formatPerfMetric as nt, currentModelIdFromSetModelResponse as o, recordPerfDuration as ot, applyConfigOptionsToRecord as p, DEFAULT_EVENT_SEGMENT_MAX_BYTES as pt, isoNow$2 as q, EXIT_CODES as qt, withConnectedSession as r, QueueConnectionError as rn, getPerfMetricsSnapshot as rt, clearDesiredConfigOption as s, resetPerfMetrics as st, LiveSessionCheckpoint as t, AcpxOperationalError as tn, assertPersistedKeyPolicy as tt, setDesiredModeId as u, parseSessionRecord as ut, recordClientOperation as v, sessionEventSegmentPath as vt, persistSessionOptions as w, isPromptInput as wt, trimConversationForRuntime as x, parseJsonRpcErrorMessage as xt, recordPromptSubmission as y, extractSessionUpdateNotification as yt, splitCommandLine as z, exitCodeForOutputErrorCode as zt };
@@ -204,7 +214,7 @@ index d454fd7c5bf742b469be75eb8c3988b694a0ffaa..17f0739318e61744af3eaa760cfbdc84
\ No newline at end of file
+//# sourceMappingURL=live-checkpoint-BSIrfgVo.js.map
diff --git a/dist/runtime.d.ts b/dist/runtime.d.ts
index e8102acb03c4c38830ad5ec22f356125eb0423b7..fa94c67bb8389437fe3ac3e8b92f56949778b1f1 100644
index e8102acb03c4c38830ad5ec22f356125eb0423b7..2277321c58c1265ca930c69eb3f8869bc30cfa8a 100644
--- a/dist/runtime.d.ts
+++ b/dist/runtime.d.ts
@@ -1,7 +1,8 @@
@@ -217,7 +227,15 @@ index e8102acb03c4c38830ad5ec22f356125eb0423b7..fa94c67bb8389437fe3ac3e8b92f5694
//#region src/agent-registry.d.ts
declare const DEFAULT_AGENT_NAME = "codex";
//#endregion
@@ -141,6 +142,11 @@ type AcpTextDeltaOriginMeta = {
@@ -44,6 +45,7 @@ type AcpRuntimeTurnAttachment = {
data: string;
};
type AcpRuntimeTurnInput = {
+ onTerminalSessionFailure?: (failure: { category: string; title?: string; details?: string }) => void;
handle: AcpRuntimeHandle;
text: string;
attachments?: AcpRuntimeTurnAttachment[];
@@ -141,6 +143,11 @@ type AcpTextDeltaOriginMeta = {
kind?: string;
source?: string;
};
@@ -229,7 +247,7 @@ index e8102acb03c4c38830ad5ec22f356125eb0423b7..fa94c67bb8389437fe3ac3e8b92f5694
type AcpRuntimeEvent = {
type: "text_delta";
text: string;
@@ -186,6 +192,10 @@ type AcpRuntimeEvent = {
@@ -186,6 +193,10 @@ type AcpRuntimeEvent = {
* non-null `input` schema.
*/
availableCommands?: AcpRuntimeAvailableCommand[];
@@ -240,7 +258,7 @@ index e8102acb03c4c38830ad5ec22f356125eb0423b7..fa94c67bb8389437fe3ac3e8b92f5694
} | {
type: "tool_call";
text: string;
@@ -313,6 +323,35 @@ type AcpRuntimeOptions = {
@@ -313,6 +324,35 @@ type AcpRuntimeOptions = {
onPermissionRequest?: (req: AcpPermissionRequest, ctx: {
signal: AbortSignal;
}) => Promise<AcpPermissionDecision | undefined>;
@@ -276,8 +294,24 @@ index e8102acb03c4c38830ad5ec22f356125eb0423b7..fa94c67bb8389437fe3ac3e8b92f5694
};
type AcpFileSessionStoreOptions = {
stateDir: string;
@@ -369,6 +409,7 @@ declare class AcpRuntimeManager {
private createAndSaveRuntimeRecord;
private retainInitializedSessionOwner;
startTurn(input: {
+ onTerminalSessionFailure?: (failure: { category: string; title?: string; details?: string }) => void;
handle: AcpRuntimeHandle;
text: string;
attachments?: AcpRuntimeTurnAttachment[];
@@ -414,6 +455,7 @@ declare class AcpRuntimeManager {
private cleanupRuntimeTurn;
private finalizeRuntimeTurnRecord;
runTurn(input: {
+ onTerminalSessionFailure?: (failure: { category: string; title?: string; details?: string }) => void;
handle: AcpRuntimeHandle;
text: string;
attachments?: AcpRuntimeTurnAttachment[];
diff --git a/dist/runtime.js b/dist/runtime.js
index a1f4a70a003792c6eacf68b6b038f37bfec1db53..5c0562034f5bcb726536136db3cb2d3f83e5a8b5 100644
index a1f4a70a003792c6eacf68b6b038f37bfec1db53..57117c9f6f2e9c737b138bc86977a43b3383f379 100644
--- a/dist/runtime.js
+++ b/dist/runtime.js
@@ -371,7 +371,7 @@ const PROMPT_EVENT_PARSERS = {
@@ -324,7 +358,7 @@ index a1f4a70a003792c6eacf68b6b038f37bfec1db53..5c0562034f5bcb726536136db3cb2d3f
function normalizeUsageCost(value) {
if (!isRecord(value)) return;
const amount = asOptionalFiniteNumber(value.amount);
@@ -812,7 +826,17 @@ var AcpRuntimeManager = class {
@@ -812,7 +840,17 @@ var AcpRuntimeManager = class {
this.deps = deps;
}
createClient(options) {
@@ -343,7 +377,7 @@ index a1f4a70a003792c6eacf68b6b038f37bfec1db53..5c0562034f5bcb726536136db3cb2d3f
}
createSessionOwner(input) {
const owner = {
@@ -821,12 +845,16 @@ var AcpRuntimeManager = class {
@@ -821,12 +859,16 @@ var AcpRuntimeManager = class {
pendingSessionUpdates: []
};
input.client.setEventHandlers({
@@ -360,7 +394,7 @@ index a1f4a70a003792c6eacf68b6b038f37bfec1db53..5c0562034f5bcb726536136db3cb2d3f
const active = owner.activeTurn;
if (active) {
const { task, turn } = active;
@@ -863,6 +891,9 @@ var AcpRuntimeManager = class {
@@ -863,6 +905,9 @@ var AcpRuntimeManager = class {
projection.checkpoint.request();
}
routeOwnedClientOperation(owner, operation) {
@@ -370,7 +404,7 @@ index a1f4a70a003792c6eacf68b6b038f37bfec1db53..5c0562034f5bcb726536136db3cb2d3f
const active = owner.activeTurn;
if (!active) return;
const { task, turn } = active;
@@ -1008,6 +1062,8 @@ var AcpRuntimeManager = class {
@@ -1008,6 +1053,8 @@ var AcpRuntimeManager = class {
record.closedAt = void 0;
record.protocolVersion = owner.client.initializeResult?.protocolVersion;
record.agentCapabilities = owner.client.initializeResult?.agentCapabilities;
@@ -379,7 +413,7 @@ index a1f4a70a003792c6eacf68b6b038f37bfec1db53..5c0562034f5bcb726536136db3cb2d3f
applyLifecycleSnapshotToRecord(record, owner.client.getAgentLifecycleSnapshot());
}
async finishBufferedOwnerControl(owner, record) {
@@ -1086,6 +1142,11 @@ var AcpRuntimeManager = class {
@@ -1086,6 +1133,11 @@ var AcpRuntimeManager = class {
record.closed = false;
record.closedAt = void 0;
this.closingActiveRecords.delete(record.acpxRecordId);
@@ -391,7 +425,7 @@ index a1f4a70a003792c6eacf68b6b038f37bfec1db53..5c0562034f5bcb726536136db3cb2d3f
await this.options.sessionStore.save(record);
return record;
}
@@ -1149,6 +1210,8 @@ var AcpRuntimeManager = class {
@@ -1149,6 +1201,8 @@ var AcpRuntimeManager = class {
this.closingActiveRecords.delete(record.acpxRecordId);
record.protocolVersion = client.initializeResult?.protocolVersion;
record.agentCapabilities = client.initializeResult?.agentCapabilities;
@@ -400,7 +434,15 @@ index a1f4a70a003792c6eacf68b6b038f37bfec1db53..5c0562034f5bcb726536136db3cb2d3f
applyConfigOptionsToRecord(record, session.sessionResult);
const modelApplication = await applyRequestedModelIfAdvertised({
client,
@@ -1469,6 +1532,10 @@ var AcpRuntimeManager = class {
@@ -1301,6 +1355,7 @@ var AcpRuntimeManager = class {
client: turn.client,
sessionId,
prompt: task.promptInput,
+ onTerminalSessionFailure: task.input.onTerminalSessionFailure,
timeoutMs: task.input.timeoutMs ?? this.options.timeoutMs,
conversation: turn.conversation,
promptMessageId: turn.promptMessageId,
@@ -1469,6 +1524,10 @@ var AcpRuntimeManager = class {
setSessionConfigOption: async (configId, value) => {
return (await task.state.activeController.setResolvedSessionConfigOption(configId, value)).response;
},
@@ -411,7 +453,7 @@ index a1f4a70a003792c6eacf68b6b038f37bfec1db53..5c0562034f5bcb726536136db3cb2d3f
setResolvedSessionConfigOption: async (configId, value) => await this.setRuntimeResolvedSessionConfigOption(task, turn, configId, value)
};
}
@@ -1575,6 +1642,8 @@ var AcpRuntimeManager = class {
@@ -1575,6 +1634,8 @@ var AcpRuntimeManager = class {
reconcileAgentSessionId(turn.record, turn.record.agentSessionId);
turn.record.protocolVersion = turn.client.initializeResult?.protocolVersion;
turn.record.agentCapabilities = turn.client.initializeResult?.agentCapabilities;
@@ -420,7 +462,7 @@ index a1f4a70a003792c6eacf68b6b038f37bfec1db53..5c0562034f5bcb726536136db3cb2d3f
turn.record.acpx = turn.acpxState;
applyConversation(turn.record, turn.conversation);
applyLifecycleSnapshotToRecord(turn.record, turn.client.getAgentLifecycleSnapshot());
@@ -1707,6 +1776,17 @@ var AcpRuntimeManager = class {
@@ -1707,6 +1768,17 @@ var AcpRuntimeManager = class {
});
await this.options.sessionStore.save(result.record);
}
@@ -438,7 +480,23 @@ index a1f4a70a003792c6eacf68b6b038f37bfec1db53..5c0562034f5bcb726536136db3cb2d3f
async cancel(handle) {
await this.activeControllers.get(handle.acpxRecordId ?? handle.sessionKey)?.requestCancelActivePrompt();
}
@@ -2119,6 +2199,14 @@ var AcpxRuntime = class {
@@ -2055,6 +2127,7 @@ var AcpxRuntime = class {
const turnPromise = this.getManager().then((manager) => manager.startTurn({
handle,
text: input.text,
+ onTerminalSessionFailure: input.onTerminalSessionFailure,
attachments: input.attachments,
mode: input.mode,
sessionMode: state.mode,
@@ -2087,6 +2160,7 @@ var AcpxRuntime = class {
yield* (await this.getManager()).runTurn({
handle,
text: input.text,
+ onTerminalSessionFailure: input.onTerminalSessionFailure,
attachments: input.attachments,
mode: input.mode,
sessionMode: state.mode,
@@ -2119,6 +2193,14 @@ var AcpxRuntime = class {
const { handle, state } = this.resolveManagerHandle(input.handle);
await (await this.getManager()).setConfigOption(handle, input.key, input.value, state.mode);
}
@@ -453,7 +511,7 @@ index a1f4a70a003792c6eacf68b6b038f37bfec1db53..5c0562034f5bcb726536136db3cb2d3f
async cancel(input) {
const { handle } = this.resolveManagerHandle(input.handle);
await (await this.getManager()).cancel(handle);
@@ -2178,4 +2266,4 @@ function createRuntimeStore(options) {
@@ -2178,4 +2260,4 @@ function createRuntimeStore(options) {
//#endregion
export { ACPX_BACKEND_ID, AcpRuntimeError, AcpxRuntime, DEFAULT_AGENT_NAME, REQUESTED_MODEL_UNSUPPORTED_ERROR_CODE, REQUESTED_MODEL_UNSUPPORTED_REASONS, RequestedModelUnsupportedError, createAcpRuntime, createAgentRegistry, createFileSessionStore, createRuntimeStore, decodeAcpxRuntimeHandleState, encodeAcpxRuntimeHandleState, isAcpRuntimeError, isRequestedModelUnsupportedError };
@@ -461,7 +519,7 @@ index a1f4a70a003792c6eacf68b6b038f37bfec1db53..5c0562034f5bcb726536136db3cb2d3f
\ No newline at end of file
+//# sourceMappingURL=runtime.js.map
diff --git a/dist/session-options-DwRDODlr.d.ts b/dist/session-options-DwRDODlr.d.ts
index c3da1645235bbea22de3f8484149051cd7dca56b..1cd88ed98a5346a345838227e8fd9e7616d0b42b 100644
index c3da1645235bbea22de3f8484149051cd7dca56b..ab6c1bf8aaf879800552d22f357e88b57b36faa2 100644
--- a/dist/session-options-DwRDODlr.d.ts
+++ b/dist/session-options-DwRDODlr.d.ts
@@ -1,4 +1,5 @@
@@ -495,7 +553,7 @@ index c3da1645235bbea22de3f8484149051cd7dca56b..1cd88ed98a5346a345838227e8fd9e76
onAcpMessage?: (direction: AcpMessageDirection, message: AcpJsonRpcMessage) => void;
onAcpOutputMessage?: (direction: AcpMessageDirection, message: AcpJsonRpcMessage) => void;
onSessionUpdate?: (notification: SessionNotification) => void;
@@ -263,6 +275,7 @@ type SessionRecord = {
@@ -263,6 +282,7 @@ type SessionRecord = {
lastAgentDisconnectReason?: string;
protocolVersion?: number;
agentCapabilities?: AgentCapabilities;
@@ -503,7 +561,7 @@ index c3da1645235bbea22de3f8484149051cd7dca56b..1cd88ed98a5346a345838227e8fd9e76
title?: string | null;
messages: SessionMessage[];
updated_at: string;
@@ -295,4 +308,4 @@ type SessionAgentOptions = {
@@ -295,4 +315,4 @@ type SessionAgentOptions = {
};
//#endregion
export { SessionRecord as _, AcpElicitationHandler as a, AcpElicitationResponse as c, AuthPolicy as d, McpServer$1 as f, PermissionStats as g, PermissionPolicy as h, AcpElicitationContext as i, AcpPermissionDecision as l, PermissionMode as m, SystemPromptOption as n, AcpElicitationMode as o, NonInteractivePermissionPolicy as p, AcpClientOptions as r, AcpElicitationRequest as s, SessionAgentOptions as t, AcpPermissionRequest as u, PromptInput as v };
@@ -41,7 +41,7 @@ async function handleRequest(request) {
const sessionFailure = {
id: `${request.params.sessionId}:error`,
revision: 1,
category: "request",
category: process.env.PAPERCLIP_ACPX_TYPED_FAILURE_CATEGORY ?? "request",
severity: "error",
title: typedFailureCanary,
actions: [],
@@ -59426,8 +59426,36 @@ describeEmbeddedPostgres("chat channel control-plane integration", () => {
const fixture = await seedCompany();
const { callbacks, endpoint, service, wakeup, webhookSecret } =
await configuredGitHubEndpoint(fixture);
// Recovery sweeps all companies. Only this endpoint's assigned agent
// proves whether its bot edit incorrectly created new work.
// Recover only this fixture's ingress and delivery. A global sweep can
// drain unrelated fixtures' retries and exceed the test's time budget.
const processFixtureDelivery = async () => {
const [ingress] = await db
.select({ id: chatActions.id })
.from(chatActions)
.where(
and(
eq(chatActions.endpointId, endpoint.id),
eq(chatActions.kind, "github_webhook_ingress"),
eq(
chatActions.providerActionId,
"github_webhook_ingress:bot-update-exact",
),
),
);
expect(ingress).toBeDefined();
await service.processPendingGitHubWebhookIngress(1, ingress!.id);
const [delivery] = await db
.select({ id: chatDeliveries.id })
.from(chatDeliveries)
.where(
and(
eq(chatDeliveries.endpointId, endpoint.id),
eq(chatDeliveries.eventKind, "message_updated"),
),
);
expect(delivery).toBeDefined();
await service.processPendingDeliveries(1, delivery!.id);
};
const fixtureWakeups = () =>
wakeup.mock.calls.filter((call) => call[0] === fixture.assignedAgentId);
const thread = makeThread({
@@ -59549,7 +59577,7 @@ describeEmbeddedPostgres("chat channel control-plane integration", () => {
).toHaveLength(0);
}
expect((await send()).ok).toBe(true);
await service.processPendingDeliveries();
await processFixtureDelivery();
const [delivery] = await db
.select()
.from(chatDeliveries)
@@ -59586,7 +59614,7 @@ describeEmbeddedPostgres("chat channel control-plane integration", () => {
filtering: { contentRetained: false },
});
expect((await send()).ok).toBe(true);
await service.processPendingDeliveries();
await processFixtureDelivery();
expect(
await db
.select()
@@ -7,6 +7,36 @@ import {
import { legacyExecutionNeedsReconciliation } from "../legacy-execution-recovery.js";
describe("classifyAdapterFailureForRecovery", () => {
it("uses a typed ACP quota reset without needing the provider's original message", () => {
const now = new Date("2026-07-15T20:00:00.000Z");
expect(classifyAdapterFailureForRecovery({
errorCode: "provider_quota",
error: "ACP agent reported a terminal limit failure.",
resultJson: {
errorFamily: "provider_quota",
retryNotBefore: "2026-07-15T21:30:00.000Z",
providerQuotaRetryNotBefore: "2026-07-15T21:30:00.000Z",
},
}, now)).toEqual({
kind: "provider_quota",
retryAt: new Date("2026-07-15T21:30:00.000Z"),
parsedResetTime: true,
});
});
it("uses the existing backoff for a typed ACP quota failure with no reset timestamp", () => {
const now = new Date("2026-07-15T20:00:00.000Z");
expect(classifyAdapterFailureForRecovery({
errorCode: "provider_quota",
error: "ACP agent reported a terminal limit failure.",
resultJson: { errorFamily: "provider_quota" },
}, now)).toEqual({
kind: "provider_quota",
retryAt: new Date(now.getTime() + PROVIDER_QUOTA_RECOVERY_DEFAULT_BACKOFF_MS),
parsedResetTime: false,
});
});
it("classifies usage-limit messages and parses the provider reset time", () => {
const now = new Date("2026-07-15T20:00:00.000Z");
const classification = classifyAdapterFailureForRecovery({