From 56793d4a81e55b8b7f89873b3be7e5689936aca0 Mon Sep 17 00:00:00 2001 From: Colin McDonnell Date: Thu, 14 May 2026 01:26:10 +0000 Subject: [PATCH] claude: prefer non-JSON stdout over NDJSON tail in exit-1 fallback (#643) (#726) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude CLI under CLAUDE_CODE_OAUTH_TOKEN exits 1 without setting `is_error` when the OAuth subscription's quota is exhausted. The existing fallback chain (`lastResultError || stderr || tailLines(stdout)`) had nothing structured to grab and dumped ~2KB of `system/init` NDJSON into the progress comment, hiding the actionable quota notice the CLI had already printed as plain text. Capture non-JSON stdout lines into a 20-line ring buffer (mirroring the existing `recentStderr` pattern) and prefer it over the raw NDJSON tail. Generic โ€” no regex on bubble text โ€” so any human-readable line the CLI emits surfaces instead of the event stream. Also adds a `failure:claude-oauth-quota` bucket to `analyze-logs.ts`, ordered before the SIGTERM check so the NDJSON tail's `cancelled` / `cancel_url` substrings (from learnings content) stop shadowing it. --- agents/claude.ts | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/agents/claude.ts b/agents/claude.ts index 10bc1cb..1cc0087 100644 --- a/agents/claude.ts +++ b/agents/claude.ts @@ -549,6 +549,13 @@ export async function runClaude(params: RunParams): Promise { }; const recentStderr: string[] = []; + // ring buffer of recent non-JSON stdout lines. Claude CLI prints + // human-readable TTY chrome (status bubbles, quota notices, etc.) + // alongside the NDJSON event stream. when the CLI exits non-zero without + // emitting a structured error event, these lines are the only actionable + // signal โ€” preferring them over the NDJSON tail keeps progress comments + // readable. issue #643. + const recentNonJsonStdout: string[] = []; let lastProviderError: string | null = null; @@ -594,6 +601,8 @@ export async function runClaude(params: RunParams): Promise { event = JSON.parse(trimmed) as ClaudeEvent; } catch { log.debug(`ยป non-JSON stdout line: ${trimmed.substring(0, 200)}`); + recentNonJsonStdout.push(trimmed); + if (recentNonJsonStdout.length > MAX_STDERR_LINES) recentNonJsonStdout.shift(); continue; } @@ -695,9 +704,16 @@ export async function runClaude(params: RunParams): Promise { const stdoutSnapshot = output.toString(); const stderrSnapshot = recentStderr.join("\n"); const truncatedStdout = stdoutSnapshot ? tailLines(stdoutSnapshot, 2048) : ""; + // prefer non-JSON stdout (human-readable TTY chrome the CLI prints, + // including status bubbles and quota notices) over the raw NDJSON + // tail. when the CLI exits 1 without emitting `is_error` (issue #643), + // the NDJSON fallback would otherwise dump 2KB of `system/init` events + // into the progress comment with no mention of the actual cause. + const nonJsonStdoutSnapshot = recentNonJsonStdout.join("\n"); const errorMessage = lastResultError || stderrSnapshot || + nonJsonStdoutSnapshot || truncatedStdout || `unknown error - no output from Claude CLI${errorContext}`; log.error(