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:
@@ -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, {
|
const child = spawn("claude", args, {
|
||||||
cwd: workspace,
|
cwd: workspace,
|
||||||
stdio: ["ignore", "pipe", "pipe"],
|
stdio: ["ignore", "pipe", "pipe"],
|
||||||
env: { ...process.env },
|
env: { ...process.env },
|
||||||
|
detached: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
const stderrLines: string[] = [];
|
const stderrLines: string[] = [];
|
||||||
@@ -157,6 +161,38 @@ export async function* dispatchToClaude(
|
|||||||
let done = false;
|
let done = false;
|
||||||
let resolveNext: (() => void) | null = null;
|
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) => {
|
rl.on("line", (line: string) => {
|
||||||
if (!line.trim()) return;
|
if (!line.trim()) return;
|
||||||
let event: Record<string, unknown>;
|
let event: Record<string, unknown>;
|
||||||
@@ -180,6 +216,11 @@ export async function* dispatchToClaude(
|
|||||||
if (type === "result") {
|
if (type === "result") {
|
||||||
const sessionId = (event.session_id as string) ?? "";
|
const sessionId = (event.session_id as string) ?? "";
|
||||||
if (sessionId) capturedSessionId = sessionId;
|
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) {
|
if (resolveNext) {
|
||||||
@@ -190,12 +231,8 @@ export async function* dispatchToClaude(
|
|||||||
});
|
});
|
||||||
|
|
||||||
rl.on("close", () => {
|
rl.on("close", () => {
|
||||||
done = true;
|
// Fallback: claude exited without emitting a result event.
|
||||||
if (resolveNext) {
|
markDone();
|
||||||
const r = resolveNext;
|
|
||||||
resolveNext = null;
|
|
||||||
r();
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
while (true) {
|
while (true) {
|
||||||
@@ -213,17 +250,6 @@ export async function* dispatchToClaude(
|
|||||||
yield events.shift()!;
|
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) {
|
if (capturedSessionId) {
|
||||||
yield { type: "done", sessionId: capturedSessionId };
|
yield { type: "done", sessionId: capturedSessionId };
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
Reference in New Issue
Block a user