Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 22 additions & 2 deletions src/adapters/coding-agent/turn.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { spawn as nodeSpawn, type ChildProcess, type SpawnOptions } from "node:child_process";
import { execFileSync, spawn as nodeSpawn, type ChildProcess, type SpawnOptions } from "node:child_process";
import type { AdapterEvent, OcxParsedRequest, OcxProviderConfig } from "../../types";
import { commandInvocation } from "../../lib/win-exec";
import type { IncomingMeta } from "../base";
Expand All @@ -8,6 +8,9 @@ import { resolveCodingAgentBinary, resolveProfileByBaseUrl, type CodingAgentProv
/** Injectable spawn for tests; production uses node:child_process. */
export type SpawnFn = (command: string, args: readonly string[], options: SpawnOptions) => ChildProcess;

/** Injectable Windows process-tree terminator; production uses taskkill /T /F. */
export type KillWindowsProcessTreeFn = (pid: number) => void;

/** Per-turn injectables: spawn/which seams for tests plus wall-clock ceilings for timeout, kill grace, and bounded reap. */
export interface CodingAgentDeps {
spawn?: SpawnFn;
Expand All @@ -20,13 +23,23 @@ export interface CodingAgentDeps {
reapTimeoutMs?: number;
/** Test seam for Windows command-shim invocation. */
platform?: NodeJS.Platform;
/** Test seam for terminating a Windows CLI and all descendants. */
killWindowsProcessTree?: KillWindowsProcessTreeFn;
}

const DEFAULT_TIMEOUT_MS = 300_000;
const DEFAULT_KILL_GRACE_MS = 2_000;
/** Bound captured stderr so an error message can never carry an unbounded (or secret) payload. */
const MAX_STDERR_BYTES = 8 * 1024;

function killWindowsProcessTree(pid: number): void {
const taskkill = `${process.env.SystemRoot ?? "C:\\Windows"}\\System32\\taskkill.exe`;
execFileSync(taskkill, ["/PID", String(pid), "/T", "/F"], {
stdio: "pipe",
windowsHide: true,
});
}

/** Env keys a CLI needs to run; everything else is dropped so the child env is scoped and deterministic. */
const INHERITED_ENV_KEYS = [
"PATH", "HOME", "USERPROFILE", "LANG", "LC_ALL", "LC_CTYPE", "TMPDIR", "TEMP", "TMP",
Expand Down Expand Up @@ -93,6 +106,7 @@ export async function runCodingAgentTurn(input: CodingAgentTurnInput): Promise<v
const timeoutMs = deps.timeoutMs ?? DEFAULT_TIMEOUT_MS;
const killGraceMs = deps.killGraceMs ?? DEFAULT_KILL_GRACE_MS;
const reapTimeoutMs = deps.reapTimeoutMs ?? (killGraceMs * 2 + 250);
const platform = deps.platform ?? process.platform;

if (incoming.abortSignal?.aborted) {
emit({ type: "error", message: "Coding-agent turn was aborted before start." });
Expand Down Expand Up @@ -140,7 +154,7 @@ export async function runCodingAgentTurn(input: CodingAgentTurnInput): Promise<v

const args = buildArgs(profile, parsed, provider);
const env = buildEnv(profile, apiKey);
const invocation = commandInvocation(binary, args, deps.platform ?? process.platform, { env });
const invocation = commandInvocation(binary, args, platform, { env });

let child: ChildProcess;
try {
Expand Down Expand Up @@ -197,6 +211,12 @@ export async function runCodingAgentTurn(input: CodingAgentTurnInput): Promise<v
const kill = (): void => {
if (killed || child.killed) return;
killed = true;
if (platform === "win32" && child.pid !== undefined) {
try {
(deps.killWindowsProcessTree ?? killWindowsProcessTree)(child.pid);
return;
} catch { /* fall back to terminating the direct child */ }
}
try { child.kill("SIGTERM"); } catch { /* already gone */ }
killTimer = setTimeout(() => {
try { child.kill("SIGKILL"); } catch { /* already gone */ }
Expand Down
36 changes: 36 additions & 0 deletions tests/providers/codebuddy-adapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ const enc = new TextEncoder();
beforeEach(() => clearCodeBuddyBinaryCache());

interface FakeChild extends EventEmitter {
pid?: number;
stdout: Readable;
stderr: Readable;
stdin: Writable;
Expand Down Expand Up @@ -614,6 +615,41 @@ describe("codebuddy runTurn streams a headless turn", () => {
expect(events.some(e => e.type === "done")).toBe(false);
});

test("a Windows abort terminates the cmd shim process tree", async () => {
const controller = new AbortController();
const stdoutStream = new Readable({
read() { setTimeout(() => controller.abort(), 5); },
});
const child = new EventEmitter() as FakeChild;
child.pid = 4242;
child.stdout = stdoutStream;
child.stderr = Readable.from([]);
child.written = [];
child.stdin = new Writable({ write(_c, _e, cb) { cb(); } });
child.killed = false;
child.exitCode = null;
const directSignals: string[] = [];
child.kill = signal => { directSignals.push(signal ?? "SIGTERM"); return true; };
const killedTrees: number[] = [];

const adapter = createCodeBuddyAdapter(provider(), {
platform: "win32",
spawn: () => child as unknown as ChildProcess,
which: () => "C:\\npm\\codebuddy.cmd",
killWindowsProcessTree: pid => {
killedTrees.push(pid);
child.exitCode = 1;
child.emit("close", 1);
},
killGraceMs: 20,
});
const events = await run(adapter, parsed(), incoming(controller.signal));

expect(killedTrees).toEqual([4242]);
expect(directSignals).toEqual([]);
expect(events).toContainEqual(expect.objectContaining({ type: "error", retryable: false }));
});

test("a timeout destroys a stalled stdout stream and returns even when close never arrives", async () => {
type TimingPhase = "entry" | "spawn" | "sigterm" | "stdout_close" | "stdout_error" | "timeout" | "return";
type TimingClassification =
Expand Down
Loading