diff --git a/README.md b/README.md index 1a75bf64..064f7bff 100644 --- a/README.md +++ b/README.md @@ -1525,7 +1525,7 @@ repository; where several repositories use one, the other commands take the ID. checkout, runs the command there, and once it exits kills any descendants it left behind. The command gets Quest's environment without Git's repository-location variables, such as `GIT_DIR` and `GIT_INDEX_FILE`, which a -Git hook would otherwise point at the repository it ran in; `-c` config is kept. When no other run remains in the checkout, it releases it: the +Git hook would otherwise point at the repository it ran in; `-c` config is kept. When no other run, nor a Claude Code session holding the checkout (see [Claude Code checkouts](#claude-code-checkouts)), remains, it releases it: the declared scratch is deleted and the checkout keeps only its source. Put a whole job, such as an agent session or a script that installs, builds, and tests, in one run, since the next run starts without scratch. @@ -1613,7 +1613,9 @@ entries into the file's `hooks`: "WorktreeCreate": [{ "hooks": [{ "type": "command", "command": "command -v quest >/dev/null || { echo 'quest is not on the PATH Claude Code runs hooks with: run nix develop --command claude in this repository, or install quest on that PATH' >&2; exit 1; }; quest worktree hook" }] }], "WorktreeRemove": [{ "hooks": [{ "type": "command", "command": "command -v quest >/dev/null || { echo 'quest is not on the PATH Claude Code runs hooks with: run nix develop --command claude in this repository, or install quest on that PATH' >&2; exit 1; }; quest worktree hook" }] }], "SubagentStart": [{ "hooks": [{ "type": "command", "command": "command -v quest >/dev/null || { echo 'quest is not on the PATH Claude Code runs hooks with: run nix develop --command claude in this repository, or install quest on that PATH' >&2; exit 1; }; quest worktree hook" }] }], - "SubagentStop": [{ "hooks": [{ "type": "command", "command": "command -v quest >/dev/null || { echo 'quest is not on the PATH Claude Code runs hooks with: run nix develop --command claude in this repository, or install quest on that PATH' >&2; exit 1; }; quest worktree hook" }] }] + "SubagentStop": [{ "hooks": [{ "type": "command", "command": "command -v quest >/dev/null || { echo 'quest is not on the PATH Claude Code runs hooks with: run nix develop --command claude in this repository, or install quest on that PATH' >&2; exit 1; }; quest worktree hook" }] }], + "SessionStart": [{ "hooks": [{ "type": "command", "command": "command -v quest >/dev/null || { echo 'quest is not on the PATH Claude Code runs hooks with: run nix develop --command claude in this repository, or install quest on that PATH' >&2; exit 1; }; quest worktree hook" }] }], + "SessionEnd": [{ "hooks": [{ "type": "command", "command": "command -v quest >/dev/null || { echo 'quest is not on the PATH Claude Code runs hooks with: run nix develop --command claude in this repository, or install quest on that PATH' >&2; exit 1; }; quest worktree hook", "timeout": 60 }] }] } } ``` @@ -1651,9 +1653,17 @@ agent ID, and a workflow agent's for its workflow run, so the hook binds a workflow agent's checkout to the first subagent that starts in it, the workflow agent itself. Another `SubagentStop` hook that keeps a subagent running defeats this: Claude continues the subagent without a `SubagentStart`, so it works on -without its scratch until it reinstalls it. A session's checkout, from -`--worktree`, `EnterWorktree`, a background session, or remote control, is not -released when the session stops; release it with `quest worktree release`. +without its scratch until it reinstalls it. + +A session's checkout, from `--worktree`, `EnterWorktree`, a background session, +or remote control, is held by the session that created it and by each session +that starts in it, and released the same way once the last of them ends, except +when `/clear` starts a new session in its place. A session that held it restores +it on resuming, as does any session starting in it, with the same note. A background session that sits idle never +ends, so its checkout stays held. Claude Code gives `SessionEnd` hooks at most a +minute, which the entry's `timeout` asks for; a release cut short or failed +leaves the checkout reserved with the failure in `quest worktree status`, until +a session starts in it or `quest worktree release` finishes it. Claude never removes a subagent's or workflow agent's checkout itself, so remove a finished one with `quest worktree remove`, which also recovers one diff --git a/quest/m0/README.md b/quest/m0/README.md index 2178b5f2..2aac6af4 100644 --- a/quest/m0/README.md +++ b/quest/m0/README.md @@ -105,6 +105,5 @@ ownership-manifest design: - [Codex cloud off](/quest/m0/codex-cloud-off.md) - automatic Codex cloud reviews stop once no open PR relies on them - [Codex project-config skip](/quest/m0/codex-project-config.md) - once Codex keeps a supported way to skip a project's `.codex/` layer, reviews keep the user's config - [Agents get the dev shell from user settings](/quest/m0/user-dev-shell/README.md) - maintainers' own Claude and Codex settings load the dev shell; repositories drop dev-shell hooks, Quest worktree hooks, and `nix develop` instructions, and `quest init` writes no hooks -- [Session checkouts released](/quest/m0/session-checkouts.md) - a Claude session's own checkout is released when the session ends and reclaimed on resume - [claude rm upstream report](/quest/m0/claude-rm-upstream.md) - posting approved: ask Claude Code to run `WorktreeRemove` instead of advising an unlock - [Three generic rules in the shared context](/quest/m0/context-rules.md) - `quest context` adds matching conventions, never loosening a flaky test, and short names under a namespace diff --git a/quest/m0/session-checkouts.md b/quest/m0/session-checkouts.md deleted file mode 100644 index 682136f6..00000000 --- a/quest/m0/session-checkouts.md +++ /dev/null @@ -1,53 +0,0 @@ -# [M] A Claude session's checkout is released when the session ends - -## Goal - -A checkout `quest worktree hook` made for a Claude Code session itself (not -a subagent), through `--worktree`, `--bg`, or agent view, is released when -the session ends and claimed again when it resumes, so session checkouts no -longer pile up the way Claude Code's own do. - -## Plan - -Decided 2026-11-02 (coordinator, /quest-spawn follow-ups, after the user -asked the coordinator to decide follow-ups from evidence): plan it, [M], m0. - -Evidence, gathered 2026-11-02: - -- [moq-dev/quest#425](https://github.com/moq-dev/quest/pull/425)'s live - probes found session checkouts never released by any route. -- Claude Code's own session worktrees (not `agent-*`) already accumulate: - 32 registered in moq.pro, 21 in moq, and 3 in quest (2026-10-14 to - 10-27), beside 449, 124, and 26 `agent-*` worktrees. Once the hooks own - these checkouts the same backlog would build in the registry. - -Work: - -- Add `SessionEnd` and `SessionStart` entries to the opt-in user-level hook - setup: the JSON block in the README's "Claude Code checkouts" section, which - SETUP.md's "Opt into worktree hooks" step points to, and `HOOK_EVENTS` in - `src/cli/worktree-hook.ts`. Amended 2026-11-03 (maintainer, /quest-unblock): - `quest init` writes no hook entries anymore, so this quest no longer - installs them through `init`. -- `SessionEnd` releases (never removes) the session's checkout; - `SessionStart` claims it again on resume. `/clear` also fires - `SessionEnd`, so release is idempotent and the next `SessionStart` - reclaims it. An idle background session never ends; leave it held. -- Remote control's `bridge-*` checkouts are session checkouts too, and the - release covers them if `SessionEnd` fires in a bridge-spawned session. - Whether it does is unverified: the maintainer dropped remote control's - live check on 2026-11-04 (/quest-unblock). Release only prunes the - declared scratch: it keeps every other file and Git's worktree lock - (`release` and `prune` in `src/cli/worktree.ts`; only `remove` unlocks). - The bridge still decides at session end: it removes a clean checkout - through `WorktreeRemove` and keeps one with changes. -- Claude Code bounds `SessionEnd` hooks by a short budget - (`CLAUDE_CODE_SESSIONEND_HOOKS_TIMEOUT_MS`) and does not wait past it. - Measure a release with scratch to delete, and set the entry's timeout to - cover it. A release that fails or is cut short leaves the checkout - reserved with its failure recorded, as `settle` does - (`src/cli/worktree.ts`), and the next `SessionStart` for that session, or - `quest worktree release` by hand, finishes it. Verify a killed release - this way too. -- Verify each route live as #425 did, from a session that is not itself - worktree-isolated. diff --git a/src/cli/main.ts b/src/cli/main.ts index 8661e615..08f0af8d 100644 --- a/src/cli/main.ts +++ b/src/cli/main.ts @@ -877,7 +877,7 @@ export function main(argv: string[], out: Output): number | Promise { .action((id: string) => record(state().restore(id))); worktree .command("hook") - .summary("Handle a Claude Code worktree or subagent hook event read on stdin") + .summary("Handle a Claude Code worktree, subagent, or session hook event read on stdin") .description( "Handle the Claude Code hook event whose JSON input arrives on stdin, for " + HOOK_EVENTS.join(", ") + @@ -891,7 +891,10 @@ export function main(argv: string[], out: Output): number | Promise { "restored instead. WorktreeRemove removes the checkout as `remove` does, so Claude keeps one holding " + "unpreserved work. SubagentStop releases the subagent's own checkout, named agent- by Claude, " + "or a workflow agent's, named for its run and bound to the first subagent that starts in it, " + - "when it finishes or stops to wait, keeping its .tmp/ workspace for the parent to read until removal, and SubagentStart restores it. Other checkouts are left alone. " + + "when it finishes or stops to wait, keeping its .tmp/ workspace for the parent to read until removal, and SubagentStart restores it. " + + "Every other checkout WorktreeCreate makes is a session's: SessionEnd releases it the same way once no session that " + + "created it or started in it is left, except when /clear starts another in its place, and SessionStart restores it. " + + "Other checkouts are left alone. " + "Exits 1 on failure, never 2, which would keep a subagent running.", ) .action(() => { diff --git a/src/cli/worktree-hook.ts b/src/cli/worktree-hook.ts index a651e050..bb1b6845 100644 --- a/src/cli/worktree-hook.ts +++ b/src/cli/worktree-hook.ts @@ -16,7 +16,14 @@ import { } from "./worktree"; /** The Claude Code events `quest worktree hook` handles. */ -export const HOOK_EVENTS = ["WorktreeCreate", "WorktreeRemove", "SubagentStart", "SubagentStop"] as const; +export const HOOK_EVENTS = [ + "WorktreeCreate", + "WorktreeRemove", + "SubagentStart", + "SubagentStop", + "SessionStart", + "SessionEnd", +] as const; /** * The guide's workspace, which holds an agent's reports and logs. Its parent reads them after it stops, @@ -34,6 +41,10 @@ interface HookInput { worktree_path?: string; /** SubagentStart and SubagentStop: the subagent's ID. */ agent_id?: string; + /** Every event: the Claude Code session it fires in, a subagent's parent session included. */ + session_id?: string; + /** SessionEnd: why the session ended, such as `clear` when `/clear` starts a new one in its place. */ + reason?: string; } function parse(input: unknown): HookInput { @@ -43,7 +54,7 @@ function parse(input: unknown): HookInput { return value as HookInput; } -function field(input: HookInput, key: "name" | "worktree_path" | "agent_id"): string { +function field(input: HookInput, key: "name" | "worktree_path" | "agent_id" | "session_id"): string { const value = input[key]; if (typeof value !== "string" || value === "") throw new Error(`${input.hook_event_name} input lacks ${key}`); return value; @@ -126,9 +137,36 @@ function superproject(checkout: string): string | undefined { return path ? canonical(path) : undefined; } +/** The registered checkout holding `cwd`, looking through the submodules it sits in. */ +async function enclosing(worktrees: Worktrees, cwd: string): Promise { + let path = toplevel(cwd); + let record: WorktreeRecord | undefined; + while (path && !record) { + const checkout = path; + record = await waiting(() => worktrees.registered(checkout)); + path = superproject(checkout); + } + return record; +} + /** Claude's name for a workflow agent's worktree, `-`, which carries no agent ID. */ const WORKFLOW_CHECKOUT = /^wf_[0-9a-f]{8}-[0-9a-f]{3}-[0-9]+$/; +/** Whether Claude made the checkout named `name` for a subagent or workflow agent rather than a session. */ +function forSubagent(name: string): boolean { + return name.startsWith("agent-") || WORKFLOW_CHECKOUT.test(name); +} + +/** The context telling an agent that a release deleted `scratch` from its checkout while it was stopped. */ +function reinstall(event: string, scratch: readonly string[]): string | undefined { + const deleted = scratch.filter((path) => path !== WORKSPACE); + if (!deleted.length) return undefined; + const additionalContext = + `Quest released this checkout while you were stopped, deleting its ignored scratch (${deleted.join(", ")}). ` + + "Reinstall or rebuild what you need before using it."; + return `${JSON.stringify({ hookSpecificOutput: { hookEventName: event, additionalContext } })}\n`; +} + /** * The checkout Claude created for this subagent. Claude names a subagent's worktree `agent-`, * so a subagent that runs without isolation in another agent's checkout never matches it. A workflow @@ -136,13 +174,7 @@ const WORKFLOW_CHECKOUT = /^wf_[0-9a-f]{8}-[0-9a-f]{3}-[0-9]+$/; * it, which is the workflow agent itself; one that later runs there without isolation never matches. */ async function subagentCheckout(worktrees: Worktrees, input: HookInput): Promise { - let path = toplevel(input.cwd); - let record: WorktreeRecord | undefined; - while (path && !record) { - const checkout = path; - record = await waiting(() => worktrees.registered(checkout)); - path = superproject(checkout); - } + const record = await enclosing(worktrees, input.cwd); const agent = field(input, "agent_id"); if (record?.name === `agent-${agent}`) return record; if (record === undefined || !WORKFLOW_CHECKOUT.test(record.name)) return undefined; @@ -157,7 +189,9 @@ async function subagentCheckout(worktrees: Worktrees, input: HookInput): Promise * `.worktrees/`, with derived defaults, and prints its path; an existing checkout of that name in the * repository is restored instead. * WorktreeRemove removes the checkout after recursive safety checks. SubagentStop releases the subagent's own - * checkout, and SubagentStart restores it, telling the subagent when its scratch was deleted. + * checkout, and SubagentStart restores it, telling the subagent when its scratch was deleted. A checkout made + * for a session rather than a subagent is held by each session that creates it or starts in it, and SessionEnd + * releases it once the last ends; SessionStart claims it again, likewise telling the session. */ export async function claudeHook( input: unknown, @@ -188,11 +222,14 @@ export async function claudeHook( if (existing?.name === name) { // Claude reuses a name, such as a resumed workflow run's, for an agent that binds afresh. await waiting(() => worktrees.bind(existing.id)); + if (!forSubagent(name)) await waiting(() => worktrees.attach(existing.id, field(hook, "session_id"))); return `${(await waiting(() => worktrees.restore(existing.id))).path}\n`; } const { reserve, scratch } = derivedDefaults(main); const ref = pullRequest(cwd, name) ?? base(cwd); - return `${(await waiting(() => worktrees.create(main, name, reserve, ref, scratch))).path}\n`; + const created = await waiting(() => worktrees.create(main, name, reserve, ref, scratch)); + if (!forSubagent(name)) await waiting(() => worktrees.attach(created.id, field(hook, "session_id"))); + return `${created.path}\n`; } case "WorktreeRemove": { const path = canonical(field(hook, "worktree_path")); @@ -206,19 +243,32 @@ export async function claudeHook( case "SubagentStart": { const record = await subagentCheckout(worktrees, hook); if (!record) return undefined; - const deleted = record.phase === "released" ? record.scratch.filter((path) => path !== WORKSPACE) : []; await waiting(() => worktrees.restore(record.id)); - if (!deleted.length) return undefined; - const additionalContext = - `Quest released this checkout while you were stopped, deleting its ignored scratch (${deleted.join(", ")}). ` + - "Reinstall or rebuild what you need before using it."; - return `${JSON.stringify({ hookSpecificOutput: { hookEventName: "SubagentStart", additionalContext } })}\n`; + return record.phase === "released" ? reinstall("SubagentStart", record.scratch) : undefined; } case "SubagentStop": { const record = await subagentCheckout(worktrees, hook); if (record) await waiting(() => worktrees.release(record.id, [WORKSPACE])); return undefined; } + case "SessionStart": { + const session = field(hook, "session_id"); + // Resuming starts in the directory Claude was launched from before it enters the session's checkout. + const ids = new Set((await waiting(() => worktrees.held(session))).map((record) => record.id)); + const here = await enclosing(worktrees, hook.cwd); + if (here?.sessions) ids.add(here.id); + const deleted = new Set(); + for (const id of ids) { + const before = await waiting(() => worktrees.attach(id, session)); + // A failure means a release may have deleted part of the scratch before it failed or was cut short. + if (before.phase === "released" || before.failure) for (const item of before.scratch) deleted.add(item); + } + return reinstall("SessionStart", [...deleted]); + } + case "SessionEnd": + // `/clear` ends the session only to start another in the same checkout, which claims it then. + await worktrees.detach(field(hook, "session_id"), hook.reason !== "clear", [WORKSPACE]); + return undefined; default: throw new Error(`unsupported hook event: ${hook.hook_event_name}`); } diff --git a/src/cli/worktree.ts b/src/cli/worktree.ts index cee70aaa..c8e5c532 100644 --- a/src/cli/worktree.ts +++ b/src/cli/worktree.ts @@ -46,6 +46,12 @@ export interface WorktreeRecord { failure?: string; /** The Claude Code subagent bound to this checkout by `quest worktree hook`, when Claude's name for it carries none. */ agent?: string; + /** + * The Claude Code sessions that created or started in a checkout `quest worktree hook` made for a session + * rather than a subagent, by ID, and whether each still runs; absent on every other checkout. The last + * one to end releases it. An ended one stays, since resuming it starts outside the checkout. + */ + sessions?: Record; } interface Registry { version: 1; @@ -188,6 +194,11 @@ function footprint(path: string): number { return diskUsage(path, seen) + (admin ? diskUsage(join(admin, "modules"), seen) : 0); } +/** Whether a Claude Code session still runs in the checkout. */ +function inSession(record: WorktreeRecord): boolean { + return Object.values(record.sessions ?? {}).some(Boolean); +} + function interrupted(record: WorktreeRecord): void { if (record.phase === "creating" && record.creator && alive(record.creator)) throw new Error( @@ -496,12 +507,12 @@ export class Worktrees { return true; } } - /** Drops finished runs a crashed supervisor left behind, and releases a checkout none remain in. */ + /** Drops finished runs a crashed supervisor left behind, and releases a checkout no run or session remains in. */ private reconcile(registry: Registry): void { for (const record of [...registry.records]) { if (!record.runs.length) continue; record.runs = record.runs.filter((run) => this.running(run)); - if (!record.runs.length) this.settle(registry, record); + if (!record.runs.length && !inSession(record)) this.settle(registry, record); } } private idle(record: WorktreeRecord): void { @@ -510,10 +521,10 @@ export class Worktrees { `supervised runs are live in this checkout: ${record.runs.map((run) => run.unit).join(", ")}`, ); } - /** Releases a checkout whose last run stopped, keeping it charged and noting why on failure. */ - private settle(registry: Registry, record: WorktreeRecord): void { + /** Releases a checkout whose last run or session stopped, keeping it charged and noting why on failure. */ + private settle(registry: Registry, record: WorktreeRecord, keep: readonly string[] = []): void { try { - this.prune(registry, record); + this.prune(registry, record, keep); delete record.failure; } catch (error) { record.failure = `release failed; the checkout stays reserved: ${message(error)}`; @@ -860,6 +871,52 @@ export class Worktrees { return record; }); } + /** The checkouts a Claude Code session created or started in. */ + held(session: string): WorktreeRecord[] { + return this.locked((registry) => registry.records.filter((record) => record.sessions?.[session] !== undefined)); + } + /** + * Marks a Claude Code session running in the checkout, which makes it a session's checkout, and reserves + * it again. Returns the record as it was, whose phase and failure tell whether a release deleted scratch. + */ + attach(id: string, session: string): WorktreeRecord { + return this.locked((registry) => { + const record = this.find(registry, id); + if (!this.owned(record)) throw new Error(`checkout is missing; release drops its record: ${record.path}`); + const before = structuredClone(record); + this.activate(registry, record); + record.sessions = { ...record.sessions, [session]: true }; + delete record.failure; + return before; + }); + } + /** + * Marks a session ended in every checkout it runs in. Unless `release` is false, a checkout no session or + * supervised run still uses is then released, keeping `keep`. The release is recorded as a failure + * first, so one cut short leaves the checkout reserved and saying so until a release or session finishes it. + */ + async detach(session: string, release: boolean, keep: readonly string[] = []): Promise { + const pending = await this.lockedWait((registry) => + registry.records.flatMap((record) => { + if (!record.sessions?.[session]) return []; + record.sessions[session] = false; + if (!release || inSession(record) || record.runs.length || record.phase !== "active") return []; + record.failure = + "release was cut short when its last Claude Code session ended; the checkout stays reserved until " + + "`quest worktree release` or a session starting in it finishes"; + return [record.id]; + }), + ); + // One lock per checkout, so concurrent hooks wait out a short critical section rather than every release. + for (const id of pending) + await this.lockedWait((registry) => { + const record = registry.records.find((item) => item.id === id); + // A session or supervised run that started in between keeps the checkout. + if (!record) return; + if (!inSession(record) && !record.runs.length) this.settle(registry, record, keep); + else delete record.failure; + }); + } private activate(registry: Registry, record: WorktreeRecord): void { if (record.phase === "active") return; if (record.phase !== "released") throw new Error(`cannot use a ${record.phase} checkout`); @@ -897,7 +954,8 @@ export class Worktrees { retained.failure = result.error.message; else current.runs = current.runs.filter((item) => item.unit !== run.unit); // A command that never started, such as on a host without a user manager, keeps its scratch. - if (!current.runs.length && "code" in result) this.settle(registry, current); + // A Claude Code session still in the checkout keeps its scratch; the session's end releases it. + if (!current.runs.length && !inSession(current) && "code" in result) this.settle(registry, current); return current.failure; }); if ("error" in result) throw result.error; diff --git a/test/worktree.test.ts b/test/worktree.test.ts index c14037b0..2b937a10 100644 --- a/test/worktree.test.ts +++ b/test/worktree.test.ts @@ -334,14 +334,14 @@ describe("worktrees", { timeout: 20_000 }, () => { // Git checks text out with CRLF where core.autocrlf is set, as on Windows runners. expect(readFileSync(join(nested, "file"), "utf8").replaceAll("\r\n", "\n")).toBe("original\n"); const direct = (await claudeHook( - { hook_event_name: "WorktreeCreate", cwd: nested, name: "direct" }, + { hook_event_name: "WorktreeCreate", session_id: "session", cwd: nested, name: "direct" }, worktrees, async () => false, nested, ))!.trim(); expect(direct).toBe(join(canonical(nested), ".worktrees", "direct")); const child = (await claudeHook( - { hook_event_name: "WorktreeCreate", cwd: nested, name: "agent-child" }, + { hook_event_name: "WorktreeCreate", session_id: "session", cwd: nested, name: "agent-child" }, worktrees, async () => false, repo, @@ -363,7 +363,7 @@ describe("worktrees", { timeout: 20_000 }, () => { // A workflow agent that starts inside a submodule of its checkout binds it there too. const flow = "wf_04c8d372-2e7-1"; const workflow = (await claudeHook( - { hook_event_name: "WorktreeCreate", cwd: nested, name: flow }, + { hook_event_name: "WorktreeCreate", session_id: "session", cwd: nested, name: flow }, worktrees, async () => false, repo, @@ -378,6 +378,21 @@ describe("worktrees", { timeout: 20_000 }, () => { phase: "released", agent: "flow", }); + // A session starting inside a submodule of a session's checkout holds the checkout too. + const owned = (await claudeHook( + { hook_event_name: "WorktreeCreate", session_id: "a", cwd: repo, name: "owned" }, + worktrees, + async () => false, + ))!.trim(); + await claudeHook( + { hook_event_name: "SessionStart", session_id: "b", cwd: join(owned, "sub", "nested", "inner") }, + worktrees, + async () => false, + ); + expect(worktrees.status().records.find((record) => record.name === "owned")?.sessions).toEqual({ + a: true, + b: true, + }); writeFileSync(join(nested, "file"), "local work"); mkdirSync(join(nested, "cache")); writeFileSync(join(nested, "cache", "output"), "build output"); @@ -399,7 +414,7 @@ describe("worktrees", { timeout: 20_000 }, () => { vi.stubEnv("GIT_WORK_TREE", module); vi.stubEnv("GIT_INDEX_FILE", join(store, "index")); const path = (await claudeHook( - { hook_event_name: "WorktreeCreate", cwd: module, name: "agent-located" }, + { hook_event_name: "WorktreeCreate", session_id: "session", cwd: module, name: "agent-located" }, worktrees, async () => false, repo, @@ -692,6 +707,26 @@ describe("supervised runs", { timeout: 20_000 }, () => { expect(existsSync(join(record.path, "cache"))).toBe(false); expect(worktrees.status().records[0]).toMatchObject({ phase: "released", runs: [] }); }); + it("leaves a checkout a Claude Code session holds to the session's end", async () => { + const { finish, live, scopes } = fakeScopes(); + const { repo, home, worktrees } = fixture(scopes); + const record = await worktrees.create(repo, "work", 10_000_000, "HEAD", ["cache"]); + worktrees.attach(record.id, "session"); + mkdirSync(join(record.path, "cache")); + const running = worktrees.run(record.id, ["build"]); + const [{ unit }] = worktrees.status().records[0].runs; + finish.get(unit)!(0); + await running; + // A crashed supervisor's run, once its scope empties, leaves it held too. + orphan(home, "crashed.scope"); + live.add("crashed.scope"); + worktrees.status(); + live.delete("crashed.scope"); + expect(worktrees.status().records[0]).toMatchObject({ phase: "active", runs: [] }); + expect(existsSync(join(record.path, "cache"))).toBe(true); + await worktrees.detach("session", true); + expect(existsSync(join(record.path, "cache"))).toBe(false); + }); it("reconciles a crashed supervisor only once its scope is empty", async () => { const { live, scopes } = fakeScopes(); const { repo, home, worktrees } = fixture(scopes); @@ -909,7 +944,7 @@ describe("Claude Code hooks", { timeout: 20_000 }, () => { git(repo, "worktree", "add", "--detach", session); writeFileSync(join(session, "file"), "session\n"); git(session, "commit", "-am", "session work"); - const created = { hook_event_name: "WorktreeCreate", cwd: session, name: "agent-1" }; + const created = { hook_event_name: "WorktreeCreate", session_id: "session", cwd: session, name: "agent-1" }; const path = (await claudeHook(created, worktrees, noForge))!.trim(); expect(path).toBe(join(canonical(repo), ".worktrees", "agent-1")); expect(git(path, "rev-parse", "HEAD")).toBe(git(session, "rev-parse", "HEAD")); @@ -938,7 +973,7 @@ describe("Claude Code hooks", { timeout: 20_000 }, () => { mkdirSync(join(repo, "cache")); mkdirSync(join(repo, ".tmp")); appendFileSync(join(repo, ".git", "info", "exclude"), ".tmp/\n"); - const create = { hook_event_name: "WorktreeCreate", cwd: repo, name: "agent-abc" }; + const create = { hook_event_name: "WorktreeCreate", session_id: "session", cwd: repo, name: "agent-abc" }; const path = (await claudeHook(create, worktrees, noForge))!.trim(); mkdirSync(join(path, "cache", "nested"), { recursive: true }); mkdirSync(join(path, ".tmp")); @@ -969,10 +1004,87 @@ describe("Claude Code hooks", { timeout: 20_000 }, () => { expect(existsSync(path)).toBe(false); expect(worktrees.status().records).toHaveLength(0); }); + it("holds a session's checkout while a session uses it and releases it when the last ends", async () => { + const { repo, worktrees } = fixture(); + mkdirSync(join(repo, "cache")); + mkdirSync(join(repo, ".tmp")); + appendFileSync(join(repo, ".git", "info", "exclude"), ".tmp/\n"); + const event = (hook_event_name: string, cwd: string, session_id: string, extra = {}) => + claudeHook({ hook_event_name, cwd, session_id, ...extra }, worktrees, noForge); + const path = (await event("WorktreeCreate", repo, "a", { name: "feature" }))!.trim(); + const record = () => worktrees.status().records.find((item) => item.name === "feature")!; + const scratch = () => { + mkdirSync(join(path, "cache", "deep"), { recursive: true }); + mkdirSync(join(path, ".tmp"), { recursive: true }); + writeFileSync(join(path, ".tmp", "log"), "kept\n"); + }; + scratch(); + expect(record().sessions).toEqual({ a: true }); + // The session that created it starts in it, and a second one joins. + expect(await event("SessionStart", join(path, "cache"), "a")).toBeUndefined(); + expect(await event("SessionStart", path, "b")).toBeUndefined(); + expect(record().sessions).toEqual({ a: true, b: true }); + // Ending a session that holds nothing, or one of two, keeps the scratch. + await event("SessionEnd", repo, "other"); + await event("SessionEnd", repo, "a"); + expect(existsSync(join(path, "cache"))).toBe(true); + // /clear hands the checkout to the session it starts, without a release. + await event("SessionEnd", path, "b", { reason: "clear" }); + expect(existsSync(join(path, "cache"))).toBe(true); + expect(await event("SessionStart", path, "c", { source: "clear" })).toBeUndefined(); + // The last session's end releases it from wherever it ended, keeping the workspace. + await event("SessionEnd", repo, "c", { reason: "prompt_input_exit" }); + expect(existsSync(join(path, "cache"))).toBe(false); + expect(readFileSync(join(path, ".tmp", "log"), "utf8")).toBe("kept\n"); + expect(record()).toMatchObject({ phase: "released", sessions: { a: false, b: false, c: false } }); + expect(record().failure).toBeUndefined(); + // Resuming starts where Claude was launched, then claims it again and says what to reinstall. + const resumed = await event("SessionStart", repo, "c", { source: "resume" }); + expect(JSON.parse(resumed!).hookSpecificOutput).toMatchObject({ + hookEventName: "SessionStart", + additionalContext: expect.stringContaining("(cache)"), + }); + expect(record().phase).toBe("active"); + expect(record().sessions?.c).toBe(true); + // A release cut short leaves its failure recorded and the checkout reserved; the next session finishes it. + scratch(); + const registry = join(worktrees.home, "registry.json"); + const saved = JSON.parse(readFileSync(registry, "utf8")); + const cut = saved.records.find((item: { name: string }) => item.name === "feature"); + cut.sessions = { c: false }; + cut.failure = "release was cut short"; + writeFileSync(registry, JSON.stringify(saved)); + rmSync(join(path, "cache", "deep"), { recursive: true }); + expect(record().phase).toBe("active"); + const finished = await event("SessionStart", path, "d"); + expect(JSON.parse(finished!).hookSpecificOutput.additionalContext).toContain("(cache)"); + expect(record().failure).toBeUndefined(); + // A session in a checkout the hook did not make for a session holds nothing. + const sub = (await claudeHook( + { hook_event_name: "WorktreeCreate", session_id: "session", cwd: repo, name: "agent-x" }, + worktrees, + noForge, + ))!.trim(); + const manual = (await worktrees.create(repo, "manual", 1_000_000, "HEAD", ["cache"])).path; + for (const checkout of [sub, manual]) { + mkdirSync(join(checkout, "cache"), { recursive: true }); + expect(await event("SessionStart", checkout, "e")).toBeUndefined(); + await event("SessionEnd", checkout, "e"); + expect(existsSync(join(checkout, "cache"))).toBe(true); + } + expect(worktrees.status().records.filter((item) => item.sessions)).toHaveLength(1); + // Its last session ending releases it again. + await event("SessionEnd", path, "d"); + expect(record().phase).toBe("released"); + }); it("checks out a pull request's head from origin for Claude's pr- names", async () => { const { root, repo, worktrees } = fixture(); const create = (name: string) => - claudeHook({ hook_event_name: "WorktreeCreate", cwd: repo, name }, worktrees, noForge); + claudeHook( + { hook_event_name: "WorktreeCreate", session_id: "session", cwd: repo, name }, + worktrees, + noForge, + ); // Without an origin, a pr- name is only a name. const plain = (await create("pr-1"))!.trim(); expect(git(plain, "rev-parse", "HEAD")).toBe(git(repo, "rev-parse", "HEAD")); @@ -1004,7 +1116,7 @@ describe("Claude Code hooks", { timeout: 20_000 }, () => { const { repo, worktrees } = fixture(); mkdirSync(join(repo, "cache")); const name = "wf_04c8d372-2e7-1"; - const create = { hook_event_name: "WorktreeCreate", cwd: repo, name }; + const create = { hook_event_name: "WorktreeCreate", session_id: "session", cwd: repo, name }; const path = (await claudeHook(create, worktrees, noForge))!.trim(); mkdirSync(join(path, "cache")); await claudeHook(subagent("SubagentStart", path, "a79f"), worktrees, noForge); @@ -1030,7 +1142,7 @@ describe("Claude Code hooks", { timeout: 20_000 }, () => { }); it("removes a finished checkout, never forcing, and refuses one it does not own", async () => { const { root, repo, worktrees } = fixture(); - const create = { hook_event_name: "WorktreeCreate", cwd: repo, name: "session" }; + const create = { hook_event_name: "WorktreeCreate", session_id: "session", cwd: repo, name: "session" }; const path = (await claudeHook(create, worktrees, noForge))!.trim(); const remove = { hook_event_name: "WorktreeRemove", cwd: repo, worktree_path: path }; writeFileSync(join(path, "new"), "untracked work"); @@ -1051,9 +1163,9 @@ describe("Claude Code hooks", { timeout: 20_000 }, () => { await expect(claudeHook({ hook_event_name: "Stop", cwd: repo }, worktrees, noForge)).rejects.toThrow( "unsupported hook event", ); - await expect(claudeHook({ hook_event_name: "WorktreeCreate", cwd: repo }, worktrees, noForge)).rejects.toThrow( - "lacks name", - ); + await expect( + claudeHook({ hook_event_name: "WorktreeCreate", session_id: "session", cwd: repo }, worktrees, noForge), + ).rejects.toThrow("lacks name"); }); it("waits out another holder of the registry lock instead of failing", async () => { const { repo, home, worktrees } = fixture(); @@ -1062,7 +1174,7 @@ describe("Claude Code hooks", { timeout: 20_000 }, () => { writeFileSync(join(lock, "live.json"), JSON.stringify(currentOwner())); let settled = false; const created = claudeHook( - { hook_event_name: "WorktreeCreate", cwd: repo, name: "agent-wait" }, + { hook_event_name: "WorktreeCreate", session_id: "session", cwd: repo, name: "agent-wait" }, worktrees, noForge, ); @@ -1095,13 +1207,22 @@ describe("Claude Code hooks", { timeout: 20_000 }, () => { env: { ...process.env, QUEST_WORKTREE_HOME: home }, }, ); - const created = cli(JSON.stringify({ hook_event_name: "WorktreeCreate", cwd: repo, name: "agent-cli" })); + const created = cli( + JSON.stringify({ hook_event_name: "WorktreeCreate", session_id: "session", cwd: repo, name: "agent-cli" }), + ); expect(created.status).toBe(0); expect(created.stdout).toBe(`${join(canonical(repo), ".worktrees", "agent-cli")}\n`); expect(cli("not json").status).toBe(1); - expect(cli(JSON.stringify({ hook_event_name: "WorktreeCreate", cwd: repo, name: "agent-cli/x" })).status).toBe( - 1, - ); + expect( + cli( + JSON.stringify({ + hook_event_name: "WorktreeCreate", + session_id: "session", + cwd: repo, + name: "agent-cli/x", + }), + ).status, + ).toBe(1); }); it.skipIf(process.platform === "win32")("the documented entries say so when quest is not on PATH", () => { const readme = readFileSync(join(source, "README.md"), "utf8");