Skip to content

Commit db42fca

Browse files
resume() continues a stopped session in the same worktree
1 parent 4909665 commit db42fca

4 files changed

Lines changed: 114 additions & 31 deletions

File tree

‎README.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,7 @@ Week 1 (Oct 19 to 23, 2026).
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 |
7676
| `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` |
77-
| `resume`: same worktree, `seq` continues | [runner/src/runner.ts](runner/src/runner.ts) | To do. Throws `not implemented`. The `--resume` arguments exist |
77+
| `resume`: same worktree, `seq` continues | [runner/src/runner.ts](runner/src/runner.ts) | Done. Runs `claude -p --resume <sessionId>` through the same `runAgent()` as `start()`, so it ends in `awaiting_approval` or `task.failed` |
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 |
8080

@@ -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; stop kills the agent and its children, keeps the worktree and refuses a session that is not running |
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; resume continues the numbering, passes the prompt, can be stopped again and refuses a session that is not stopped |
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/scripts/try.ts‎

Lines changed: 21 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
1-
// pnpm try [repoPath] [prompt] [--approve | --reject]: one real run through the runner, one event per line.
1+
// pnpm try [repoPath] [prompt] [--approve | --reject] [--stop]: one real run through the runner, one event per line.
22
// Without a flag it asks approve / reject / keep at the end (or keeps, when not in a terminal).
3+
// --stop stops the run at its first command (npm test), then resumes it. Check Task Manager while it is stopped.
34
// Costs real money (capped by budgetUsd below). Run scripts/reset-fixture.sh afterwards.
45
import { randomUUID } from "node:crypto";
56
import { homedir } from "node:os";
67
import { join } from "node:path";
78
import { createInterface } from "node:readline/promises";
9+
import type { RunnerEvent } from "@codehands/contracts";
810
import { createRunner } from "../src/index.js";
911

1012
const flags = process.argv.slice(2).filter((a) => a.startsWith("--"));
@@ -14,12 +16,24 @@ const [repoPath = join(homedir(), "codehands-fixtures", "date-bug"), prompt = "F
1416
const runner = createRunner();
1517
const sessionId = randomUUID();
1618
let finished = false;
17-
for await (const e of runner.start({ sessionId, repoPath, prompt, agent: "claude-code", model: "sonnet", budgetUsd: 0.5 })) {
18-
const p = e.payload;
19-
const detail = p.reason ?? p.command ?? p.path ?? (typeof p.text === "string" ? p.text.slice(0, 80) : "") ?? "";
20-
const extra = e.type === "test.result" ? ` passed=${p.passed}` : e.type === "task.finished" ? ` $${p.total_cost_usd}` : "";
21-
console.log(`${String(e.seq).padStart(3)} ${e.type.padEnd(17)} ${String(detail).replace(/\s+/g, " ")}${extra}`);
22-
if (e.type === "task.finished") finished = true;
19+
let stopping: Promise<void> | undefined;
20+
async function print(run: AsyncIterable<RunnerEvent>, stopAtCommand = false) {
21+
for await (const e of run) {
22+
const p = e.payload;
23+
const detail = p.reason ?? p.command ?? p.path ?? (typeof p.text === "string" ? p.text.slice(0, 80) : "") ?? "";
24+
const extra = e.type === "test.result" ? ` passed=${p.passed}` : e.type === "task.finished" ? ` $${p.total_cost_usd}` : "";
25+
console.log(`${String(e.seq).padStart(3)} ${e.type.padEnd(17)} ${String(detail).replace(/\s+/g, " ")}${extra}`);
26+
if (e.type === "task.finished") finished = true;
27+
if (stopAtCommand && e.type === "command.started" && !stopping) stopping = runner.stop(sessionId);
28+
}
29+
}
30+
31+
await print(runner.start({ sessionId, repoPath, prompt, agent: "claude-code", model: "sonnet", budgetUsd: 0.5 }), flags.includes("--stop"));
32+
if (stopping) {
33+
await stopping;
34+
console.log("\nstopped. Check Task Manager for claude, node or npm left over. Resuming in 10 s...\n");
35+
await new Promise((r) => setTimeout(r, 10_000));
36+
await print(runner.resume(sessionId));
2337
}
2438
if (!finished) process.exit(1);
2539

‎runner/src/runner.ts‎

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { createInterface } from "node:readline";
22
import type { Runner, RunnerEvent, StartOptions } from "@codehands/contracts";
33
import { execa } from "execa";
4-
import { claudeArgs, newClaudeParseState, parseClaudeLine } from "./agents/claude-code.js";
4+
import { claudeArgs, claudeResumeArgs, newClaudeParseState, parseClaudeLine } from "./agents/claude-code.js";
55
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";
@@ -15,11 +15,19 @@ export interface RunnerOptions {
1515
worktreeRoot?: string; // default ~/.codehands/worktrees
1616
// Which program to run for a task. Tests swap in a script that replays a recording.
1717
agentCommand?: (o: StartOptions) => { file: string; args: string[] };
18+
resumeCommand?: (o: StartOptions, prompt: string) => { file: string; args: string[] };
1819
}
1920

2021
const claudeCommand = (o: StartOptions) => ({ file: "claude", args: claudeArgs(o) });
21-
22-
export function createRunner({ worktreeRoot, agentCommand = claudeCommand }: RunnerOptions = {}): Runner {
22+
// start() passes our sessionId as Claude's --session-id, so the same id resumes it.
23+
const claudeResumeCommand = (o: StartOptions, prompt: string) =>
24+
({ file: "claude", args: claudeResumeArgs(o.sessionId, prompt, o.budgetUsd) });
25+
26+
export function createRunner({
27+
worktreeRoot,
28+
agentCommand = claudeCommand,
29+
resumeCommand = claudeResumeCommand,
30+
}: RunnerOptions = {}): Runner {
2331
const store = new SessionStore();
2432
// The running agent of each session, so stop() can wait for it to exit.
2533
const exits = new Map<string, Promise<unknown>>();
@@ -148,9 +156,11 @@ export function createRunner({ worktreeRoot, agentCommand = claudeCommand }: Run
148156
await stopProcessTree(record.pid, exited);
149157
if (record.status === "running") store.transition(sessionId, "stopped");
150158
},
151-
// TODO: runAgent(record, new EventSequence(id, record.lastSeq), claudeResumeArgs(...)) in the same worktree.
152-
resume() {
153-
throw new Error("not implemented: resume (Thu)");
159+
// Continues a stopped session in the same worktree. seq carries on from the last event,
160+
// and the run ends the way start()'s does. If Claude never saved the session, it ends in task.failed.
161+
async *resume(sessionId, prompt = "Continue.") {
162+
const record = store.transition(sessionId, "running");
163+
yield* runAgent(record, new EventSequence(sessionId, record.lastSeq), resumeCommand(record.options, prompt));
154164
},
155165
// The change since the base commit, new files included. Fresh each call while the worktree exists.
156166
async diff(sessionId) {

‎runner/test/runner.test.ts‎

Lines changed: 75 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "no
44
import { tmpdir } from "node:os";
55
import { join } from "node:path";
66
import { fileURLToPath } from "node:url";
7-
import type { RunnerEvent, StartOptions } from "@codehands/contracts";
7+
import type { Runner, RunnerEvent, StartOptions } from "@codehands/contracts";
88
import { afterAll, describe, expect, it, vi } from "vitest";
99
import { createRunner, type RunnerOptions } from "../src/index.js";
1010

@@ -166,23 +166,35 @@ describe("runner diff, approve and reject", () => {
166166
});
167167
});
168168

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")) };
169+
// Reads a run to the end and calls stop() as soon as the agent has started.
170+
async function readAndStop(runner: Runner, sessionId: string, run: AsyncIterable<RunnerEvent>) {
171+
const events: RunnerEvent[] = [];
172+
let stopping: Promise<void> | undefined;
173+
for await (const e of run) {
174+
events.push(e);
175+
if (e.type === "task.started") stopping = runner.stop(sessionId);
184176
}
177+
await stopping;
178+
return events;
179+
}
180+
181+
// A run whose agent hangs after task.started, with a child of its own, like claude -> npm test.
182+
async function stoppedRun(runnerOptions: RunnerOptions = {}) {
183+
const repo = makeRepo();
184+
const o = options(repo);
185+
const pidFile = join(tmp, `child-${o.sessionId}.pid`);
186+
const runner = newRunner({ agentCommand: replayAgent(spike, 0, "hang", pidFile), ...runnerOptions });
187+
const events = await readAndStop(runner, o.sessionId, runner.start(o));
188+
return { repo, o, runner, events, childPid: Number(readFileSync(pidFile, "utf8")) };
189+
}
185190

191+
async function drain(run: AsyncIterable<RunnerEvent>) {
192+
const events: RunnerEvent[] = [];
193+
for await (const e of run) events.push(e);
194+
return events;
195+
}
196+
197+
describe("runner.stop", () => {
186198
it("kills the agent and its children, and the stream ends with task.stopped", async () => {
187199
const { events, childPid } = await stoppedRun();
188200
expect(events.map((e) => e.type)).toEqual(["task.started", "task.stopped"]);
@@ -208,3 +220,50 @@ describe("runner.stop", () => {
208220
await expect(other.stop(randomUUID())).rejects.toThrow(/unknown session/);
209221
});
210222
});
223+
224+
describe("runner.resume", () => {
225+
const resumeWith = (mode = "", pidFile = ""): RunnerOptions["resumeCommand"] =>
226+
() => ({ file: process.execPath, args: [replay, spike, "0", mode, pidFile] });
227+
228+
it("continues in the same worktree, seq carries on, and it ends awaiting approval", async () => {
229+
const { repo, o, runner, events } = await stoppedRun({ resumeCommand: resumeWith("edit") });
230+
const resumed = await drain(runner.resume(o.sessionId));
231+
232+
expect(resumed.at(-1)!.type).toBe("task.finished");
233+
expect(resumed.at(-1)!.payload.diffStat).toMatch(/2 files changed/);
234+
expect([...events, ...resumed].map((e) => e.seq)).toEqual([...events, ...resumed].map((_, i) => i + 1));
235+
236+
const { branch } = await runner.approve(o.sessionId, "Fix the failing test");
237+
expect(git(repo, "show", "--name-only", "--format=", branch).split("\n").sort()).toEqual(["a.txt", "new.txt"]);
238+
});
239+
240+
it("passes the prompt to the agent", async () => {
241+
let seen = "";
242+
const { o, runner } = await stoppedRun({
243+
resumeCommand: (_o, prompt) => {
244+
seen = prompt;
245+
return { file: process.execPath, args: [replay, spike] };
246+
},
247+
});
248+
await drain(runner.resume(o.sessionId, "Only fix the date test."));
249+
expect(seen).toBe("Only fix the date test.");
250+
});
251+
252+
it("can be stopped again, and numbering still carries on", async () => {
253+
const pidFile = join(tmp, `resume-child-${randomUUID()}.pid`);
254+
const { o, runner, events } = await stoppedRun({ resumeCommand: resumeWith("hang", pidFile) });
255+
const resumed = await readAndStop(runner, o.sessionId, runner.resume(o.sessionId));
256+
257+
expect(resumed.map((e) => e.type)).toEqual(["task.started", "task.stopped"]);
258+
expect(resumed.map((e) => e.seq)).toEqual([events.length + 1, events.length + 2]);
259+
await vi.waitFor(() => expect(alive(Number(readFileSync(pidFile, "utf8")))).toBe(false), { timeout: 3000 });
260+
});
261+
262+
it("refuses a session that is not stopped", async () => {
263+
const o = options(makeRepo());
264+
const runner = newRunner({ agentCommand: replayAgent(spike) });
265+
await collect(o, {}, runner);
266+
await expect(drain(runner.resume(o.sessionId))).rejects.toThrow(/illegal move/); // awaiting approval
267+
await expect(drain(runner.resume(randomUUID()))).rejects.toThrow(/unknown session/);
268+
});
269+
});

0 commit comments

Comments
 (0)