diff --git a/packages/agent-harness/src/detect-usage-limit.ts b/packages/agent-harness/src/detect-usage-limit.ts index 5309b0a..6cba187 100644 --- a/packages/agent-harness/src/detect-usage-limit.ts +++ b/packages/agent-harness/src/detect-usage-limit.ts @@ -38,14 +38,12 @@ */ const USAGE_LIMIT_PATTERNS = [ - // "You've hit your limit" - /hit your (?:session|usage|account|weekly|monthly|fast|5[- ]?hour) (?:spend )?limit/i, - // "You have reached your usage limit" - /reached your (?:usage|session|account|weekly|monthly|fast) (?:spend )?limit/i, - // "usage limit reached", "fast limit reached", "session limit reached" - /(?:usage|session|account|fast|usage credit) limit reached/i, - // generic Claude/Anthropic phrasing - /claude (?:ai )?usage limit/i, + // Keep subscription messages anchored to the whole line. Codex writes its + // tool transcript to stderr, so a substring match also sees source such as + // `super("Agent usage limit reached")` as though it were a CLI diagnostic. + /^(?:error:\s*)?you(?:'|’)ve hit your (?:session|usage|account|weekly|monthly|fast|5[- ]?hour) (?:spend )?limit(?:\s*(?:[.·—-]\s*)?(?:resets?|try again|available again|retry[- ]after)\b[^\n]*)?[.!]?$/i, + /^(?:error:\s*)?you have reached your (?:usage|session|account|weekly|monthly|fast) (?:spend )?limit(?:\s*(?:[.·—-]\s*)?(?:resets?|try again|available again|retry[- ]after)\b[^\n]*)?[.!]?$/i, + /^(?:error:\s*)?(?:(?:usage|session|account|fast|usage credit) limit reached|claude (?:ai )?usage limit(?: reached)?)(?:\s*(?:[.·—-]\s*)?(?:resets?|try again|available again|retry[- ]after)\b[^\n]*)?[.!]?$/i, ] as const; // These are intentionally evaluated line-by-line and only when the line looks @@ -63,12 +61,9 @@ const PROVIDER_LIMIT_PATTERNS = [ const ANSI_ESCAPE_PATTERN = new RegExp(`${String.fromCodePoint(0x1b)}\\[[0-?]*[ -/]*[@-~]`, "g"); -type OutputStream = "stdout" | "stderr"; - interface OutputLine { raw: string; normalized: string; - stream: OutputStream; } const RESET_PATTERNS = [ @@ -115,13 +110,12 @@ function stripAnsi(text: string): string { } /** - * Split one captured stream into matchable lines with their source stream. + * Split one captured stream into matchable lines. */ -function outputLines(text: string, stream: OutputStream): OutputLine[] { +function outputLines(text: string): OutputLine[] { return text.split(/\r?\n/).map((raw) => ({ raw, normalized: stripAnsi(raw), - stream, })); } @@ -135,11 +129,13 @@ function isSourceOrDiffLine(line: string): boolean { const trimmed = line.trim(); return ( /^(?:diff --git|index\b|---\s|\+\+\+\s|@@\s)/i.test(trimmed) || - /^[+-]{1,3}\s/.test(trimmed) || + /^[+-](?![+-])/.test(trimmed) || /^(?:\/\/|\/\*|\*|#|>|```|~~~)/.test(trimmed) || // `rg -n`, grep, and compiler-style locations: path:line[:column]:content. /^(?:(?:\.?\.?\/|\/)?(?:[^:\s]+\/)+[^:\s]+|[^:\s]+\.[a-z\d]+):\d+(?::\d+)?:/i.test(trimmed) || /^(?:const|let|var|function|class|import|export|return)\b/.test(trimmed) || + /^(?:super|throw\s+new\s+Error|[\w$.]+\.(?:error|warn|log))\s*\(/.test(trimmed) || + /^(?:["'`]).*(?:["'`])[,;)]?$/.test(trimmed) || /\b(?:includes|startsWith|endsWith|\.match|\.test)\s*\(/.test(trimmed) || /=>/.test(trimmed) ); @@ -156,19 +152,15 @@ function isLikelyProviderDiagnostic(line: OutputLine): boolean { return false; } - // Provider CLIs commonly write transport failures to stderr. - if (line.stream === "stderr") { - return true; - } - - // Also accept structured or explicitly diagnostic stdout from SDK-backed - // harnesses (for example AI_RetryError and JSON error responses). + // Accept structured or explicitly diagnostic output from SDK-backed + // harnesses (for example AI_RetryError and JSON error responses). Do not + // inherently trust stderr: Codex uses it for its complete tool transcript. if ( - /^(?:error|fatal|warning)\b/i.test(trimmed) || + /^(?:(?:api|provider)\s+)?(?:error|fatal|warning)\b/i.test(trimmed) || /^(?:AI_RetryError|Too Many Requests)\b/i.test(trimmed) || /^HTTP\s*429\b/i.test(trimmed) || /^\s*[{"[].*(?:rate_limit|quota).*[}\]]\s*$/i.test(trimmed) || - /\b(?:error|exception|failed|failure|last error|provider|response|returned|status|retry)\b/i.test( + /\b(?:last error|provider (?:error|response)|response status|returned (?:an? )?(?:error|status)|request failed|retrying)\b/i.test( trimmed, ) ) { @@ -189,7 +181,7 @@ function isLikelyProviderDiagnostic(line: OutputLine): boolean { function findUsageLimitLine(stdout: string, stderr: string): OutputLine | undefined { // Check stderr first because it is the conventional diagnostic channel, then // inspect stdout for explicit subscription-limit and structured SDK errors. - const lines = [...outputLines(stderr, "stderr"), ...outputLines(stdout, "stdout")]; + const lines = [...outputLines(stderr), ...outputLines(stdout)]; return lines.find((line) => { const normalized = line.normalized.trim(); diff --git a/packages/agent-harness/tests/detect-usage-limit.test.ts b/packages/agent-harness/tests/detect-usage-limit.test.ts index 1eb50af..7163864 100644 --- a/packages/agent-harness/tests/detect-usage-limit.test.ts +++ b/packages/agent-harness/tests/detect-usage-limit.test.ts @@ -77,6 +77,10 @@ describe("detectUsageLimit", () => { expect(detectUsageLimit("", "Error: 429 Too Many Requests").limited).toBe(true); }); + test("detects an API error prefix on stderr", () => { + expect(detectUsageLimit("", "API Error: 429 Too Many Requests").limited).toBe(true); + }); + test("detects an HTTP 429 diagnostic on stdout", () => { expect(detectUsageLimit("HTTP 429: Too Many Requests", "").limited).toBe(true); }); @@ -107,6 +111,32 @@ describe("detectUsageLimit", () => { expect(detectUsageLimit("", source).limited).toBe(false); }); + test("ignores usage-limit phrases in source emitted by Codex tools", () => { + const source = [ + ' super(`Agent usage limit reached${resetHint ? ` (resets ${resetHint})` : ""}`);', + 'throw new Error("Claude usage limit reached");', + '"usage limit reached"', + "The previous run reported Claude usage limit reached but recovered.", + "Agent usage limit reached. Stopping; will retry on the next scheduled run.", + ].join("\n"); + + expect(detectUsageLimit(source, "").limited).toBe(false); + // Codex writes its formatted tool transcript to stderr. + expect(detectUsageLimit("", source).limited).toBe(false); + }); + + test("ignores compact diff additions without whitespace after the marker", () => { + const diff = '+throw new Error("Claude usage limit reached");'; + + expect(detectUsageLimit("", diff).limited).toBe(false); + }); + + test("ignores provider-like prose on stderr", () => { + const source = "The test expects Too Many Requests but received a socket error."; + + expect(detectUsageLimit("", source).limited).toBe(false); + }); + test("ignores a test count containing 429", () => { expect(detectUsageLimit("Ran 429 tests across 47 files.", "").limited).toBe(false); }); diff --git a/packages/code/src/index.ts b/packages/code/src/index.ts index d24018e..01042d8 100755 --- a/packages/code/src/index.ts +++ b/packages/code/src/index.ts @@ -2938,6 +2938,9 @@ async function runEstimation( const usage = detectUsageLimit(stdoutOutput, stderrOutput); if (usage.limited) { + if (usage.matchedLine) { + console.log(` Matched output: ${usage.matchedLine}`); + } reject(new UsageLimitError(usage.resetsAt)); return; } @@ -3422,6 +3425,9 @@ async function runAgentHarness( usage.resetsAt ? ` (resets ${usage.resetsAt})` : "" }`, ); + if (usage.matchedLine) { + console.log(` Matched output: ${usage.matchedLine}`); + } reject(new UsageLimitError(usage.resetsAt)); return; } diff --git a/packages/code/src/webhook-server.ts b/packages/code/src/webhook-server.ts index 37891a9..516e0af 100644 --- a/packages/code/src/webhook-server.ts +++ b/packages/code/src/webhook-server.ts @@ -1336,6 +1336,9 @@ async function runAgentHarnessForReview( maxTurnsReached, }); } else if (usage.limited) { + if (usage.matchedLine) { + console.log(` Matched output: ${usage.matchedLine}`); + } // A usage/rate limit is account-global — surface it so the caller can // pause the queue until reset rather than treating it as a task failure. resolve({