diff options
| author | Adam Malczewski <[email protected]> | 2026-06-25 14:06:23 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-06-25 14:06:23 +0900 |
| commit | 1ff0eac44cd44751af979c51c746a1774c268e8a (patch) | |
| tree | bf1c4563595e5b4c23f63e1d5b0782400be7e025 /packages/tool-shell/src/spawn.ts | |
| parent | 54db4583e66134010375a1fa94256f36034ffdff (diff) | |
| download | dispatch-1ff0eac44cd44751af979c51c746a1774c268e8a.tar.gz dispatch-1ff0eac44cd44751af979c51c746a1774c268e8a.zip | |
feat(ssh): wave 2 — route filesystem/shell tools behind ExecBackend
Wave 2 of transparent SSH support (4 parallel owner-agents on disjoint
tool packages). The tools now resolve an ExecBackend per-call from
ctx.computerId and call backend.spawn / backend.readFile / etc. instead of
node:fs and node:child_process directly — so they are transport-agnostic
(local now; remote over SSH later, transparent to the agent). Still LOCAL-ONLY
this wave (computerId always undefined -> LocalExecBackend, behavior-identical).
- tool-shell: factory takes resolveBackend; execute calls backend.spawn.
spawn.ts DELETED (realSpawn was a verbatim duplicate of exec-backend's
LocalExecBackend.spawn — logic moved to the sanctioned shared package).
manifest dependsOn:[exec-backend]; host.getService at activation.
- tool-read-file: readFile/stat/readdir -> backend.* (pure logic untouched;
ENOENT .code branches kept).
- tool-write-file: exists/stat/writeFile -> backend.* (pure logic untouched).
- tool-edit-file: readFile/writeFile -> backend.* + forward-compatible REMOTE
diagnostics skip (ctx.computerId set -> skip LSP, return empty — plan §6.1;
local path byte-identical to today). LSP lookup stays lazy.
- orchestrator: pre-wired @dispatch/exec-backend dep into the 4 tool
package.jsons + bun install (build/config, my lane) so isolated verify
resolved cleanly; agents added the ../exec-backend tsconfig ref.
Verified: tsc -b EXIT 0, biome clean, 1599 vitest pass (was 1592).
Refs: notes/ssh-support-plan.md (decisions §0.5/§13). No merge or push.
Diffstat (limited to 'packages/tool-shell/src/spawn.ts')
| -rw-r--r-- | packages/tool-shell/src/spawn.ts | 87 |
1 files changed, 0 insertions, 87 deletions
diff --git a/packages/tool-shell/src/spawn.ts b/packages/tool-shell/src/spawn.ts deleted file mode 100644 index 9b1d7e4..0000000 --- a/packages/tool-shell/src/spawn.ts +++ /dev/null @@ -1,87 +0,0 @@ -import { spawn as nodeSpawn } from "node:child_process"; -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 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 = () => { - 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"); - }); - - child.stderr.on("data", (chunk: Buffer) => { - 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) => { - 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", () => { - killGroup(); - settle({ exitCode: 1, timedOut: false, aborted: false }); - }); - }); -}; |
