Skip to content

Commit 4909665

Browse files
stop() kills the agent's process tree and ends the stream with task.stopped
1 parent d589a06 commit 4909665

8 files changed

Lines changed: 153 additions & 31 deletions

File tree

‎README.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,7 @@ Week 1 (Oct 19 to 23, 2026).
7373
| Worktrees: branch names, add, remove, prune | [runner/src/git/worktree.ts](runner/src/git/worktree.ts) | Done |
7474
| Diff since the base commit, commit on approve | [runner/src/git/diff.ts](runner/src/git/diff.ts) | Done |
7575
| `start`, `diff`, `approve`, `reject` | [runner/src/runner.ts](runner/src/runner.ts) | Done, tested with real runs |
76-
| `stop`: kill the whole process tree, keep the worktree | [runner/src/process/kill-tree.ts](runner/src/process/kill-tree.ts) | To do. Throws `not implemented` |
76+
| `stop`: kill the whole process tree, keep the worktree | [runner/src/process/kill-tree.ts](runner/src/process/kill-tree.ts) | Done. `task.stopped` comes out of the `start()` stream; `stop()` resolves once the session is `stopped` |
7777
| `resume`: same worktree, `seq` continues | [runner/src/runner.ts](runner/src/runner.ts) | To do. Throws `not implemented`. The `--resume` arguments exist |
7878
| Codex: `codex exec --json` to events | [runner/src/agents/codex.ts](runner/src/agents/codex.ts) | To do. `start()` with `agent: "codex"` fails cleanly for now |
7979
| Repo notes read before a run, summary appended after | [runner/src/memory/notes.ts](runner/src/memory/notes.ts) | To do. `repoKey()` is done |
@@ -211,7 +211,7 @@ The scripts in `runner/scripts/`:
211211
| File | Covers |
212212
| --- | --- |
213213
| [claude-parser.test.ts](runner/test/claude-parser.test.ts) | The whole recorded run replays in order; each mapping above; blocked commands; failed tests hidden behind exit code 0; error results; broken lines; `raw` on every event |
214-
| [runner.test.ts](runner/test/runner.test.ts) | A worktree on a new branch with your repo untouched; clean failures for a repo with no commits, an agent with no result line, a missing binary and an unsupported agent; diff with new files; approve commits on the branch and leaves `main` alone; reject deletes the branch; nothing to approve |
214+
| [runner.test.ts](runner/test/runner.test.ts) | A worktree on a new branch with your repo untouched; clean failures for a repo with no commits, an agent with no result line, a missing binary and an unsupported agent; diff with new files; approve commits on the branch and leaves `main` alone; reject deletes the branch; nothing to approve; stop kills the agent and its children, keeps the worktree and refuses a session that is not running |
215215
| [fake-runner.test.ts](runner/test/fake-runner.test.ts) | Event order and `seq`; approve only after finishing; stop then resume with `seq` continuing |
216216
| [worktree.test.ts](runner/test/worktree.test.ts) | Branch names |
217217

‎runner/package.json‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,8 @@
2222
},
2323
"dependencies": {
2424
"@codehands/contracts": "file:../../codehands-contracts",
25-
"execa": "^10.2.0"
25+
"execa": "^10.2.0",
26+
"tree-kill": "^1.2.2"
2627
},
2728
"devDependencies": {
2829
"@types/node": "^26.6.5",

‎runner/pnpm-lock.yaml‎

Lines changed: 9 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎runner/src/process/kill-tree.ts‎

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,24 @@
11
// Week 1 guide, section 7 and diagram 1e. Killing the parent does not kill its
22
// children on Windows (claude -> npm test -> node), so kill the whole tree.
33

4-
// TODO(Wed): ask politely, wait about 3 s, then `tree-kill` (taskkill /T /F on Windows).
5-
export async function stopProcessTree(_pid: number, _graceMs = 3000): Promise<void> {
6-
throw new Error("not implemented: stopProcessTree (Wed)");
4+
import treeKill from "tree-kill";
5+
6+
// Errors are ignored: the most common one is "already gone", which is what we want.
7+
const kill = (pid: number, signal: NodeJS.Signals) =>
8+
new Promise<void>((resolve) => treeKill(pid, signal, () => resolve()));
9+
10+
// Ask politely, wait up to graceMs for `exited`, then force. On Windows tree-kill always
11+
// runs `taskkill /T /F`, because a console program can't be asked to close, so the first kill is final.
12+
export async function stopProcessTree(pid: number, exited: Promise<unknown>, graceMs = 3000): Promise<void> {
13+
await kill(pid, "SIGTERM");
14+
let timer: NodeJS.Timeout | undefined;
15+
const timedOut = await Promise.race([
16+
exited.then(() => false),
17+
new Promise<boolean>((resolve) => (timer = setTimeout(() => resolve(true), graceMs))),
18+
]);
19+
clearTimeout(timer);
20+
if (timedOut) {
21+
await kill(pid, "SIGKILL");
22+
await exited;
23+
}
724
}

‎runner/src/runner.ts‎

Lines changed: 46 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,8 @@ import { EventSequence } from "./events.js";
66
import { commitAll, diffSince } from "./git/diff.js";
77
import { addWorktree, baseCommit, branchName, pruneWorktrees, removeWorktree, worktreePath } from "./git/worktree.js";
88
import { repoKey } from "./memory/notes.js";
9-
import { SessionStore } from "./session-store.js";
9+
import { stopProcessTree } from "./process/kill-tree.js";
10+
import { SessionStore, type SessionRecord } from "./session-store.js";
1011

1112
// The real runner. Diagram 1c in the week 1 guide is the map for start().
1213

@@ -20,18 +21,26 @@ const claudeCommand = (o: StartOptions) => ({ file: "claude", args: claudeArgs(o
2021

2122
export function createRunner({ worktreeRoot, agentCommand = claudeCommand }: RunnerOptions = {}): Runner {
2223
const store = new SessionStore();
24+
// The running agent of each session, so stop() can wait for it to exit.
25+
const exits = new Map<string, Promise<unknown>>();
2326

24-
async function* start(o: StartOptions): AsyncIterable<RunnerEvent> {
25-
const record = store.create(o);
26-
const seq = new EventSequence(o.sessionId);
27+
// Every event goes through emit, so lastSeq is always right for resume().
28+
function emitters(record: SessionRecord, seq: EventSequence) {
2729
const emit = (event: RunnerEvent) => {
2830
record.lastSeq = seq.last;
2931
return event;
3032
};
3133
const fail = (reason: string, extra: Record<string, unknown> = {}) => {
32-
store.transition(o.sessionId, "failed");
34+
store.transition(record.options.sessionId, "failed");
3335
return emit(seq.next("task.failed", { reason, ...extra }));
3436
};
37+
return { emit, fail };
38+
}
39+
40+
async function* start(o: StartOptions): AsyncIterable<RunnerEvent> {
41+
const record = store.create(o);
42+
const seq = new EventSequence(o.sessionId);
43+
const { fail } = emitters(record, seq);
3544

3645
if (o.agent !== "claude-code") {
3746
yield fail(`agent ${o.agent} is not supported yet`);
@@ -50,17 +59,26 @@ export function createRunner({ worktreeRoot, agentCommand = claudeCommand }: Run
5059
return;
5160
}
5261
store.transition(o.sessionId, "running");
62+
yield* runAgent(record, seq, agentCommand(o));
63+
}
64+
65+
// Steps 2 to 5 of a run. start() and resume() share them, so a resumed run ends the same way.
66+
async function* runAgent(record: SessionRecord, seq: EventSequence, { file, args }: { file: string; args: string[] }): AsyncIterable<RunnerEvent> {
67+
const { sessionId } = record.options;
68+
const { worktreePath, baseCommit } = worktreeOf(sessionId);
69+
const { emit, fail } = emitters(record, seq);
70+
record.stopRequested = false;
5371

5472
// 2. Run the agent inside the copy. reject: false, so a crash or a missing binary is a result, not a throw.
55-
const { file, args } = agentCommand(o);
5673
const child = execa(file, args, {
57-
cwd: record.worktreePath,
74+
cwd: worktreePath,
5875
stdin: "ignore",
5976
reject: false,
6077
buffer: { stdout: false, stderr: true },
6178
windowsHide: true,
6279
});
6380
record.pid = child.pid;
81+
exits.set(sessionId, child);
6482

6583
// 3. Every complete stdout line becomes events. readline never hands over half a line.
6684
// The final event waits until the process is gone, so it can carry the diff.
@@ -76,6 +94,14 @@ export function createRunner({ worktreeRoot, agentCommand = claudeCommand }: Run
7694
// 4. The process is done. No result line means it crashed or never started.
7795
const result = await child;
7896
record.pid = undefined;
97+
exits.delete(sessionId);
98+
99+
// stop() killed it. The worktree stays so the session can resume.
100+
if (record.stopRequested) {
101+
if (record.status === "running") store.transition(sessionId, "stopped");
102+
yield emit(seq.next("task.stopped", {}));
103+
return;
104+
}
79105
if (!finished) {
80106
yield fail("the agent exited without a result", {
81107
exitCode: result.exitCode,
@@ -85,19 +111,19 @@ export function createRunner({ worktreeRoot, agentCommand = claudeCommand }: Run
85111
return;
86112
}
87113
if (finished.type === "task.failed") {
88-
store.transition(o.sessionId, "failed");
114+
store.transition(sessionId, "failed");
89115
yield emit(finished);
90116
return;
91117
}
92118

93119
// 5. Store the change and put its summary on task.finished, so the phone shows it right away.
94120
try {
95-
record.diff = await diffSince(record.worktreePath, record.baseCommit);
121+
record.diff = await diffSince(worktreePath, baseCommit);
96122
finished.payload.diffStat = record.diff.stat;
97123
} catch (err) {
98124
finished.payload.diffError = (err as Error).message;
99125
}
100-
store.transition(o.sessionId, "awaiting_approval");
126+
store.transition(sessionId, "awaiting_approval");
101127
yield emit(finished);
102128
}
103129

@@ -112,11 +138,17 @@ export function createRunner({ worktreeRoot, agentCommand = claudeCommand }: Run
112138

113139
return {
114140
start,
115-
// TODO(Wed): stopProcessTree(pid), transition to stopped, yield task.stopped. Keep the worktree.
116-
async stop() {
117-
throw new Error("not implemented: stop (Wed)");
141+
// Kills the agent and everything it started. task.stopped comes out of the start() stream,
142+
// and the session is stopped by the time this resolves. The worktree stays.
143+
async stop(sessionId) {
144+
const record = store.check(sessionId, "stopped");
145+
const exited = exits.get(sessionId);
146+
if (!record.pid || !exited) throw new Error(`session ${sessionId} has no agent process to stop`);
147+
record.stopRequested = true;
148+
await stopProcessTree(record.pid, exited);
149+
if (record.status === "running") store.transition(sessionId, "stopped");
118150
},
119-
// TODO(Thu am): claudeResumeArgs in the same worktree, seq continues from lastSeq.
151+
// TODO: runAgent(record, new EventSequence(id, record.lastSeq), claudeResumeArgs(...)) in the same worktree.
120152
resume() {
121153
throw new Error("not implemented: resume (Thu)");
122154
},

‎runner/src/session-store.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ export interface SessionRecord {
99
branch?: string;
1010
baseCommit?: string;
1111
pid?: number;
12+
stopRequested?: boolean; // set by stop(), so the agent's exit reads as task.stopped, not task.failed
1213
lastSeq: number;
1314
diff?: { patch: string; stat: string };
1415
}

‎runner/test/helpers/replay-agent.mjs‎

Lines changed: 17 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,22 @@
11
// Stand-in for `claude`: prints a recorded JSONL file line by line, then exits with the given code.
2-
// node replay-agent.mjs <file.jsonl> [exitCode] [edit]
2+
// node replay-agent.mjs <file.jsonl> [exitCode] [edit | hang] [pidFile]
33
// With "edit" it also changes a.txt and adds new.txt in its working folder, like a real fix would.
4+
// With "hang" it prints the first 2 lines (task.started), starts a child that never exits
5+
// (like claude -> npm test), writes the child's pid to pidFile and never exits itself.
6+
import { spawn } from "node:child_process";
47
import { readFileSync, writeFileSync } from "node:fs";
58

6-
const [file, exitCode = "0", mode] = process.argv.slice(2);
7-
if (mode === "edit") {
8-
writeFileSync("a.txt", "hello\nworld\n");
9-
writeFileSync("new.txt", "a new file\n");
9+
const [file, exitCode = "0", mode, pidFile] = process.argv.slice(2);
10+
if (mode === "hang") {
11+
const child = spawn(process.execPath, ["-e", "setInterval(() => {}, 1000)"], { stdio: "ignore" });
12+
writeFileSync(pidFile, String(child.pid));
13+
process.stdout.write(readFileSync(file, "utf8").split("\n").slice(0, 2).join("\n") + "\n");
14+
setInterval(() => {}, 1000);
15+
} else {
16+
if (mode === "edit") {
17+
writeFileSync("a.txt", "hello\nworld\n");
18+
writeFileSync("new.txt", "a new file\n");
19+
}
20+
if (file) process.stdout.write(readFileSync(file, "utf8"));
21+
process.exitCode = Number(exitCode);
1022
}
11-
if (file) process.stdout.write(readFileSync(file, "utf8"));
12-
process.exitCode = Number(exitCode);

‎runner/test/runner.test.ts‎

Lines changed: 56 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
11
import { execFileSync } from "node:child_process";
22
import { randomUUID } from "node:crypto";
3-
import { existsSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
3+
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
44
import { tmpdir } from "node:os";
55
import { join } from "node:path";
66
import { fileURLToPath } from "node:url";
77
import type { RunnerEvent, StartOptions } from "@codehands/contracts";
8-
import { afterAll, describe, expect, it } from "vitest";
8+
import { afterAll, describe, expect, it, vi } from "vitest";
99
import { createRunner, type RunnerOptions } from "../src/index.js";
1010

1111
// start() against a throwaway git repo. The agent is a script that replays spike-1.jsonl, so no API cost.
@@ -40,8 +40,17 @@ async function collect(o: StartOptions, runnerOptions: RunnerOptions = {}, runne
4040

4141
const newRunner = (runnerOptions: RunnerOptions = {}) => createRunner({ worktreeRoot: join(tmp, "worktrees"), ...runnerOptions });
4242

43-
const replayAgent = (file: string, exitCode = 0, mode = ""): RunnerOptions["agentCommand"] =>
44-
() => ({ file: process.execPath, args: [replay, file, String(exitCode), mode] });
43+
const replayAgent = (file: string, exitCode = 0, mode = "", pidFile = ""): RunnerOptions["agentCommand"] =>
44+
() => ({ file: process.execPath, args: [replay, file, String(exitCode), mode, pidFile] });
45+
46+
const alive = (pid: number) => {
47+
try {
48+
process.kill(pid, 0);
49+
return true;
50+
} catch {
51+
return false;
52+
}
53+
};
4554

4655
describe("runner.start", () => {
4756
it("makes a worktree on a new branch, runs the agent there and streams its events", async () => {
@@ -156,3 +165,46 @@ describe("runner diff, approve and reject", () => {
156165
await expect(runner.reject(o.sessionId)).rejects.toThrow(/no worktree/);
157166
});
158167
});
168+
169+
describe("runner.stop", () => {
170+
// A run whose agent hangs after task.started, with a child of its own, like claude -> npm test.
171+
async function stoppedRun() {
172+
const repo = makeRepo();
173+
const o = options(repo);
174+
const pidFile = join(tmp, `child-${o.sessionId}.pid`);
175+
const runner = newRunner({ agentCommand: replayAgent(spike, 0, "hang", pidFile) });
176+
const events: RunnerEvent[] = [];
177+
let stopping: Promise<void> | undefined;
178+
for await (const e of runner.start(o)) {
179+
events.push(e);
180+
if (e.type === "task.started") stopping = runner.stop(o.sessionId);
181+
}
182+
await stopping;
183+
return { repo, o, runner, events, childPid: Number(readFileSync(pidFile, "utf8")) };
184+
}
185+
186+
it("kills the agent and its children, and the stream ends with task.stopped", async () => {
187+
const { events, childPid } = await stoppedRun();
188+
expect(events.map((e) => e.type)).toEqual(["task.started", "task.stopped"]);
189+
expect(events.map((e) => e.seq)).toEqual([1, 2]);
190+
await vi.waitFor(() => expect(alive(childPid)).toBe(false), { timeout: 3000 });
191+
});
192+
193+
it("keeps the worktree and the branch, so the session can resume", async () => {
194+
const { repo, o } = await stoppedRun();
195+
const short = o.sessionId.slice(0, 8);
196+
expect(git(repo, "worktree", "list")).toContain(short);
197+
expect(git(repo, "branch", "--list", `codehands/fix-the-failing-test-${short}`)).not.toBe("");
198+
});
199+
200+
it("refuses to stop a session that is not running", async () => {
201+
const { runner, o } = await stoppedRun();
202+
await expect(runner.stop(o.sessionId)).rejects.toThrow(/illegal move/); // already stopped
203+
204+
const finished = options(makeRepo());
205+
const other = newRunner({ agentCommand: replayAgent(spike) });
206+
await collect(finished, {}, other);
207+
await expect(other.stop(finished.sessionId)).rejects.toThrow(/illegal move/); // awaiting approval
208+
await expect(other.stop(randomUUID())).rejects.toThrow(/unknown session/);
209+
});
210+
});

0 commit comments

Comments
 (0)