From 2f34e5270dc18b3aa5dce6e4096af98f3ba3872a Mon Sep 17 00:00:00 2001 From: vladimirkunakov Date: Sat, 30 May 2026 20:30:01 +0700 Subject: [PATCH] fix(claude-cli): await completion to fix empty analysis on ingest MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit streamClaudeCodeCli() was resolving its Promise immediately after invoke('claude_cli_spawn') returned — before the claude subprocess emitted any tokens. The ingest pipeline checked the analysis buffer while the CLI was still running, found it empty, and wrote a stub with '(Analysis not available)'. Same race that was fixed for the Codex CLI transport in #238. Fix: - Add a completion Promise that resolves only inside finishWith(), then await completion after spawn - Track emittedToken flag; surface a clear error when CLI exits 0 but produces no content, instead of silently writing a stub - Guard pre-aborted and abort-during-spawn races Fixes #292 --- src/lib/claude-cli-transport.ts | 49 +++++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/src/lib/claude-cli-transport.ts b/src/lib/claude-cli-transport.ts index 2c0c9fe4e..c1c80188d 100644 --- a/src/lib/claude-cli-transport.ts +++ b/src/lib/claude-cli-transport.ts @@ -143,6 +143,16 @@ export async function streamClaudeCodeCli( let unlistenData: UnlistenFn | undefined let unlistenDone: UnlistenFn | undefined let finished = false + let aborted = signal?.aborted ?? false + // Track whether any assistant text was received — used to detect the + // silent-exit case where the CLI exits 0 but emits no content. + let emittedToken = false + // Completion promise: resolves when finishWith() fires so the caller + // awaits the full round-trip rather than returning after spawn. + let resolveCompletion: () => void = () => {} + const completion = new Promise((resolve) => { + resolveCompletion = resolve + }) // Diagnostic capture for failure paths. The Rust side emits every // stdout line; lines the parser doesn't recognize (non-JSON, @@ -173,15 +183,21 @@ export async function streamClaudeCodeCli( finished = true cleanup() cb() + resolveCompletion() } const abortListener = () => { + aborted = true void invoke("claude_cli_kill", { streamId }).catch(() => { // Kill is best-effort; if the process already exited, the Rust // side returns Ok and the done handler fires normally. }) finishWith(onDone) } + if (aborted) { + finishWith(onDone) + return + } signal?.addEventListener("abort", abortListener) try { @@ -189,6 +205,7 @@ export async function streamClaudeCodeCli( unlistenData = await listen(`claude-cli:${streamId}`, (event) => { const token = parse(event.payload) if (token !== null) { + emittedToken = true onToken(token) } else { // Parser didn't recognize this line. Stash it in case the @@ -198,6 +215,10 @@ export async function streamClaudeCodeCli( captureUnparsed(event.payload) } }) + if (aborted || finished) { + cleanup() + return + } unlistenDone = await listen<{ code: number | null; stderr: string }>( `claude-cli:${streamId}:done`, @@ -210,11 +231,27 @@ export async function streamClaudeCodeCli( new Error(buildExitError(code, stderr, unparsedLines.join("\n"))), ), ) + } else if (!emittedToken) { + // CLI exited successfully but produced no assistant text. + // Surface this as an explicit error so the ingest pipeline + // retries rather than silently writing an empty stub page. + const details = stderr || unparsedLines.join("\n").trim() + finishWith(() => + onError(new Error( + details + ? `Claude Code CLI completed but returned no content:\n${details}` + : "Claude Code CLI completed but returned no content. Try running `claude -p` in a terminal to inspect the output, or switch to the Anthropic API in Settings.", + )), + ) } else { finishWith(onDone) } }, ) + if (aborted || finished) { + cleanup() + return + } const payload: SpawnPayload = { streamId, @@ -222,6 +259,18 @@ export async function streamClaudeCodeCli( messages, } await invoke("claude_cli_spawn", payload) + if (aborted || signal?.aborted) { + aborted = true + await invoke("claude_cli_kill", { streamId }).catch(() => {}) + finishWith(onDone) + return + } + // Wait for the done event to be processed before returning. + // Without this await the caller sees an empty analysis buffer + // because streamClaudeCodeCli() resolves immediately after spawn + // while the CLI subprocess is still running. (Same race that was + // fixed for the Codex CLI transport in #238.) + await completion } catch (err) { finishWith(() => { const message = err instanceof Error ? err.message : String(err)