diff --git a/utils/agentHangReport.ts b/utils/agentHangReport.ts index e744417..08f6c71 100644 --- a/utils/agentHangReport.ts +++ b/utils/agentHangReport.ts @@ -41,6 +41,15 @@ export function formatAgentHangBody(input: { }): string | null { if (!input.diagnostic) return null; + // billing exhaustion (CreditsError / FreeUsageLimitError / spending cap / + // Insufficient balance) is mis-classified as transient by upstream harnesses + // and the run only ends when the activity-timeout watchdog fires (see #778). + // when we recognise the billing label, replace the generic "stalled — auth + // error" headline with a billing-specific CTA that names the actual remedy. + if (input.diagnostic.lastProviderError === "provider billing exhausted") { + return formatBillingExhaustedBody(input.diagnostic); + } + const verb = input.isHang ? "stalled" : "failed"; const cause = input.diagnostic.lastProviderError ? ` — likely cause: \`${input.diagnostic.lastProviderError}\`` @@ -113,3 +122,49 @@ function pickFence(content: string): string { } return "`".repeat(Math.max(3, max + 1)); } + +/** + * Pull a billing URL out of the captured stderr if the provider helpfully + * embedded one (OpenCode Zen does — Anthropic and Gemini do not). Restricted + * to known billing/console hosts so a stray URL elsewhere in the buffer + * can't masquerade as the remedy link. + */ +function extractBillingUrl(lines: readonly string[]): string | undefined { + const urlPattern = + /https:\/\/(?:opencode\.ai\/[^\s"]*billing[^\s"]*|console\.anthropic\.com[^\s"]*|console\.cloud\.google\.com[^\s"]*billing[^\s"]*)/i; + for (let i = lines.length - 1; i >= 0; i--) { + const m = urlPattern.exec(lines[i] ?? ""); + if (m) return m[0]; + } + return undefined; +} + +function formatBillingExhaustedBody(diagnostic: AgentDiagnostic): string { + const headline = `**${diagnostic.label} stopped** — your model provider returned a billing-exhausted response.`; + + const billingUrl = extractBillingUrl(diagnostic.recentStderr); + const cta = billingUrl + ? `Top up your provider balance, then re-run: [${billingUrl}](${billingUrl})` + : "Top up your model-provider balance (or rotate to a key with remaining credits) and re-run."; + const explanation = + "The agent kept retrying the request because the provider marked the failure as transient. Pullfrog's activity-timeout watchdog ended the run after no further events were emitted."; + + const parts = [headline, "", explanation, "", cta]; + + const tail = renderStderrTail(diagnostic.recentStderr); + if (tail) { + const fence = pickFence(tail); + parts.push( + "", + "
Recent agent stderr", + "", + fence, + tail, + fence, + "", + "
" + ); + } + + return parts.join("\n"); +} diff --git a/utils/apiKeys.test.ts b/utils/apiKeys.test.ts index 82279ad..a096fe1 100644 --- a/utils/apiKeys.test.ts +++ b/utils/apiKeys.test.ts @@ -172,6 +172,23 @@ describe("isApiKeyAuthError", () => { expect(isApiKeyAuthError("401 Invalid authentication")).toBe(true); }); + // see #782 — direct-Anthropic 401 shape (revoked / mistyped / rotated + // ANTHROPIC_API_KEY) reaches us via Claude CLI as a JSON dump, not as + // any of the canonical "Invalid API key" strings. these matchers ensure + // the formatted CTA fires instead of the raw 401 JSON blob. + it("matches direct-Anthropic 401 shapes", () => { + expect( + isApiKeyAuthError( + 'Failed to authenticate. API Error: 401 {"type":"error","error":{"type":"authentication_error","message":"Invalid bearer token"}}' + ) + ).toBe(true); + expect( + isApiKeyAuthError( + "» Pullfrog result error: subtype=success, api_error_status=401, message=Failed to authenticate." + ) + ).toBe(true); + }); + it("ignores unrelated errors", () => { expect(isApiKeyAuthError("git fetch failed")).toBe(false); expect(isApiKeyAuthError("")).toBe(false); diff --git a/utils/apiKeys.ts b/utils/apiKeys.ts index 58049db..491801c 100644 --- a/utils/apiKeys.ts +++ b/utils/apiKeys.ts @@ -120,10 +120,14 @@ export function validateAgentApiKey(params: { /** * Detect agent-runtime auth failures that should be reformatted as an actionable - * key-fix CTA before being shown to the user. Covers the two shapes we see: + * key-fix CTA before being shown to the user. Covers the shapes we see: * - missing key (validateAgentApiKey throw): contains MISSING_KEY_MARKER * - revoked / invalid key (Claude CLI 401 surfaced via api_error_status): * "Invalid API key · Fix external API key" + similar provider variants + * - direct-Anthropic 401 (`Failed to authenticate. API Error: 401 ... + * {"type":"error","error":{"type":"authentication_error", ... + * "Invalid bearer token"}}`) emitted by the Claude CLI for revoked / + * mistyped / rotated `ANTHROPIC_API_KEY`. see #782. */ export function isApiKeyAuthError(text: string): boolean { if (!text) return false; @@ -131,7 +135,11 @@ export function isApiKeyAuthError(text: string): boolean { text.includes(MISSING_KEY_MARKER) || /Invalid API key/i.test(text) || /\bUser not found\b/i.test(text) || - /\bInvalid authentication\b/i.test(text) + /\bInvalid authentication\b/i.test(text) || + /authentication_error/i.test(text) || + /Invalid bearer token/i.test(text) || + /api_error_status\s*=\s*401/i.test(text) || + /API Error:\s*401/i.test(text) ); } diff --git a/utils/errorReport.ts b/utils/errorReport.ts index 4169108..cdeef63 100644 --- a/utils/errorReport.ts +++ b/utils/errorReport.ts @@ -1,6 +1,7 @@ import type { ToolState } from "../toolState.ts"; import { getApiUrl } from "./apiUrl.ts"; import { buildPullfrogFooter } from "./buildPullfrogFooter.ts"; +import { log } from "./cli.ts"; import { createOctokit, parseRepoContext } from "./github.ts"; import { updateProgressComment } from "./progressComment.ts"; import { getGitHubInstallationToken } from "./token.ts"; @@ -9,16 +10,20 @@ interface ReportErrorParams { toolState: ToolState; error: string; title?: string; + /** + * When the run has no pre-existing progress comment to update (silent + * IncrementalReview / pull_request_synchronize, mode-less polls), create + * a fresh issue comment on `toolState.issueNumber` instead of returning + * silently. Used for terminal errors (BillingError, TransientError) where + * the GH job summary is the only other surface and most users never open + * it. see #775. + */ + createIfMissing?: boolean; } export async function reportErrorToComment(ctx: ReportErrorParams): Promise { const formattedError = ctx.title ? `${ctx.title}\n\n${ctx.error}` : ctx.error; - const comment = ctx.toolState.progressComment; - if (!comment) { - return; - } - const repoContext = parseRepoContext(); const octokit = createOctokit(getGitHubInstallationToken()); const runId = process.env.GITHUB_RUN_ID @@ -41,12 +46,39 @@ export async function reportErrorToComment(ctx: ReportErrorParams): Promise { }); }); + describe("billing exhaustion", () => { + // see #778 — providers return 401 / 429 for billing/quota exhaustion + // (OpenCode Zen `CreditsError` / `FreeUsageLimitError`, Gemini + // `RESOURCE_EXHAUSTED` + spending cap, "Insufficient balance"). these + // are non-retryable; status-code patterns must NOT win and surface the + // misleading "auth error (401)" / "rate limited (429)" labels. + it("classifies OpenCode Zen CreditsError as billing exhausted, not 401", () => { + const stderr = JSON.stringify({ + statusCode: 401, + responseBody: + '{"type":"error","error":{"type":"CreditsError","message":"Insufficient balance. Manage your billing here: https://opencode.ai/workspace/x/billing"}}', + }); + expect(detectProviderError(stderr)).toBe("provider billing exhausted"); + }); + + it("classifies OpenCode Zen FreeUsageLimitError as billing exhausted, not 429", () => { + const stderr = JSON.stringify({ + statusCode: 429, + responseBody: + '{"type":"error","error":{"type":"FreeUsageLimitError","message":"Rate limit exceeded. Please try again later."}}', + }); + expect(detectProviderError(stderr)).toBe("provider billing exhausted"); + }); + + it("classifies Gemini spending-cap RESOURCE_EXHAUSTED as billing exhausted, not 429", () => { + const stderr = + 'statusCode: 429, body: {"code": 429, "status": "RESOURCE_EXHAUSTED", "message": "Your project has exceeded its monthly spending cap..."}'; + expect(detectProviderError(stderr)).toBe("provider billing exhausted"); + }); + + it("classifies bare 'Insufficient balance' as billing exhausted", () => { + expect(detectProviderError("error: Insufficient balance")).toBe("provider billing exhausted"); + }); + }); + describe("real provider errors", () => { it("detects 429 only when adjacent to a status key", () => { expect(detectProviderError('{"statusCode": 429}')).toBe("rate limited (429)"); diff --git a/utils/providerErrors.ts b/utils/providerErrors.ts index 1cf071c..2c8104f 100644 --- a/utils/providerErrors.ts +++ b/utils/providerErrors.ts @@ -6,6 +6,17 @@ type ProviderErrorPattern = { regex: RegExp; label: string }; const statusKey = `\\b(?:status[_ ]?code|http[_ ]?status|status)["']?\\s*[:=]\\s*["']?`; const PROVIDER_ERROR_PATTERNS: ProviderErrorPattern[] = [ + // billing-payload patterns come BEFORE bare status-code patterns. providers + // commonly return 401 / 429 for billing/quota exhaustion (OpenCode Zen + // `CreditsError` / `FreeUsageLimitError`, Gemini `RESOURCE_EXHAUSTED` + + // "spending cap", Anthropic "Insufficient balance"). these are non-retryable + // and require user-billing action — distinct from a transient auth error or + // rate-limit. status-code patterns would otherwise win and surface + // "auth error (401)" / "rate limited (429)" with no billing hint. see #778. + { regex: /\bCreditsError\b/, label: "provider billing exhausted" }, + { regex: /\bFreeUsageLimitError\b/, label: "provider billing exhausted" }, + { regex: /Insufficient balance/i, label: "provider billing exhausted" }, + { regex: /spending cap/i, label: "provider billing exhausted" }, // auth patterns must come BEFORE rate-limit patterns. OpenRouter 401 error // payloads carry `x-ratelimit-*` response headers in the dump, and the // free-form rate-limit regex below would otherwise win on word-boundary diff --git a/utils/proxy.ts b/utils/proxy.ts index 9833007..c49527f 100644 --- a/utils/proxy.ts +++ b/utils/proxy.ts @@ -206,16 +206,28 @@ export async function runProxyResolution(ctx: { const summary = formatBillingErrorSummary(error, ctx.repo.owner); await writeSummary(summary).catch(() => {}); // Mirror to the PR progress comment if the trigger created one (mention / - // PR event). Without this, auto-reload declines are only visible in the - // job summary — users rarely open that, so the agent just appears to - // silently stop mid-run. - await reportErrorToComment({ toolState: ctx.toolState, error: summary }).catch(() => {}); + // PR event). When the trigger is silent (IncrementalReview on + // pull_request_synchronize), no progress comment exists; fall through to + // creating a fresh issue comment so the user actually sees the + // billing-exhaustion remediation copy. Without `createIfMissing`, + // auto-reload declines on silent triggers are visible only in the GH job + // summary, which most users never open — so back-to-back pushes silently + // burn through dispatches with no PR-side signal. see #775. + await reportErrorToComment({ + toolState: ctx.toolState, + error: summary, + createIfMissing: true, + }).catch(() => {}); throw error; } if (error instanceof TransientError) { const summary = formatTransientErrorSummary(error, ctx.repo.owner); await writeSummary(summary).catch(() => {}); - await reportErrorToComment({ toolState: ctx.toolState, error: summary }).catch(() => {}); + await reportErrorToComment({ + toolState: ctx.toolState, + error: summary, + createIfMissing: true, + }).catch(() => {}); throw error; } throw error;