Skip to content
Open
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
5 changes: 5 additions & 0 deletions packages/agent/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,11 @@
- Refresh `abortServerSideFallback` from the host's next-turn snapshot so an active agent run cannot carry a prior
model's server-fallback policy into the next provider request
([#796](https://github.com/code-yeongyu/senpi/pull/796)).
- The Node harness Windows process-tree kill no longer raises an uncaught `spawn taskkill ENOENT`. It tries every
absolute `System32` / `Sysnative` `taskkill.exe` before the PATH-resolved name, runs synchronously so a teardown
that exits in the same tick still terminates its children, and degrades to killing the direct child only when no
launcher starts at all ([#812](https://github.com/code-yeongyu/senpi/issues/812),
[#807](https://github.com/code-yeongyu/senpi/pull/807)).

### Removed

Expand Down
26 changes: 26 additions & 0 deletions packages/agent/src/changes.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,32 @@
- LOW: `agent-loop.ts` unknown-tool preparation branch.
- LOW: `agent.ts` loop-config forwarding.

## 2026-08-11 - Windows process-tree kill survives an unresolvable taskkill

### What changed and why

- `harness/env/nodejs.ts`: the Windows branch of the harness `killProcessTree` moved into the exported
`killWindowsProcessTree`, which walks the ordered launcher list from the new `windowsTaskkillCandidates` export
(every existing absolute `System32` / `Sysnative` `taskkill.exe`, then the bare PATH-resolved name), runs each with
`spawnSync` under a 5s timeout, and only degrades to `process.kill(pid)` when no launcher starts at all.
- `spawn("taskkill", ...)` resolves through PATH and reports a failed lookup asynchronously on the child's `error`
event, so the surrounding `try`/`catch` never observed it. Without a listener Node re-emits ENOENT as an uncaught
exception, killing the host process instead of the target tree whenever PATH had lost `%SystemRoot%\System32`.
- The kill is synchronous so a caller that tears down and exits in the same tick still terminates its children;
`spawnSync` also reports a failed lookup on its returned `error` field instead of emitting it. The direct
`process.kill` stays a last resort because `TerminateProcess` leaves descendants orphaned.
- The same fix lands in `packages/coding-agent/src/utils/shell.ts`; the two harnesses keep independent copies of this
helper as they already do for `getShellEnv` and bash resolution.

### Why the extension system could not handle this

- The kill runs inside the Node harness's own process supervision, below every extension hook.

### Expected merge conflict zones on next upstream sync

- LOW: the Windows branch of `killProcessTree` and the `node:child_process` / `node:fs` import lines in
`harness/env/nodejs.ts`.

## 2026-08-10 - Refresh server-fallback policy between tool turns

### What changed and why
Expand Down
88 changes: 77 additions & 11 deletions packages/agent/src/harness/env/nodejs.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { type ChildProcess, spawn } from "node:child_process";
import { type ChildProcess, spawn, spawnSync } from "node:child_process";
import { randomUUID } from "node:crypto";
import { constants, createReadStream } from "node:fs";
import { constants, createReadStream, existsSync } from "node:fs";
import {
access,
appendFile,
Expand Down Expand Up @@ -247,17 +247,83 @@ function getShellEnv(
};
}

/**
* Ordered `taskkill` launchers to try, most reliable first.
*
* `spawn("taskkill", ...)` relies on a PATH lookup, so any session whose PATH lost
* `%SystemRoot%\System32` (a POSIX-style PATH inherited from a Git Bash/MSYS launcher,
* a truncated user PATH, a locked-down service account) fails to resolve it. A broken PATH
* must not cost us the process-tree kill, so every absolute System32 location that actually
* exists is tried before the bare PATH-resolved name.
*/
export function windowsTaskkillCandidates(env: NodeJS.ProcessEnv = process.env): string[] {
// A bare `SystemDrive` is drive-relative ("C:"), so anchor it before joining.
const systemDrive = env.SystemDrive ? `${env.SystemDrive}\\` : undefined;
const roots = [env.SystemRoot, env.SYSTEMROOT, env.windir, systemDrive && join(systemDrive, "Windows")];
const candidates: string[] = [];
for (const root of roots) {
if (!root) continue;
// Sysnative reaches the real 64-bit System32 from a 32-bit process, where System32
// is redirected to SysWOW64.
for (const systemDir of ["System32", "Sysnative"]) {
const absolute = join(root, systemDir, "taskkill.exe");
if (!candidates.includes(absolute) && existsSync(absolute)) candidates.push(absolute);
}
}
candidates.push("taskkill.exe");
return candidates;
}

function killProcessDirectly(pid: number): void {
try {
process.kill(pid);
} catch {
// Process already dead.
}
}

/** Upper bound on how long a teardown may block waiting for `taskkill` to finish. */
const TASKKILL_TIMEOUT_MS = 5_000;

function taskkillHandledTree(pid: number, taskkillPath: string): boolean {
try {
const result = spawnSync(taskkillPath, ["/F", "/T", "/PID", String(pid)], {
stdio: "ignore",
windowsHide: true,
timeout: TASKKILL_TIMEOUT_MS,
});
// `error` means the launcher never started (ENOENT, EACCES); a null status means
// the timeout killed it. Any real taskkill exit code counts as handled.
return result.error === undefined && result.status !== null;
} catch {
return false;
}
}

/**
* Kill a process and all its children on Windows via `taskkill /T`.
*
* Synchronous on purpose. A caller that tears down and exits in the same tick would never
* observe an asynchronous killer's `error` event, leaving the target alive. `spawnSync`
* also reports a failed executable lookup on its returned `error` field instead of
* emitting it, so a PATH without `%SystemRoot%\System32` can no longer surface as an
* uncaught `spawn taskkill ENOENT`.
*
* The direct `process.kill` at the end is a degraded last resort reached only when no
* `taskkill.exe` can be launched at all. It maps to `TerminateProcess`, which does not
* touch descendants; nothing in-process can walk a Windows process tree without an
* external tool, so this still beats leaving the whole tree running.
*/
export function killWindowsProcessTree(pid: number, taskkillPaths = windowsTaskkillCandidates()): void {
for (const taskkillPath of taskkillPaths) {
if (taskkillHandledTree(pid, taskkillPath)) return;
}
killProcessDirectly(pid);
}

function killProcessTree(pid: number): void {
if (process.platform === "win32") {
try {
spawn("taskkill", ["/F", "/T", "/PID", String(pid)], {
stdio: "ignore",
detached: true,
windowsHide: true,
});
} catch {
// Ignore errors.
}
killWindowsProcessTree(pid);
return;
}

Expand Down
88 changes: 88 additions & 0 deletions packages/agent/test/harness/kill-process-tree-windows.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import { spawn } from "node:child_process";
import { once } from "node:events";
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { killWindowsProcessTree, windowsTaskkillCandidates } from "../../src/harness/env/nodejs.ts";

/** Names that can never resolve on PATH, so spawn() fails with ENOENT on every platform. */
const UNRESOLVABLE_TASKKILL = ["senpi-nonexistent-taskkill-binary.exe"];

const tempRoots: string[] = [];

function createFakeSystemRoot(withTaskkill: boolean): string {
const root = mkdtempSync(join(tmpdir(), "senpi-systemroot-"));
tempRoots.push(root);
if (withTaskkill) {
mkdirSync(join(root, "System32"), { recursive: true });
writeFileSync(join(root, "System32", "taskkill.exe"), "");
}
return root;
}

afterEach(() => {
while (tempRoots.length > 0) {
const root = tempRoots.pop();
if (root) rmSync(root, { recursive: true, force: true });
}
});

describe("windowsTaskkillCandidates", () => {
it("puts the absolute System32 executable ahead of the bare PATH lookup", () => {
const root = createFakeSystemRoot(true);
expect(windowsTaskkillCandidates({ SystemRoot: root })).toEqual([
join(root, "System32", "taskkill.exe"),
"taskkill.exe",
]);
});

it("falls back to the bare executable name when no absolute candidate exists", () => {
const root = createFakeSystemRoot(false);
expect(windowsTaskkillCandidates({ SystemRoot: root })).toEqual(["taskkill.exe"]);
expect(windowsTaskkillCandidates({})).toEqual(["taskkill.exe"]);
});
});

describe("killWindowsProcessTree", () => {
it("kills the child instead of crashing the process when taskkill cannot be spawned", async () => {
const child = spawn(process.execPath, ["-e", "setInterval(() => {}, 1000);"], { stdio: "ignore" });
await once(child, "spawn");
const pid = child.pid;
expect(pid).toBeDefined();

const uncaught: unknown[] = [];
const onUncaught = (error: unknown) => uncaught.push(error);
process.on("uncaughtException", onUncaught);

try {
// An asynchronous spawn() surfaces ENOENT through the child's 'error' event.
// Before the fix this became an uncaughtException that killed the host process.
killWindowsProcessTree(pid as number, UNRESOLVABLE_TASKKILL);
await once(child, "exit");
expect(uncaught).toEqual([]);
} finally {
process.off("uncaughtException", onUncaught);
if (child.exitCode === null && child.signalCode === null) child.kill("SIGKILL");
}
});

it("issues the fallback kill synchronously, before the caller can exit", () => {
// A caller that tears down and exits in the same tick never reaches a later
// event-loop turn, so the fallback must be issued before this call returns.
const calls: Array<[number, NodeJS.Signals | number | undefined]> = [];
const realKill = process.kill.bind(process);
process.kill = ((pid: number, signal?: NodeJS.Signals | number) => {
calls.push([pid, signal]);
return true;
}) as typeof process.kill;

try {
killWindowsProcessTree(4242, UNRESOLVABLE_TASKKILL);
} finally {
process.kill = realKill;
}

expect(calls).toEqual([[4242, undefined]]);
});
});
6 changes: 6 additions & 0 deletions packages/coding-agent/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,12 @@
authenticated ambient Claude CLI is usable. Empty sentinel credentials and logged-out local CLIs are skipped, and
fallback responses carrying errors such as `Not logged in` cannot emit an immediate or delayed green
`Fallback model responded` notice ([#803](https://github.com/code-yeongyu/senpi/pull/803)).
- Windows shutdown no longer dies with an uncaught `Error: spawn taskkill ENOENT` when `%SystemRoot%\System32` is
missing from PATH. The tracked-detached-child kill now tries every absolute `System32` / `Sysnative` `taskkill.exe`
before the PATH-resolved name, runs synchronously so a shutdown that exits in the same tick still terminates the
tree, and degrades to killing the direct child only when no launcher starts at all
([#812](https://github.com/code-yeongyu/senpi/issues/812),
[#807](https://github.com/code-yeongyu/senpi/pull/807)).

### New Features

Expand Down
33 changes: 33 additions & 0 deletions packages/coding-agent/src/utils/changes.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,38 @@
# changes

## Windows process-tree kill survives an unresolvable taskkill (2026-08-11)

### What changed

- `shell.ts`: the Windows branch of `killProcessTree` moved into `killWindowsProcessTree`, which walks the ordered
launcher list from the new `windowsTaskkillCandidates` export (every existing absolute `System32` / `Sysnative`
`taskkill.exe`, then the bare PATH-resolved name), runs each with `spawnSync` under a 5s timeout, and only degrades
to `process.kill(pid)` when no launcher starts at all. Both new functions are exported for regression coverage.

### Why

- `spawn("taskkill", ...)` resolves the executable through PATH and reports a failed lookup asynchronously on the
child's `error` event, so the surrounding `try`/`catch` never saw it. On a session whose PATH had lost
`%SystemRoot%\System32`, `killTrackedDetachedChildren()` during shutdown raised
`Error: spawn taskkill ENOENT` as an uncaught exception and took the CLI down instead of exiting, and no tracked
child was killed.
- The kill is synchronous because `emergencyTerminalExit()` calls `killTrackedDetachedChildren()` and then
`process.exit(129)` in the same tick. An asynchronous killer — or a fallback wired to the child's `error` event —
never runs on that path, so the tracked child would survive. `spawnSync` reports a failed lookup on its returned
`error` field instead of emitting it, so ENOENT can no longer become an uncaught exception either.
- The candidate list exists because the reported failure was PATH resolution, not a missing binary: a broken PATH must
not downgrade a tree kill to a direct kill. `process.kill` maps to `TerminateProcess` and leaves descendants
orphaned, the same limitation `packages/pty/src/pipe-fallback.ts` documents, so it stays a last resort.

### Why extension system couldn't handle this

- Detached-child bookkeeping and the shutdown signal handlers live in core modes; no extension hook runs inside the
signal path that kills tracked children.

### Expected merge conflict zones on next upstream sync

- LOW: the Windows branch of `killProcessTree` and the `node:path` / `child_process` import lines in `shell.ts`.

## Config-reload recursive watch option (2026-07-21)

### What changed
Expand Down
91 changes: 79 additions & 12 deletions packages/coding-agent/src/utils/shell.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { existsSync } from "node:fs";
import { delimiter } from "node:path";
import { spawn, spawnSync } from "child_process";
import { delimiter, join } from "node:path";
import { spawnSync } from "child_process";
import { getBinDir } from "../config.ts";

/** Family of a resolved shell executable, used to pick invocation arguments. */
Expand Down Expand Up @@ -260,21 +260,88 @@ export function killTrackedDetachedChildren(): void {
trackedDetachedChildPids.clear();
}

/**
* Ordered `taskkill` launchers to try, most reliable first.
*
* `spawn("taskkill", ...)` relies on a PATH lookup, so any session whose PATH lost
* `%SystemRoot%\System32` (a POSIX-style PATH inherited from a Git Bash/MSYS launcher,
* a truncated user PATH, a locked-down service account) fails to resolve it. A broken PATH
* must not cost us the process-tree kill, so every absolute System32 location that actually
* exists is tried before the bare PATH-resolved name.
*/
export function windowsTaskkillCandidates(env: NodeJS.ProcessEnv = process.env): string[] {
// A bare `SystemDrive` is drive-relative ("C:"), so anchor it before joining.
const systemDrive = env.SystemDrive ? `${env.SystemDrive}\\` : undefined;
const roots = [env.SystemRoot, env.SYSTEMROOT, env.windir, systemDrive && join(systemDrive, "Windows")];
const candidates: string[] = [];
for (const root of roots) {
if (!root) continue;
// Sysnative reaches the real 64-bit System32 from a 32-bit process, where System32
// is redirected to SysWOW64.
for (const systemDir of ["System32", "Sysnative"]) {
const absolute = join(root, systemDir, "taskkill.exe");
if (!candidates.includes(absolute) && existsSync(absolute)) candidates.push(absolute);
}
}
candidates.push("taskkill.exe");
return candidates;
}

function killProcessDirectly(pid: number): void {
try {
process.kill(pid);
} catch {
// Process already dead.
}
}

/** Upper bound on how long a shutdown may block waiting for `taskkill` to finish. */
const TASKKILL_TIMEOUT_MS = 5_000;

function taskkillHandledTree(pid: number, taskkillPath: string): boolean {
try {
const result = spawnSync(taskkillPath, ["/F", "/T", "/PID", String(pid)], {
stdio: "ignore",
windowsHide: true,
timeout: TASKKILL_TIMEOUT_MS,
});
// `error` means the launcher never started (ENOENT, EACCES); a null status means
// the timeout killed it. Any real taskkill exit code counts as handled.
return result.error === undefined && result.status !== null;
} catch {
return false;
}
}

/**
* Kill a process and all its children on Windows via `taskkill /T`.
*
* Synchronous on purpose. Shutdown paths call `killTrackedDetachedChildren()` and then
* `process.exit()` in the same tick (`emergencyTerminalExit()`), so neither an
* asynchronous killer nor a fallback wired to a child's `error` event would ever run and
* the tracked child would survive. `spawnSync` also reports a failed executable lookup on
* its returned `error` field instead of emitting it, so a PATH without
* `%SystemRoot%\System32` can no longer surface as an uncaught `spawn taskkill ENOENT`.
*
* The direct `process.kill` at the end is a degraded last resort reached only when no
* `taskkill.exe` can be launched at all. It maps to `TerminateProcess`, which does not
* touch descendants — the same limitation `packages/pty/src/pipe-fallback.ts` documents.
* Nothing in-process can walk a Windows process tree without an external tool, so this
* still beats leaving the whole tree running.
*/
export function killWindowsProcessTree(pid: number, taskkillPaths = windowsTaskkillCandidates()): void {
for (const taskkillPath of taskkillPaths) {
if (taskkillHandledTree(pid, taskkillPath)) return;
}
killProcessDirectly(pid);
}

/**
* Kill a process and all its children (cross-platform)
*/
export function killProcessTree(pid: number): void {
if (process.platform === "win32") {
// Use taskkill on Windows to kill process tree
try {
spawn("taskkill", ["/F", "/T", "/PID", String(pid)], {
stdio: "ignore",
detached: true,
windowsHide: true,
});
} catch {
// Ignore errors if taskkill fails
}
killWindowsProcessTree(pid);
} else {
// Use SIGKILL on Unix/Linux/Mac
try {
Expand Down
Loading