diff --git a/packages/adapters/claude-local/src/server/parse.test.ts b/packages/adapters/claude-local/src/server/parse.test.ts index 13b4964b99..5b291d4056 100644 --- a/packages/adapters/claude-local/src/server/parse.test.ts +++ b/packages/adapters/claude-local/src/server/parse.test.ts @@ -199,6 +199,18 @@ describe("isClaudeTransientUpstreamError", () => { ); }); + it("classifies the qualifier-less limit wording as provider quota and extracts the retry time", () => { + // Current Claude CLI phrasing: no "session"/"usage" qualifier before "limit". + const now = new Date("2026-08-28T22:30:00.000Z"); + const errorMessage = "You've hit your limit · resets 2:30am (UTC)"; + + expect(isClaudeProviderQuotaError({ errorMessage })).toBe(true); + expect(isClaudeTransientUpstreamError({ errorMessage })).toBe(false); + expect(extractClaudeRetryNotBefore({ errorMessage }, now)?.toISOString()).toBe( + "2026-08-29T02:30:00.000Z", + ); + }); + it("classifies Anthropic API rate_limit_error and overloaded_error as transient", () => { expect( isClaudeTransientUpstreamError({ diff --git a/packages/adapters/claude-local/src/server/parse.ts b/packages/adapters/claude-local/src/server/parse.ts index a3f3d95bec..3e636ac2cf 100644 --- a/packages/adapters/claude-local/src/server/parse.ts +++ b/packages/adapters/claude-local/src/server/parse.ts @@ -23,11 +23,11 @@ const URL_RE = /(https?:\/\/[^\s'"`<>()[\]{};,!?]+[^\s'"`<>()[\]{};,!.?:]+)/gi; const CLAUDE_TRANSIENT_UPSTREAM_RE = /(?:rate[-\s]?limit(?:ed)?|rate_limit_error|too\s+many\s+requests|\b429\b|overloaded(?:_error)?|server\s+overloaded|service\s+unavailable|\b503\b|\b529\b|high\s+demand|try\s+again\s+later|temporarily\s+unavailable|throttl(?:ed|ing)|throttlingexception|servicequotaexceededexception|out\s+of\s+extra\s+usage|extra\s+usage\b|claude\s+usage\s+limit\s+reached|5[-\s]?hour\s+limit\s+reached|weekly\s+limit\s+reached|usage\s+limit\s+reached|usage\s+cap\s+reached)/i; const CLAUDE_PROVIDER_QUOTA_RE = - /(?:you(?:'|’)ve\s+hit\s+your\s+session\s+limit|session\s+limit\s+(?:reached|exceeded)|out\s+of\s+extra\s+usage|extra\s+usage\b|claude\s+usage\s+limit\s+reached|5[-\s]?hour\s+limit\s+reached|weekly\s+limit\s+reached|usage\s+limit\s+reached|usage\s+cap\s+reached|servicequotaexceededexception)/i; + /(?:you(?:'|’)ve\s+hit\s+your\s+(?:\w+\s+)?limit|session\s+limit\s+(?:reached|exceeded)|out\s+of\s+extra\s+usage|extra\s+usage\b|claude\s+usage\s+limit\s+reached|5[-\s]?hour\s+limit\s+reached|weekly\s+limit\s+reached|usage\s+limit\s+reached|usage\s+cap\s+reached|servicequotaexceededexception)/i; const CLAUDE_MODEL_NOT_FOUND_RE = /(?:\b404\b[\s\S]{0,120})?(?:model[\s_-]*(?:not[\s_-]*found|does not exist|unknown|invalid)|unknown[\s_-]*model)/i; const CLAUDE_EXTRA_USAGE_RESET_RE = - /(?:you(?:'|’)ve\s+hit\s+your\s+session\s+limit|session\s+limit\s+(?:reached|exceeded)|out\s+of\s+extra\s+usage|extra\s+usage|usage\s+limit\s+reached|usage\s+cap\s+reached|5[-\s]?hour\s+limit\s+reached|weekly\s+limit\s+reached|claude\s+usage\s+limit\s+reached)[\s\S]{0,120}?\bresets?\s+(?:at\s+)?([^\n()]+?)(?:\s*\(([^)]+)\))?(?:[.!]|\n|$)/i; + /(?:you(?:'|’)ve\s+hit\s+your\s+(?:\w+\s+)?limit|session\s+limit\s+(?:reached|exceeded)|out\s+of\s+extra\s+usage|extra\s+usage|usage\s+limit\s+reached|usage\s+cap\s+reached|5[-\s]?hour\s+limit\s+reached|weekly\s+limit\s+reached|claude\s+usage\s+limit\s+reached)[\s\S]{0,120}?\bresets?\s+(?:at\s+)?([^\n()]+?)(?:\s*\(([^)]+)\))?(?:[.!]|\n|$)/i; /** * Sum the per-model usage ledger from a Claude CLI result event. The result diff --git a/server/src/services/recovery/provider-failure-classification.test.ts b/server/src/services/recovery/provider-failure-classification.test.ts index 20f5f43e4f..307d38f4fe 100644 --- a/server/src/services/recovery/provider-failure-classification.test.ts +++ b/server/src/services/recovery/provider-failure-classification.test.ts @@ -65,6 +65,22 @@ describe("classifyAdapterFailureForRecovery", () => { }); }); + it("classifies the qualifier-less limit wording and parses the 'resets' clock", () => { + // Current Claude CLI phrasing, as recorded on the run by the adapter. + const now = new Date("2026-08-28T22:30:00.000Z"); + const classification = classifyAdapterFailureForRecovery({ + errorCode: "adapter_failed", + error: "Claude run failed: subtype=success: You've hit your limit · resets 2:30am (UTC)", + resultJson: null, + }, now); + + expect(classification).toEqual({ + kind: "provider_quota", + retryAt: new Date("2026-08-29T02:30:00.000Z"), + parsedResetTime: true, + }); + }); + it.each([ "model_not_found: requested model does not exist", "No API credentials were found for this provider", diff --git a/server/src/services/recovery/service.ts b/server/src/services/recovery/service.ts index 1693ec9848..1e28caff56 100644 --- a/server/src/services/recovery/service.ts +++ b/server/src/services/recovery/service.ts @@ -288,7 +288,7 @@ function isProviderQuotaRecovery(latestRun: LatestIssueRun) { if (latestRun?.errorCode === "provider_quota") return true; if (readRecoveryRunErrorFamily(latestRun) === "provider_quota") return true; if (latestRun?.errorCode !== "adapter_failed") return false; - return /(?:usage|rate|quota) limit|quota (?:exceeded|reset)|try again after/i.test(latestRun.error ?? ""); + return /(?:usage|rate|quota) limit|you(?:'|’)ve hit your (?:\w+ )?limit|quota (?:exceeded|reset)|try again after/i.test(latestRun.error ?? ""); } function resolveStrandedRecoveryCause( @@ -406,7 +406,7 @@ const CONTINUATION_RECOVERY_TRANSIENT_BASE_BACKOFF_MS = 60_000; export const PROVIDER_QUOTA_RECOVERY_DEFAULT_BACKOFF_MS = 60 * 60 * 1000; const PROVIDER_QUOTA_ERROR_RE = - /(?:you(?:'|’)ve hit your usage limit|usage limit(?: reached| exceeded)?|provider quota|quota (?:limit )?exceeded|model (?:is )?at capacity)/i; + /(?:you(?:'|’)ve hit your (?:\w+ )?limit|usage limit(?: reached| exceeded)?|provider quota|quota (?:limit )?exceeded|model (?:is )?at capacity)/i; const CONFIGURATION_INCOMPLETE_ERROR_RE = /(?:model_not_found|model [^\n]{0,120} not found|missing (?:api )?(?:key|credentials?)|credentials? (?:are |is )?missing|no (?:api )?(?:key|credentials?) (?:was |were )?(?:found|configured|provided)|api key (?:is )?(?:not set|unavailable))/i; @@ -417,7 +417,7 @@ export type AdapterFailureRecoveryClassification = function parseProviderQuotaClockReset(error: string, now: Date) { const match = error.match( - /try again at\s+(\d{1,2})(?::(\d{2}))?\s*(?:([ap])\.?\s*m\.?)?(?:\s*\(([^)]+)\)|\s+([A-Z]{2,5}))?/i, + /(?:try again at|resets?(?:\s+at)?)\s+(\d{1,2})(?::(\d{2}))?\s*(?:([ap])\.?\s*m\.?)?(?:\s*\(([^)]+)\)|\s+([A-Z]{2,5}))?/i, ); if (!match) return null; diff --git a/server/src/services/recovery/stranded-notice.test.ts b/server/src/services/recovery/stranded-notice.test.ts index 4a2878ac32..e26776496c 100644 --- a/server/src/services/recovery/stranded-notice.test.ts +++ b/server/src/services/recovery/stranded-notice.test.ts @@ -156,6 +156,37 @@ describe("buildStrandedRecoveryEscalationNotice", () => { expect(rows.some((row) => row.label === "Failure summary")).toBe(false); }); + it("leads with the classified run failure code over the generic seed title", () => { + const notice = buildStrandedRecoveryEscalationNotice({ + seed: buildImmediateExecutionPathRecoveryNoticeSeed({ status: "in_progress" }), + recoveryActionId: actionId, + recoveryOwner: owner, + sourceRun: { + ...sourceRun, + errorCode: "provider_quota", + errorSummary: "You've hit your limit · resets 2:30am (UTC)", + }, + }); + + expect(notice.presentation.title).toBe("Error: usage limit reached"); + expect(allRows(notice.metadata)).toContainEqual({ + type: "key_value", + label: "Failure code", + value: "provider_quota", + }); + }); + + it("titles auth-required run failures as a login error", () => { + expect( + buildStrandedRecoveryEscalationNotice({ + seed: buildImmediateExecutionPathRecoveryNoticeSeed({ status: "todo" }), + recoveryActionId: actionId, + recoveryOwner: null, + sourceRun: { ...sourceRun, errorCode: "claude_auth_required" }, + }).presentation.title, + ).toBe("Error: not logged in to Claude"); + }); + it("is matched by the metadata-based escalation dedupe matcher", () => { const notice = buildStrandedRecoveryEscalationNotice({ seed: buildImmediateExecutionPathRecoveryNoticeSeed({ status: "todo" }), diff --git a/server/src/services/recovery/stranded-notice.ts b/server/src/services/recovery/stranded-notice.ts index 79b78f83dc..75cbaace6f 100644 --- a/server/src/services/recovery/stranded-notice.ts +++ b/server/src/services/recovery/stranded-notice.ts @@ -35,6 +35,17 @@ const STRANDED_RECOVERY_NOTICE_TITLES_BY_CAUSE: Record = { execution_review_participant_recovery: "Review recovery stalled", }; +// Titles keyed by the source run's classified error code. The raw failure text +// never reaches the issue thread (summarizeRunFailureForIssueComment withholds +// it), so the classified code is the only safe, specific cause the collapsed +// notice row can lead with. A mapped code outranks the seed titles because the +// seeds describe the recovery family ("No live execution path"), not the cause. +const STRANDED_RECOVERY_NOTICE_TITLES_BY_RUN_ERROR_CODE: Record = { + provider_quota: "Error: usage limit reached", + claude_auth_required: "Error: not logged in to Claude", + acpx_auth_required: "Error: agent login required", +}; + export function buildImmediateExecutionPathRecoveryNoticeSeed(input: { status: "todo" | "in_progress"; }): StrandedRecoveryNoticeSeed { @@ -112,7 +123,9 @@ export function buildStrandedRecoveryEscalationNotice(input: { }): StrandedRecoveryEscalationNotice { const fallbackBody = input.fallbackBody?.trim(); const body = input.seed?.body ?? (fallbackBody || DEFAULT_STRANDED_RECOVERY_NOTICE_BODY); - const title = input.seed?.title ?? + const title = + STRANDED_RECOVERY_NOTICE_TITLES_BY_RUN_ERROR_CODE[input.sourceRun?.errorCode?.trim() ?? ""] ?? + input.seed?.title ?? STRANDED_RECOVERY_NOTICE_TITLES_BY_CAUSE[input.recoveryCause ?? ""] ?? DEFAULT_STRANDED_RECOVERY_NOTICE_TITLE;