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
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ Week 1 (Oct 19 to 23, 2026).
| Diff since the base commit, commit on approve | [runner/src/git/diff.ts](runner/src/git/diff.ts) | Done |
| `start`, `diff`, `approve`, `reject` | [runner/src/runner.ts](runner/src/runner.ts) | Done, tested with real runs |
| `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` |
| `resume`: same worktree, `seq` continues | [runner/src/runner.ts](runner/src/runner.ts) | To do. Throws `not implemented`. The `--resume` arguments exist |
| `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` |
| 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 |
| Repo notes read before a run, summary appended after | [runner/src/memory/notes.ts](runner/src/memory/notes.ts) | To do. `repoKey()` is done |

Expand Down Expand Up @@ -211,7 +211,7 @@ The scripts in `runner/scripts/`:
| File | Covers |
| --- | --- |
| [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 |
| [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 |
| [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 |
| [fake-runner.test.ts](runner/test/fake-runner.test.ts) | Event order and `seq`; approve only after finishing; stop then resume with `seq` continuing |
| [worktree.test.ts](runner/test/worktree.test.ts) | Branch names |

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

const flags = process.argv.slice(2).filter((a) => a.startsWith("--"));
Expand All @@ -14,12 +16,24 @@ const [repoPath = join(homedir(), "codehands-fixtures", "date-bug"), prompt = "F
const runner = createRunner();
const sessionId = randomUUID();
let finished = false;
for await (const e of runner.start({ sessionId, repoPath, prompt, agent: "claude-code", model: "sonnet", budgetUsd: 0.5 })) {
const p = e.payload;
const detail = p.reason ?? p.command ?? p.path ?? (typeof p.text === "string" ? p.text.slice(0, 80) : "") ?? "";
const extra = e.type === "test.result" ? ` passed=${p.passed}` : e.type === "task.finished" ? ` $${p.total_cost_usd}` : "";
console.log(`${String(e.seq).padStart(3)} ${e.type.padEnd(17)} ${String(detail).replace(/\s+/g, " ")}${extra}`);
if (e.type === "task.finished") finished = true;
let stopping: Promise<void> | undefined;
async function print(run: AsyncIterable<RunnerEvent>, stopAtCommand = false) {
for await (const e of run) {
const p = e.payload;
const detail = p.reason ?? p.command ?? p.path ?? (typeof p.text === "string" ? p.text.slice(0, 80) : "") ?? "";
const extra = e.type === "test.result" ? ` passed=${p.passed}` : e.type === "task.finished" ? ` $${p.total_cost_usd}` : "";
console.log(`${String(e.seq).padStart(3)} ${e.type.padEnd(17)} ${String(detail).replace(/\s+/g, " ")}${extra}`);
if (e.type === "task.finished") finished = true;
if (stopAtCommand && e.type === "command.started" && !stopping) stopping = runner.stop(sessionId);
}
}

await print(runner.start({ sessionId, repoPath, prompt, agent: "claude-code", model: "sonnet", budgetUsd: 0.5 }), flags.includes("--stop"));
if (stopping) {
await stopping;
console.log("\nstopped. Check Task Manager for claude, node or npm left over. Resuming in 10 s...\n");
await new Promise((r) => setTimeout(r, 10_000));
await print(runner.resume(sessionId));
}
if (!finished) process.exit(1);

Expand Down
22 changes: 16 additions & 6 deletions runner/src/runner.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { createInterface } from "node:readline";
import type { Runner, RunnerEvent, StartOptions } from "@codehands/contracts";
import { execa } from "execa";
import { claudeArgs, newClaudeParseState, parseClaudeLine } from "./agents/claude-code.js";
import { claudeArgs, claudeResumeArgs, newClaudeParseState, parseClaudeLine } from "./agents/claude-code.js";
import { EventSequence } from "./events.js";
import { commitAll, diffSince } from "./git/diff.js";
import { addWorktree, baseCommit, branchName, pruneWorktrees, removeWorktree, worktreePath } from "./git/worktree.js";
Expand All @@ -15,11 +15,19 @@ export interface RunnerOptions {
worktreeRoot?: string; // default ~/.codehands/worktrees
// Which program to run for a task. Tests swap in a script that replays a recording.
agentCommand?: (o: StartOptions) => { file: string; args: string[] };
resumeCommand?: (o: StartOptions, prompt: string) => { file: string; args: string[] };
}

const claudeCommand = (o: StartOptions) => ({ file: "claude", args: claudeArgs(o) });

export function createRunner({ worktreeRoot, agentCommand = claudeCommand }: RunnerOptions = {}): Runner {
// start() passes our sessionId as Claude's --session-id, so the same id resumes it.
const claudeResumeCommand = (o: StartOptions, prompt: string) =>
({ file: "claude", args: claudeResumeArgs(o.sessionId, prompt, o.budgetUsd) });

export function createRunner({
worktreeRoot,
agentCommand = claudeCommand,
resumeCommand = claudeResumeCommand,
}: RunnerOptions = {}): Runner {
const store = new SessionStore();
// The running agent of each session, so stop() can wait for it to exit.
const exits = new Map<string, Promise<unknown>>();
Expand Down Expand Up @@ -148,9 +156,11 @@ export function createRunner({ worktreeRoot, agentCommand = claudeCommand }: Run
await stopProcessTree(record.pid, exited);
if (record.status === "running") store.transition(sessionId, "stopped");
},
// TODO: runAgent(record, new EventSequence(id, record.lastSeq), claudeResumeArgs(...)) in the same worktree.
resume() {
throw new Error("not implemented: resume (Thu)");
// Continues a stopped session in the same worktree. seq carries on from the last event,
// and the run ends the way start()'s does. If Claude never saved the session, it ends in task.failed.
async *resume(sessionId, prompt = "Continue.") {
const record = store.transition(sessionId, "running");
yield* runAgent(record, new EventSequence(sessionId, record.lastSeq), resumeCommand(record.options, prompt));
},
// The change since the base commit, new files included. Fresh each call while the worktree exists.
async diff(sessionId) {
Expand Down
91 changes: 75 additions & 16 deletions runner/test/runner.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "no
import { tmpdir } from "node:os";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
import type { RunnerEvent, StartOptions } from "@codehands/contracts";
import type { Runner, RunnerEvent, StartOptions } from "@codehands/contracts";
import { afterAll, describe, expect, it, vi } from "vitest";
import { createRunner, type RunnerOptions } from "../src/index.js";

Expand Down Expand Up @@ -166,23 +166,35 @@ describe("runner diff, approve and reject", () => {
});
});

describe("runner.stop", () => {
// A run whose agent hangs after task.started, with a child of its own, like claude -> npm test.
async function stoppedRun() {
const repo = makeRepo();
const o = options(repo);
const pidFile = join(tmp, `child-${o.sessionId}.pid`);
const runner = newRunner({ agentCommand: replayAgent(spike, 0, "hang", pidFile) });
const events: RunnerEvent[] = [];
let stopping: Promise<void> | undefined;
for await (const e of runner.start(o)) {
events.push(e);
if (e.type === "task.started") stopping = runner.stop(o.sessionId);
}
await stopping;
return { repo, o, runner, events, childPid: Number(readFileSync(pidFile, "utf8")) };
// Reads a run to the end and calls stop() as soon as the agent has started.
async function readAndStop(runner: Runner, sessionId: string, run: AsyncIterable<RunnerEvent>) {
const events: RunnerEvent[] = [];
let stopping: Promise<void> | undefined;
for await (const e of run) {
events.push(e);
if (e.type === "task.started") stopping = runner.stop(sessionId);
}
await stopping;
return events;
}

// A run whose agent hangs after task.started, with a child of its own, like claude -> npm test.
async function stoppedRun(runnerOptions: RunnerOptions = {}) {
const repo = makeRepo();
const o = options(repo);
const pidFile = join(tmp, `child-${o.sessionId}.pid`);
const runner = newRunner({ agentCommand: replayAgent(spike, 0, "hang", pidFile), ...runnerOptions });
const events = await readAndStop(runner, o.sessionId, runner.start(o));
return { repo, o, runner, events, childPid: Number(readFileSync(pidFile, "utf8")) };
}

async function drain(run: AsyncIterable<RunnerEvent>) {
const events: RunnerEvent[] = [];
for await (const e of run) events.push(e);
return events;
}

describe("runner.stop", () => {
it("kills the agent and its children, and the stream ends with task.stopped", async () => {
const { events, childPid } = await stoppedRun();
expect(events.map((e) => e.type)).toEqual(["task.started", "task.stopped"]);
Expand All @@ -208,3 +220,50 @@ describe("runner.stop", () => {
await expect(other.stop(randomUUID())).rejects.toThrow(/unknown session/);
});
});

describe("runner.resume", () => {
const resumeWith = (mode = "", pidFile = ""): RunnerOptions["resumeCommand"] =>
() => ({ file: process.execPath, args: [replay, spike, "0", mode, pidFile] });

it("continues in the same worktree, seq carries on, and it ends awaiting approval", async () => {
const { repo, o, runner, events } = await stoppedRun({ resumeCommand: resumeWith("edit") });
const resumed = await drain(runner.resume(o.sessionId));

expect(resumed.at(-1)!.type).toBe("task.finished");
expect(resumed.at(-1)!.payload.diffStat).toMatch(/2 files changed/);
expect([...events, ...resumed].map((e) => e.seq)).toEqual([...events, ...resumed].map((_, i) => i + 1));

const { branch } = await runner.approve(o.sessionId, "Fix the failing test");
expect(git(repo, "show", "--name-only", "--format=", branch).split("\n").sort()).toEqual(["a.txt", "new.txt"]);
});

it("passes the prompt to the agent", async () => {
let seen = "";
const { o, runner } = await stoppedRun({
resumeCommand: (_o, prompt) => {
seen = prompt;
return { file: process.execPath, args: [replay, spike] };
},
});
await drain(runner.resume(o.sessionId, "Only fix the date test."));
expect(seen).toBe("Only fix the date test.");
});

it("can be stopped again, and numbering still carries on", async () => {
const pidFile = join(tmp, `resume-child-${randomUUID()}.pid`);
const { o, runner, events } = await stoppedRun({ resumeCommand: resumeWith("hang", pidFile) });
const resumed = await readAndStop(runner, o.sessionId, runner.resume(o.sessionId));

expect(resumed.map((e) => e.type)).toEqual(["task.started", "task.stopped"]);
expect(resumed.map((e) => e.seq)).toEqual([events.length + 1, events.length + 2]);
await vi.waitFor(() => expect(alive(Number(readFileSync(pidFile, "utf8")))).toBe(false), { timeout: 3000 });
});

it("refuses a session that is not stopped", async () => {
const o = options(makeRepo());
const runner = newRunner({ agentCommand: replayAgent(spike) });
await collect(o, {}, runner);
await expect(drain(runner.resume(o.sessionId))).rejects.toThrow(/illegal move/); // awaiting approval
await expect(drain(runner.resume(randomUUID()))).rejects.toThrow(/unknown session/);
});
});
Loading