summaryrefslogtreecommitdiffhomepage
path: root/packages/tool-shell/src
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-06-24 14:10:03 +0900
committerAdam Malczewski <[email protected]>2026-06-24 14:10:03 +0900
commitdabcbc79831052effc6ce990021feee07d661f7e (patch)
tree3e74e16f36d6a675abe676f0d04ca169f65f0a71 /packages/tool-shell/src
parentb58fb8373a1f7311cead23aa9a4d1fcd6927634f (diff)
downloaddispatch-dabcbc79831052effc6ce990021feee07d661f7e.tar.gz
dispatch-dabcbc79831052effc6ce990021feee07d661f7e.zip
fix(kernel+tool-shell): abort hanging tool calls without bricking the conversation
kernel: executeToolCall now races tool.execute against the abort signal via Promise.race; on abort resolves (not rejects) with an "Aborted" result so the step completes normally → finishReason "aborted" → turn seals cleanly (done event) → finally clears activeTurns → conversation freed, next message accepted. run-turn strips tool-call chunks from the assistant message on abort (keeps text/thinking) and omits tool-result messages to avoid persisting dangling tool calls that would 400 the provider next turn. tool-shell: realSpawn spawns detached (own process group); on abort AND timeout kills the entire group (process.kill(-pgid, SIGKILL)) and resolves immediately — no child.on("close") dependency, so a grandchild holding the pipes can't stall the spawn promise or leak. Also: ORCHESTRATOR.md migrated to dispatch CLI summon mechanism; .skills summary; bin/sync-env PATH injection; frontend handoff docs. 1453 vitest pass · tsc -b EXIT 0 · biome clean.
Diffstat (limited to 'packages/tool-shell/src')
-rw-r--r--packages/tool-shell/src/shell.test.ts156
-rw-r--r--packages/tool-shell/src/shell.ts5
-rw-r--r--packages/tool-shell/src/spawn.ts71
3 files changed, 203 insertions, 29 deletions
diff --git a/packages/tool-shell/src/shell.test.ts b/packages/tool-shell/src/shell.test.ts
index a70693b..07e0af4 100644
--- a/packages/tool-shell/src/shell.test.ts
+++ b/packages/tool-shell/src/shell.test.ts
@@ -22,8 +22,12 @@ function stubCtx(overrides?: Partial<ToolExecuteContext>): ToolExecuteContext {
};
}
-function fakeSpawn(result: { exitCode: number | null; timedOut: boolean }): SpawnShell {
- return async () => result;
+function fakeSpawn(result: {
+ exitCode: number | null;
+ timedOut: boolean;
+ aborted?: boolean;
+}): SpawnShell {
+ return async () => ({ aborted: false, ...result });
}
describe("validateArgs", () => {
@@ -201,7 +205,7 @@ describe("createRunShellTool", () => {
workdir: "/tmp",
spawn: async (_params) => {
_params.onOutput("hello\n", "stdout");
- return { exitCode: 0, timedOut: false };
+ return { exitCode: 0, timedOut: false, aborted: false };
},
});
const result = await tool.execute({ command: "echo hello" }, stubCtx());
@@ -214,7 +218,7 @@ describe("createRunShellTool", () => {
workdir: "/tmp",
spawn: async (_params) => {
_params.onOutput("error output\n", "stderr");
- return { exitCode: 1, timedOut: false };
+ return { exitCode: 1, timedOut: false, aborted: false };
},
});
const result = await tool.execute({ command: "false" }, stubCtx());
@@ -227,7 +231,7 @@ describe("createRunShellTool", () => {
workdir: "/tmp",
spawn: async (_params) => {
_params.onOutput("partial\n", "stdout");
- return { exitCode: null, timedOut: true };
+ return { exitCode: null, timedOut: true, aborted: false };
},
});
const result = await tool.execute({ command: "sleep 999" }, stubCtx());
@@ -242,7 +246,7 @@ describe("createRunShellTool", () => {
outputCap: cap,
spawn: async (_params) => {
_params.onOutput("a".repeat(200), "stdout");
- return { exitCode: 0, timedOut: false };
+ return { exitCode: 0, timedOut: false, aborted: false };
},
});
const result = await tool.execute({ command: "gen" }, stubCtx());
@@ -258,7 +262,7 @@ describe("createRunShellTool", () => {
params.onOutput("line1\n", "stdout");
params.onOutput("err1\n", "stderr");
params.onOutput("line2\n", "stdout");
- return { exitCode: 0, timedOut: false };
+ return { exitCode: 0, timedOut: false, aborted: false };
},
});
await tool.execute(
@@ -280,7 +284,7 @@ describe("createRunShellTool", () => {
workdir: "/baked",
spawn: async (params) => {
receivedCwd = params.cwd;
- return { exitCode: 0, timedOut: false };
+ return { exitCode: 0, timedOut: false, aborted: false };
},
});
await tool.execute({ command: "pwd" }, stubCtx({ cwd: "/custom" }));
@@ -293,7 +297,7 @@ describe("createRunShellTool", () => {
workdir: "/baked",
spawn: async (params) => {
receivedCwd = params.cwd;
- return { exitCode: 0, timedOut: false };
+ return { exitCode: 0, timedOut: false, aborted: false };
},
});
await tool.execute({ command: "pwd" }, stubCtx());
@@ -317,7 +321,7 @@ describe("createRunShellTool", () => {
controller.abort();
const tool = createRunShellTool({
workdir: "/tmp",
- spawn: async () => ({ exitCode: 0, timedOut: false }),
+ spawn: async () => ({ exitCode: 0, timedOut: false, aborted: false }),
});
const result = await tool.execute({ command: "test" }, stubCtx({ signal: controller.signal }));
expect(result.isError).toBe(true);
@@ -329,7 +333,7 @@ describe("createRunShellTool", () => {
workdir: "/tmp",
spawn: async (params) => {
receivedTimeout = params.timeout;
- return { exitCode: 0, timedOut: false };
+ return { exitCode: 0, timedOut: false, aborted: false };
},
});
await tool.execute({ command: "test", timeout: 5000 }, stubCtx());
@@ -355,3 +359,133 @@ describe("createRunShellTool (integration)", () => {
expect(streamed).toContain("hello-from-shell");
});
});
+
+describe("realSpawn — process-group kill on abort/timeout", () => {
+ it("aborts a command with a grandchild holding the pipes and resolves immediately", async () => {
+ const { realSpawn } = await import("./spawn.js");
+ const controller = new AbortController();
+
+ // "sleep 30 & wait" spawns a grandchild (sleep) that inherits the stdio
+ // pipes. Killing just the sh parent does NOT close the pipes → close never
+ // fires. With detached:true + process-group kill, the grandchild dies too.
+ const promise = realSpawn({
+ command: "sleep 30 & wait",
+ cwd: "/tmp",
+ signal: controller.signal,
+ timeout: 60_000,
+ onOutput: () => {},
+ });
+
+ // Give the shell time to actually spawn the grandchild.
+ await new Promise((r) => setTimeout(r, 500));
+
+ controller.abort();
+
+ // Must resolve promptly (not wait 30s for the grandchild's sleep).
+ const result = await promise;
+ expect(result.aborted).toBe(true);
+ expect(result.timedOut).toBe(false);
+
+ // Give the OS a moment to reap the killed processes.
+ await new Promise((r) => setTimeout(r, 200));
+
+ // The grandchild sleep process should be gone. Check via pgrep.
+ const { execSync } = await import("node:child_process");
+ let sleeping: string[];
+ try {
+ sleeping = execSync("pgrep -f 'sleep 30'", { encoding: "utf-8" }).trim().split("\n");
+ } catch {
+ // pgrep returns non-zero when no processes match → all gone.
+ sleeping = [];
+ }
+ expect(sleeping.length).toBe(0);
+ });
+
+ it("times out a command with a grandchild holding the pipes and resolves promptly", async () => {
+ const { realSpawn } = await import("./spawn.js");
+ const controller = new AbortController();
+
+ const promise = realSpawn({
+ command: "sleep 30 & wait",
+ cwd: "/tmp",
+ signal: controller.signal,
+ timeout: 500,
+ onOutput: () => {},
+ });
+
+ // Must resolve within a short window (not 30s).
+ const start = Date.now();
+ const result = await promise;
+ const elapsed = Date.now() - start;
+
+ expect(result.timedOut).toBe(true);
+ expect(result.aborted).toBe(false);
+ // Should resolve shortly after the 500ms timeout, well under 30s.
+ expect(elapsed).toBeLessThan(10_000);
+
+ // Grandchild should be dead.
+ await new Promise((r) => setTimeout(r, 200));
+ const { execSync } = await import("node:child_process");
+ let sleeping: string[];
+ try {
+ sleeping = execSync("pgrep -f 'sleep 30'", { encoding: "utf-8" }).trim().split("\n");
+ } catch {
+ sleeping = [];
+ }
+ expect(sleeping.length).toBe(0);
+ });
+
+ it("captures stdout on normal completion (regression guard)", async () => {
+ const { realSpawn } = await import("./spawn.js");
+ const controller = new AbortController();
+ let output = "";
+
+ const result = await realSpawn({
+ command: "echo hi",
+ cwd: "/tmp",
+ signal: controller.signal,
+ timeout: 5_000,
+ onOutput: (data) => {
+ output += data;
+ },
+ });
+
+ expect(result.aborted).toBe(false);
+ expect(result.timedOut).toBe(false);
+ expect(result.exitCode).toBe(0);
+ expect(output).toContain("hi");
+ });
+
+ it("aborts a simple single-process command and resolves with aborted: true", async () => {
+ const { realSpawn } = await import("./spawn.js");
+ const controller = new AbortController();
+
+ const promise = realSpawn({
+ command: "sleep 30",
+ cwd: "/tmp",
+ signal: controller.signal,
+ timeout: 60_000,
+ onOutput: () => {},
+ });
+
+ // Let the sleep actually start.
+ await new Promise((r) => setTimeout(r, 300));
+
+ controller.abort();
+
+ const result = await promise;
+ expect(result.aborted).toBe(true);
+ expect(result.timedOut).toBe(false);
+
+ // The sleep process should be gone.
+ await new Promise((r) => setTimeout(r, 200));
+ const { execSync } = await import("node:child_process");
+ let sleeping: string[];
+ try {
+ sleeping = execSync("pgrep -f 'sleep 30'", { encoding: "utf-8" }).trim().split("\n");
+ } catch {
+ sleeping = [];
+ }
+ expect(sleeping.length).toBe(0);
+ });
+});
diff --git a/packages/tool-shell/src/shell.ts b/packages/tool-shell/src/shell.ts
index d96d73e..cc76bca 100644
--- a/packages/tool-shell/src/shell.ts
+++ b/packages/tool-shell/src/shell.ts
@@ -12,6 +12,7 @@ export interface ValidatedArgs {
export interface SpawnResult {
readonly exitCode: number | null;
readonly timedOut: boolean;
+ readonly aborted: boolean;
}
export type SpawnShell = (params: {
@@ -139,7 +140,6 @@ export function createRunShellTool(deps: {
};
let spawnResult: SpawnResult;
- let aborted = false;
try {
spawnResult = await deps.spawn({
@@ -154,7 +154,6 @@ export function createRunShellTool(deps: {
});
} catch (err: unknown) {
if (ctx.signal.aborted) {
- aborted = true;
return buildResult({
exitCode: null,
timedOut: false,
@@ -172,7 +171,7 @@ export function createRunShellTool(deps: {
return buildResult({
exitCode: spawnResult.exitCode,
timedOut: spawnResult.timedOut,
- aborted,
+ aborted: spawnResult.aborted,
output,
cap,
});
diff --git a/packages/tool-shell/src/spawn.ts b/packages/tool-shell/src/spawn.ts
index 9025c26..9b1d7e4 100644
--- a/packages/tool-shell/src/spawn.ts
+++ b/packages/tool-shell/src/spawn.ts
@@ -3,24 +3,66 @@ import type { SpawnResult, SpawnShell } from "./shell.js";
export const realSpawn: SpawnShell = (params): Promise<SpawnResult> => {
return new Promise<SpawnResult>((resolve) => {
+ // detached: true puts the child in its own process group (pgid = child.pid).
+ // This lets us kill the entire group (child + any grandchildren that inherit
+ // the pipes) via process.kill(-pgid, "SIGKILL") on abort/timeout, so a
+ // backgrounded grandchild can't keep the stdio pipes open and stall the
+ // promise on child.on("close").
const child = nodeSpawn("sh", ["-c", params.command], {
cwd: params.cwd,
stdio: ["ignore", "pipe", "pipe"],
+ detached: true,
});
+ let settled = false;
let timedOut = false;
- let killed = false;
- const timer = setTimeout(() => {
- timedOut = true;
- child.kill("SIGKILL");
- }, params.timeout);
+ let timer: ReturnType<typeof setTimeout> | undefined;
+
+ /** Kill the entire child process group (best-effort — group may be gone). */
+ const killGroup = () => {
+ if (child.pid !== undefined) {
+ try {
+ process.kill(-child.pid, "SIGKILL");
+ } catch {
+ // Process group may already be gone — ignore.
+ }
+ }
+ };
+
+ /** Remove the abort listener and clear the timeout timer (no leaks). */
+ const cleanup = () => {
+ if (timer !== undefined) {
+ clearTimeout(timer);
+ timer = undefined;
+ }
+ params.signal.removeEventListener("abort", onAbort);
+ };
+
+ /** Resolve once, then clean up so listeners/timers never leak. */
+ const settle = (result: SpawnResult) => {
+ if (settled) return;
+ settled = true;
+ cleanup();
+ resolve(result);
+ };
const onAbort = () => {
- killed = true;
- child.kill("SIGKILL");
+ if (settled) return;
+ killGroup();
+ // Resolve immediately — do NOT wait for child.on("close"), which may
+ // never fire if a grandchild holds the pipes open.
+ settle({ exitCode: null, timedOut: false, aborted: true });
};
params.signal.addEventListener("abort", onAbort, { once: true });
+ timer = setTimeout(() => {
+ if (settled) return;
+ timedOut = true;
+ killGroup();
+ // Resolve immediately — same reasoning as abort.
+ settle({ exitCode: null, timedOut: true, aborted: false });
+ }, params.timeout);
+
child.stdout.on("data", (chunk: Buffer) => {
params.onOutput(chunk.toString(), "stdout");
});
@@ -29,18 +71,17 @@ export const realSpawn: SpawnShell = (params): Promise<SpawnResult> => {
params.onOutput(chunk.toString(), "stderr");
});
+ // Normal-completion path: wait for "close" so all stdout/stderr is captured.
+ // If abort/timeout already settled, this is a no-op (settled === true).
child.on("close", (code) => {
- clearTimeout(timer);
- params.signal.removeEventListener("abort", onAbort);
- resolve({ exitCode: code, timedOut });
+ settle({ exitCode: code, timedOut, aborted: false });
});
+ // Spawn error (e.g. bad cwd, sh not found). Kill the group just in case
+ // and resolve — never leave the promise pending.
child.on("error", () => {
- clearTimeout(timer);
- params.signal.removeEventListener("abort", onAbort);
- if (!killed && !timedOut) {
- resolve({ exitCode: 1, timedOut: false });
- }
+ killGroup();
+ settle({ exitCode: 1, timedOut: false, aborted: false });
});
});
};