From 6be8d4798216e8a86986b35622a99a1e27830f90 Mon Sep 17 00:00:00 2001 From: zhi Date: Tue, 21 Apr 2026 08:52:57 +0000 Subject: [PATCH] fix(claude-adapter): commit turn on result event, dont block on process exit Previously dispatchToClaude awaited child.on(close) before yielding the done event. Claude CLIs Bash tool occasionally leaves ssh/bash grandchildren alive (e.g. a GUI app that ignores SIGPIPE on the remote end of a piped ssh command); that kept claude -p alive past end-of-turn, which kept the bridge SSE stream open, which kept OpenClaw from committing the turn to its session jsonl. Switch to emitting done as soon as the terminal result stream-json event arrives. Spawn claude in its own process group (detached:true) and schedule a best-effort SIGTERM/SIGKILL sweep of leaked descendants; temp-file cleanup runs asynchronously on actual process close. Co-Authored-By: Claude Opus 4.7 --- plugin/core/claude/sdk-adapter.ts | 60 ++++++++++++++++++++++--------- 1 file changed, 43 insertions(+), 17 deletions(-) diff --git a/plugin/core/claude/sdk-adapter.ts b/plugin/core/claude/sdk-adapter.ts index c4f4b74..7b4f3af 100644 --- a/plugin/core/claude/sdk-adapter.ts +++ b/plugin/core/claude/sdk-adapter.ts @@ -138,10 +138,14 @@ export async function* dispatchToClaude( } } + // detached:true puts claude in its own process group. Claude's Bash tool + // occasionally leaks shells/ssh that keep claude alive past end-of-turn; when + // that happens we SIGKILL the whole group rather than wait forever. const child = spawn("claude", args, { cwd: workspace, stdio: ["ignore", "pipe", "pipe"], env: { ...process.env }, + detached: true, }); const stderrLines: string[] = []; @@ -157,6 +161,38 @@ export async function* dispatchToClaude( let done = false; let resolveNext: (() => void) | null = null; + let cleanupScheduled = false; + const scheduleCleanup = (): void => { + if (cleanupScheduled) return; + cleanupScheduled = true; + + const killGroup = (sig: NodeJS.Signals): void => { + if (child.pid == null || child.exitCode !== null) return; + try { process.kill(-child.pid, sig); } catch { /* already gone */ } + }; + const termTimer = setTimeout(() => killGroup("SIGTERM"), 3000); + const killTimer = setTimeout(() => killGroup("SIGKILL"), 10000); + + child.once("close", () => { + clearTimeout(termTimer); + clearTimeout(killTimer); + if (mcpConfigPath) { + try { fs.unlinkSync(mcpConfigPath); } catch { /* ignore */ } + } + }); + }; + + const markDone = (): void => { + if (done) return; + done = true; + scheduleCleanup(); + if (resolveNext) { + const r = resolveNext; + resolveNext = null; + r(); + } + }; + rl.on("line", (line: string) => { if (!line.trim()) return; let event: Record; @@ -180,6 +216,11 @@ export async function* dispatchToClaude( if (type === "result") { const sessionId = (event.session_id as string) ?? ""; if (sessionId) capturedSessionId = sessionId; + // `result` is the terminal stream-json event; commit the turn without + // waiting for claude's process tree to fully exit (leaked Bash grandchildren + // can otherwise hold stdout open indefinitely). + markDone(); + return; } if (resolveNext) { @@ -190,12 +231,8 @@ export async function* dispatchToClaude( }); rl.on("close", () => { - done = true; - if (resolveNext) { - const r = resolveNext; - resolveNext = null; - r(); - } + // Fallback: claude exited without emitting a result event. + markDone(); }); while (true) { @@ -213,17 +250,6 @@ export async function* dispatchToClaude( yield events.shift()!; } - await new Promise((resolve) => { - child.on("close", resolve); - if (child.exitCode !== null) resolve(); - }); - - // Clean up temp files - if (mcpConfigPath) { - try { fs.unlinkSync(mcpConfigPath); } catch { /* ignore */ } - // tool defs file path is embedded in the config — leave it for now - } - if (capturedSessionId) { yield { type: "done", sessionId: capturedSessionId }; } else {