From db42fcabd12dde33d030dec49bb6ebbe1e502dea Mon Sep 17 00:00:00 2001 From: imtahirabatool Date: Thu, 29 Oct 2026 12:37:37 +0500 Subject: [PATCH] resume() continues a stopped session in the same worktree --- README.md | 4 +- runner/scripts/try.ts | 28 +++++++++--- runner/src/runner.ts | 22 ++++++--- runner/test/runner.test.ts | 91 +++++++++++++++++++++++++++++++------- 4 files changed, 114 insertions(+), 31 deletions(-) diff --git a/README.md b/README.md index dfc212e..144264b 100644 --- a/README.md +++ b/README.md @@ -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 ` 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 | @@ -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 | diff --git a/runner/scripts/try.ts b/runner/scripts/try.ts index 3ac1ce1..f3c2cfe 100644 --- a/runner/scripts/try.ts +++ b/runner/scripts/try.ts @@ -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("--")); @@ -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 | undefined; +async function print(run: AsyncIterable, 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); diff --git a/runner/src/runner.ts b/runner/src/runner.ts index e15eca7..a6a1231 100644 --- a/runner/src/runner.ts +++ b/runner/src/runner.ts @@ -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"; @@ -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>(); @@ -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) { diff --git a/runner/test/runner.test.ts b/runner/test/runner.test.ts index 424d3f0..40c7d97 100644 --- a/runner/test/runner.test.ts +++ b/runner/test/runner.test.ts @@ -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"; @@ -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 | 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) { + const events: RunnerEvent[] = []; + let stopping: Promise | 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) { + 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"]); @@ -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/); + }); +});