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 <noreply@anthropic.com>
This commit is contained in:
zhi
2026-04-21 08:52:57 +00:00
parent e73a7ea049
commit 6be8d47982

View File

@@ -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<string, unknown>;
@@ -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<void>((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 {