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;