This commit is contained in:
Sasshigo 2026-09-13 06:51:38 +00:00 committed by GitHub
commit 3a40fbb595
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 139 additions and 1 deletions

View File

@ -0,0 +1,82 @@
import { describe, expect, it } from "vitest";
import type { AdapterExecutionResult } from "@paperclipai/adapter-utils";
import { mapClaudeAcpLimitErrorCode } from "./acp.js";
function turnFailure(overrides: Partial<AdapterExecutionResult> = {}): AdapterExecutionResult {
return {
exitCode: 1,
signal: null,
timedOut: false,
errorCode: "acpx_turn_failed",
errorMessage: null,
resultJson: { phase: "turn" },
...overrides,
};
}
describe("mapClaudeAcpLimitErrorCode", () => {
it("classifies a session-limit turn failure as provider_quota with the parsed reset time", () => {
const now = new Date("2026-09-10T03:18:53.000Z"); // 10:18 in Asia/Bangkok (UTC+7)
const result = mapClaudeAcpLimitErrorCode(
turnFailure({
errorMessage: "Internal error: You've hit your session limit · resets 1:30pm (Asia/Bangkok)",
}),
now,
);
expect(result.errorCode).toBe("provider_quota");
expect(result.errorFamily).toBe("provider_quota");
// 1:30pm Asia/Bangkok = 06:30 UTC, same day (still ahead of `now`).
expect(result.retryNotBefore).toBe("2026-09-10T06:30:00.000Z");
expect(result.resultJson).toMatchObject({
phase: "turn",
errorFamily: "provider_quota",
retryNotBefore: "2026-09-10T06:30:00.000Z",
transientRetryNotBefore: "2026-09-10T06:30:00.000Z",
providerQuotaRetryNotBefore: "2026-09-10T06:30:00.000Z",
});
});
it("classifies a weekly-limit turn failure as provider_quota", () => {
const result = mapClaudeAcpLimitErrorCode(
turnFailure({
errorMessage: "Internal error: You've hit your weekly limit · resets 4pm (America/Chicago)",
}),
new Date("2026-09-10T03:00:00.000Z"),
);
expect(result.errorCode).toBe("provider_quota");
expect(result.retryNotBefore).toBe("2026-09-10T21:00:00.000Z"); // 4pm CDT = 21:00 UTC
});
it("keeps the quota classification when the message states no reset time", () => {
const result = mapClaudeAcpLimitErrorCode(
turnFailure({ errorMessage: "Claude usage limit reached." }),
);
expect(result.errorCode).toBe("provider_quota");
expect(result.retryNotBefore).toBeUndefined();
expect(result.resultJson).toMatchObject({ errorFamily: "provider_quota" });
});
it("leaves non-limit turn failures untouched", () => {
const input = turnFailure({ errorMessage: "Internal error: something else entirely" });
expect(mapClaudeAcpLimitErrorCode(input)).toBe(input);
});
it("leaves other error codes untouched even when the message mentions a limit", () => {
const input = turnFailure({
errorCode: "acpx_runtime_error",
errorMessage: "You've hit your session limit · resets 2pm (Asia/Bangkok)",
});
expect(mapClaudeAcpLimitErrorCode(input)).toBe(input);
});
it("never classifies from transcript noise: only the error surface is read", () => {
const input = turnFailure({
errorMessage: "Internal error: unrelated failure",
resultJson: {
phase: "turn",
stdout: "the agent transcript casually mentions: you've hit your session limit · resets 9am",
},
});
expect(mapClaudeAcpLimitErrorCode(input)).toBe(input);
});
});

View File

@ -42,6 +42,11 @@ import {
prepareClaudeConfigSeed,
prepareSandboxClaudeProbeRuntime,
} from "./claude-config.js";
import {
extractClaudeRetryNotBefore,
isClaudeProviderQuotaError,
isClaudeTransientUpstreamError,
} from "./parse.js";
import {
buildAdapterTestTargetCheck,
buildClaudeLoginRequiredHint,
@ -354,6 +359,57 @@ export function mapClaudeAcpAuthErrorCode(
return { ...result, errorCode: CLAUDE_AUTH_REQUIRED_ERROR_CODE };
}
/**
* The generic error code the shared acpx engine emits when the provider turn
* fails. Claude limit exhaustion ("You've hit your session limit · resets
* 1:30pm (Asia/Bangkok)") surfaces this way on the ACP lane, so without a
* Claude-specific translation the recovery sweep cannot classify it as a
* quota wait and strands the issue in `blocked` ("No live execution path").
*/
const ACPX_TURN_FAILED_ERROR_CODE = "acpx_turn_failed";
/**
* Translate a generic acpx turn failure into the same limit classification the
* Claude CLI lane already emits (`execute.ts`): `provider_quota` /
* `claude_transient_upstream` plus the parsed `retryNotBefore` reset time, in
* both the result fields and the `resultJson` keys the server recovery sweep
* reads (`retryNotBefore`, `transientRetryNotBefore`,
* `providerQuotaRetryNotBefore`). Classification deliberately reads only the
* run's error surface (`errorMessage`), never the transcript/stdout, which
* routinely *mentions* limits it is merely talking about.
*/
export function mapClaudeAcpLimitErrorCode(
result: AdapterExecutionResult,
now = new Date(),
): AdapterExecutionResult {
if (result.errorCode !== ACPX_TURN_FAILED_ERROR_CODE) return result;
const surface = { errorMessage: result.errorMessage ?? null };
const providerQuota = isClaudeProviderQuotaError(surface);
const transientUpstream = !providerQuota && isClaudeTransientUpstreamError(surface);
if (!providerQuota && !transientUpstream) return result;
const retryNotBefore = extractClaudeRetryNotBefore(surface, now);
const iso = retryNotBefore ? retryNotBefore.toISOString() : null;
const errorFamily = providerQuota ? "provider_quota" as const : "transient_upstream" as const;
const priorResultJson = parseObject(result.resultJson);
return {
...result,
errorCode: providerQuota ? "provider_quota" : "claude_transient_upstream",
errorFamily,
...(iso ? { retryNotBefore: iso } : {}),
resultJson: {
...priorResultJson,
errorFamily,
...(iso
? {
retryNotBefore: iso,
transientRetryNotBefore: iso,
...(providerQuota ? { providerQuotaRetryNotBefore: iso } : {}),
}
: {}),
},
};
}
export function createClaudeAcpExecutor(options: ClaudeAcpExecutorOptions = {}): ClaudeAcpExecutor {
let executor: ClaudeAcpExecutor | null = null;
return async (ctx) => {
@ -371,7 +427,7 @@ export function createClaudeAcpExecutor(options: ClaudeAcpExecutorOptions = {}):
...ctx,
config: buildClaudeAcpConfig(ctx.config, target?.kind === "remote" ? {} : process.env),
});
return mapClaudeAcpAuthErrorCode(result);
return mapClaudeAcpLimitErrorCode(mapClaudeAcpAuthErrorCode(result));
};
}