/* * `ensure_worktree` — the worktree invariant as a verb. * * `git worktree add` lives on five cards as a RECIPE, which means it is followed * from memory and skipped under pressure. The failure it prevents is not * theoretical: a shared checkout is how two agents' edits land in one tree, and * the primary checkout is the one everybody reaches for because it is the path * they already have. * * CUT FROM `origin/`, NEVER A LOCAL BRANCH. A local `main` is whatever the * last person left there; `origin/` is what everyone else will merge into. * This is the same rule as `land`'s target-tip check, one step earlier: the * question is always "what does the thing I am merging into have?". */ import { execFileSync } from "node:child_process"; import { existsSync, realpathSync } from "node:fs"; import path from "node:path"; import { z } from "zod"; /** * Compare paths by their REAL path, never by string. * * git reports `/private/var/...` where a caller passes `/var/...` — macOS's * symlink — so `path.resolve` comparison silently MISSES the primary and the * guard that refuses it never fires. The same symlink cost a carrier check its * main-module guard earlier today: it exited 0 having done nothing. */ const samePath = (a: string, b: string): boolean => { const real = (x: string) => { try { return realpathSync(x); } catch { return path.resolve(x); } }; return real(a) === real(b); }; const git = (repo: string, args: string[]): string => execFileSync("git", args, { cwd: repo, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim(); /** Every worktree git knows about: `{path, branch, bare}` in list order. The * FIRST entry is the primary checkout — that is what `--porcelain` guarantees. */ export function listWorktrees(repo: string): { path: string; branch: string | null }[] { const out: { path: string; branch: string | null }[] = []; let cur: { path: string; branch: string | null } | null = null; for (const line of git(repo, ["worktree", "list", "--porcelain"]).split("\n")) { if (line.startsWith("worktree ")) { if (cur) out.push(cur); cur = { path: line.slice(9), branch: null }; } else if (line.startsWith("branch ") && cur) { cur.branch = line.slice(7).replace(/^refs\/heads\//, ""); } } if (cur) out.push(cur); return out; } /** The primary checkout — the tree nobody may be given for a slice. */ export function primaryOf(repo: string): string | null { const all = listWorktrees(repo); return all.length ? (all[0] as { path: string }).path : null; } const isDirty = (repo: string): boolean => { try { return git(repo, ["status", "--porcelain"]).length > 0; } catch { return true; // unreadable is not clean } }; export const ensureWorktreeSchema = { agentId: z.string().min(1), repo: z.string().min(1), base: z.string().min(1), task: z.string().optional(), ephemeral: z.boolean().optional(), parent: z.string().optional(), }; export async function ensureWorktreeTool(args: { agentId: string; repo: string; base: string; task?: string; ephemeral?: boolean; parent?: string; }) { const { agentId, repo, base } = args; if (!path.isAbsolute(repo)) return { ok: false as const, error: `repo must be an absolute path, got '${repo}'` }; if (!existsSync(repo)) return { ok: false as const, error: `no such repo: ${repo}` }; let primary: string | null; try { primary = primaryOf(repo); } catch (e) { return { ok: false as const, error: `not a git repository (${String((e as Error).message).split("\n")[0]})` }; } // `repo` IS the primary checkout in normal use — that is how you address the // repository. What must never happen is HANDING AN AGENT THE PRIMARY as its // slice tree, so the refusal is on the computed TARGET, not on the input. // // Reading spec 1.4 literally ("calling with the primary path exits non-zero") // would refuse every call and make the verb unusable, since `repo` is always // the primary. The invariant it protects is the one implemented here: two // agents editing one tree. Flagged in the PR rather than silently chosen. return await create({ ...args, primary: primary ?? repo }); } async function create(a: { agentId: string; repo: string; base: string; task?: string; ephemeral?: boolean; parent?: string; primary: string; }) { const { repo, base, agentId, primary } = a; // The BASE must exist as a REMOTE ref. A missing `origin/` is refused // rather than silently falling back to a local branch of the same name — the // local one is whatever the last person left there. const ref = `origin/${base}`; let sha: string; try { git(repo, ["fetch", "--quiet", "origin", base]); } catch { /* offline or no remote: fall through to the rev-parse, which is the real test */ } try { sha = git(repo, ["rev-parse", "--verify", `${ref}^{commit}`]); } catch { return { ok: false as const, error: `${ref} does not resolve — refusing to cut a tree from a local '${base}', which is whatever the last person left there. Fetch the remote, or name a base that exists on origin.`, }; } // ONE PROJECT PARENT: trees live beside the primary, named for the agent, so a // stray tree is identifiable without opening it. const parent = a.parent ?? path.dirname(primary); const leaf = `${path.basename(primary)}-${agentId}${a.ephemeral ? "-ephemeral" : ""}`; const target = path.join(parent, leaf); const branch = a.task ? `${agentId}/${a.task}` : `${agentId}/work`; // DEFENCE IN DEPTH, AND IT IS CURRENTLY UNREACHABLE — stated because a guard // that cannot fire is worth nothing until someone knows it cannot. // // The leaf is always `-`, so the target can never // equal the primary while that naming holds. I tried to write a test that // reaches this branch and could not without symlink contrivance; the honest // conclusion is that the NAMING is the real invariant and this is a backstop // for the day someone changes it. It is kept rather than deleted for exactly // that day, and labelled rather than left looking load-bearing. // // What actually protects the invariant is tested instead: the target always // differs from the primary, for any agent id. if (samePath(target, primary)) { return { ok: false as const, error: `refusing to hand back the PRIMARY checkout (${primary}) as a slice tree — that is the path everyone already has, ` + `so it is the one two agents end up editing at once. Pass a different parent or agentId.`, }; } const existing = listWorktrees(repo).find((w) => samePath(w.path, target)); if (existing) { // IDEMPOTENT, and it reports what it FOUND rather than what it would have // made: a verb that silently returns a tree on a different branch than asked // for is the adjacent-answer shape. const head = git(existing.path, ["rev-parse", "HEAD"]); // IS THE REUSED TREE ACTUALLY AT THE BASE IT CLAIMS? // // The verb warned when an existing tree was on a different BRANCH and said // nothing about it being behind the BASE — so a tree left on last week's // main was handed back as ready. Starting a new task there produces the // "stale main -> confidently-wrong inventories" failure the worker card // already warns about, and nothing about the result looks stale. // // Reported here, not refused: an existing tree legitimately holds // in-progress work mid-slice (1.3). The refusal belongs to `claim`, which // is the verb that means "start something new". let atBase = false; let behindBy: number | null = null; try { const baseSha = git(repo, ["rev-parse", "--verify", `${ref}^{commit}`]); atBase = head === baseSha; if (!atBase) behindBy = Number(git(existing.path, ["rev-list", "--count", `HEAD..${ref}`])) || 0; } catch { // NOT MEASURED IS NOT AT-BASE. Leaving `atBase` false is the safe // direction: it makes `claim` ask rather than assume. atBase = false; } return { ok: true as const, path: existing.path, sha: head, branch: existing.branch, created: false, base: ref, atBase, behindBy: behindBy ?? 0, ...(existing.branch !== branch ? { warning: `existing tree is on '${existing.branch}', not the '${branch}' this call would have created — reusing it, NOT re-pointing it` } : {}), }; } try { git(repo, ["worktree", "add", "-q", "-b", branch, target, sha]); } catch (e) { return { ok: false as const, error: `git worktree add failed: ${String((e as Error).message).split("\n")[0]}` }; } // ⟨q-7b2f6c04⟩ — THE EMPTY PUSH AT CLAIM. The branch goes to origin at the // base's tip, so the lane has a ref on the remote from its first minute: the // VCS axis sees it, and the stall clock scores it on the CLAIM axis (a ref // sitting on the base has no commits of its own to date). Best-effort and // REPORTED, never fatal — a tree cut offline is still a tree. let pushed: { ok: true; ref: string } | { ok: false; why: string }; try { execFileSync("git", ["push", "-q", "-u", "origin", `${branch}:refs/heads/${branch}`], { cwd: repo, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], timeout: 20_000 }); pushed = { ok: true, ref: `origin/${branch}` }; } catch (e) { pushed = { ok: false, why: `the empty claim-time push did not land: ${String((e as Error).message).split("\n").find((l) => l.trim()) ?? "unknown"} — the lane is observable on the claim axis from the board's history only` }; } return { ok: true as const, path: target, sha, branch, created: true, pushed, base: ref, // Cut from `sha`, which IS origin/ — true by construction here, and // stated so callers need not special-case "created" to know it. atBase: true, behindBy: 0, ...(a.ephemeral ? { ephemeral: true, // A VERB NAME, NOT A COMMAND LINE. This used to hand back the shell to // paste — the tool knew the repo, path and branch and spent the agent's // tokens on work it could do itself. releaseWith: { verb: "release_worktree", args: { repo, path: target, apply: true } }, } : {}), }; } // ---------- shared: is this tree's work already in the base? ---------- /** * Are the commits HEAD has beyond `tip` ALREADY LANDED there? * * `rev-list --count ..HEAD` is pure ANCESTRY, and under squash merge a landed * branch's own shas never enter the base's history — so `ahead` stays > 0 FOREVER * for work that shipped (q-5c40db91; 20 of the last 20 merges on this repo's main * are single-parent). Reading `ahead > 0` as "this tree holds unlanded work" makes * the verb whose job is fast-forwarding idle trees classify a FINISHED tree as * mid-slice, permanently. * * MEASURED, and it cost a lane cycle: a `claim` was refused because a worker's tree * "reads as 1 commit not on origin/main" while that branch had been squash-merged an * hour earlier. Same inverted alarm as the item's headline — landed work reported as * unmerged, inviting preservation of work already shipped — inside our own tooling. * * `git cherry` compares PATCH IDS, so every line marked `-` means every commit's patch * is already upstream. It is NOT complete: several commits squashed into one carry a * different combined patch id, so a negative is INCONCLUSIVE and is reported as * mid-slice exactly as before. Only a positive changes the verdict, which keeps the * failure direction the same as today's for everything this cannot prove. */ function aheadIsLanded(at: string, tip: string): boolean { // `git` here THROWS rather than returning null, so the catch is what keeps an // UNREADABLE answer out of the landed bucket: not measured is not landed, and // the tree stays mid-slice, which is the direction that refuses rather than // the direction that tells someone their work is safe to discard. let cherry: string; try { cherry = git(at, ["cherry", tip, "HEAD"]); } catch { return false; } if (cherry.trim().length === 0) return false; return cherry .trim() .split("\n") .every((l) => l.trim().startsWith("-")); } // ---------- 1.2 / 1.3: refresh idle trees, never mid-slice ---------- export const refreshWorktreesSchema = { repo: z.string().min(1), base: z.string().min(1), apply: z.boolean().optional(), }; /** * Fast-forward IDLE trees onto `origin/`. Never `--force`, never a * mid-slice tree. * * A tree is MID-SLICE when it is dirty or holds commits the base does not. * Pulling under someone's feet is worse than staleness: staleness is visible in * a diff, a clobbered work-in-progress is not. So dirty or diverged is REFUSED * per tree and named, and the run continues for the others. */ export async function refreshWorktreesTool(args: { repo: string; base: string; apply?: boolean }) { const { repo, base } = args; const ref = `origin/${base}`; try { git(repo, ["fetch", "--quiet", "origin", base]); } catch { /* reported per tree below */ } let tip: string; try { tip = git(repo, ["rev-parse", "--verify", `${ref}^{commit}`]); } catch { return { ok: false as const, error: `${ref} does not resolve — nothing to fast-forward onto` }; } const results = []; for (const w of listWorktrees(repo)) { const at = w.path; if (samePath(at, primaryOf(repo) ?? "")) { // THE PRIMARY CHECKOUT IS REPORTED, ALWAYS — it used to be skipped with // "not a slice tree", which is true and useless. It is the tree David // reads, the one every hand-run measurement runs in, and a silent skip // let it sit arbitrarily far behind while this verb reported a clean // sweep. "Not ours to fast-forward" and "nothing to say about it" are // different claims and only the first one was intended. let behind = "unknown"; let ahead = "unknown"; try { behind = git(at, ["rev-list", "--count", `HEAD..${tip}`]); ahead = git(at, ["rev-list", "--count", `${tip}..HEAD`]); } catch { /* left as unknown — an unreadable count must not read as zero */ } const dirty = isDirty(at); // FAST-FORWARD ONLY, AND ONLY WHEN CLEAN. A ff-only merge cannot rewrite // history or resolve a conflict, so the worst case is a refusal. Dirty is // refused for the same reason a slice tree is: uncommitted work is what a // diff cannot show and git cannot give back. const canFf = !dirty && ahead === "0" && behind !== "0" && behind !== "unknown"; if (args.apply && canFf) { try { git(at, ["merge", "--ff-only", ref]); results.push({ path: w.path, action: "fast-forwarded", why: `primary checkout advanced ${behind} commit(s) to ${ref}` }); continue; } catch (e) { results.push({ path: w.path, action: "refused", why: `primary checkout: ff-only merge failed — ${(e as Error).message}` }); continue; } } results.push({ path: w.path, action: behind === "0" ? "current" : "stale", why: behind === "0" ? `primary checkout is at ${ref}` : `primary checkout is ${behind} commit(s) behind ${ref}` + (dirty ? " and DIRTY — not fast-forwarded; uncommitted work is what a diff cannot show" : ahead !== "0" ? aheadIsLanded(at, tip) ? ` and ${ahead} ahead whose patches are ALREADY UPSTREAM — squash-merged, so ancestry will never agree; not fast-forwarded, but nothing here needs preserving` : ` and ${ahead} ahead — not fast-forwarded; it has commits ${ref} does not` : args.apply ? "" : " — pass apply:true to fast-forward it"), }); continue; } if (isDirty(at)) { results.push({ path: w.path, action: "refused", why: "MID-SLICE: uncommitted changes. Pulling here would clobber work a diff cannot show." }); continue; } let head: string; try { head = git(at, ["rev-parse", "HEAD"]); } catch { results.push({ path: w.path, action: "refused", why: "unreadable HEAD" }); continue; } if (head === tip) { results.push({ path: w.path, action: "current", why: `already at ${ref}` }); continue; } let ahead = "0"; try { ahead = git(at, ["rev-list", "--count", `${tip}..HEAD`]); } catch { /* treated as diverged below */ } if (ahead !== "0") { // LANDED, NOT MID-SLICE — the ancestry count cannot tell these apart. if (aheadIsLanded(at, tip)) { results.push({ path: w.path, action: "landed", why: `${ahead} commit(s) are not ancestors of ${ref}, but every one of their PATCHES is already upstream — ` + `this branch was SQUASH-MERGED and its work has shipped. Ancestry can never say so: a squash writes a new ` + `commit, so these shas will read as "not on ${ref}" forever. Nothing here needs preserving; detach to ${ref} ` + `(or delete the branch) and this tree is reusable. NOT fast-forwarded automatically — moving a tree off ` + `committed work is a decision, not a refresh.`, }); continue; } results.push({ path: w.path, action: "refused", why: `MID-SLICE: ${ahead} commit(s) not on ${ref}, and their patches are NOT upstream. Refusing rather than --force. ` + `(A squash of SEVERAL commits into one changes the combined patch id, so this cannot prove the negative — ` + `it reports mid-slice, which is the same direction it always failed in.)`, }); continue; } if (!args.apply) { results.push({ path: w.path, action: "would-fast-forward", why: `behind ${ref}` }); continue; } try { git(at, ["merge", "--ff-only", tip]); results.push({ path: w.path, action: "fast-forwarded", why: `to ${tip.slice(0, 8)}` }); } catch (e) { results.push({ path: w.path, action: "refused", why: `ff-only failed: ${String((e as Error).message).split("\n")[0]}` }); } } return { ok: true as const, base: ref, tip: tip.slice(0, 8), applied: args.apply === true, trees: results }; } // ---------- the counterpart to ensure_worktree: give the tree back ---------- /* * `ensure_worktree` CREATED trees and nothing RELEASED them, so teardown stayed a * hand step in five role cards — `git worktree prune` in coord-audit, coord-ci, * coord-qa and coord-worker, `git worktree remove` twice in coordinator, which * says so itself: "it never removes, so `git worktree remove`/`prune` remain hand * steps". Seven commands for one act the bus already had the inputs for. * * WORSE THAN ABSENT: `ensure_worktree` COMPUTED the teardown command and handed it * back as a string for the agent to paste (`removeWith`). The tool knew the repo, * the path and the branch, and spent the agent's tokens on shell it could have run. * That is the shape David ruled against: "where possible we should hide the * plumbing from the agents". * * THE REFUSALS ARE THE POINT, and they inherit `refresh_worktrees`' direction — * report by default, refuse per tree with the reason named, never clobber: * * - THE PRIMARY CHECKOUT IS NEVER RELEASED. It is the path everyone already has. * - A DIRTY TREE IS REFUSED. Uncommitted work is what a diff cannot show and git * cannot give back, so this is the one refusal with no override. * - A TREE HOLDING UNLANDED COMMITS IS REFUSED — and "unlanded" is decided by * `aheadIsLanded` (patch ids), NOT by ancestry. Under squash merge a landed * branch's shas never enter the base, so ancestry calls every finished tree * mid-slice forever (q-5c40db91). Using ancestry here would refuse to release * exactly the trees that are safe to release, which is how the hand step * survived: the guard would have been wrong and everyone would have forced it. * * A NEGATIVE FROM `aheadIsLanded` IS INCONCLUSIVE, NOT A NO. Several commits squashed * into one carry a different combined patch id, so it cannot prove landedness for * every shape. It therefore refuses and says the check was inconclusive rather than * claiming the work is unlanded — the caller lands it, or passes `force:true`, which * is recorded in the result so a forced release is findable afterwards. */ export const releaseWorktreeSchema = { repo: z.string().min(1), agentId: z.string().min(1).optional(), path: z.string().min(1).optional(), base: z.string().min(1).optional(), apply: z.boolean().optional(), force: z.boolean().optional(), }; export async function releaseWorktreeTool(args: { repo: string; agentId?: string; path?: string; base?: string; apply?: boolean; force?: boolean; }) { const { repo } = args; if (!args.agentId && !args.path) { return { ok: false as const, error: "name the tree to release: pass `agentId` or `path`" }; } const trees = listWorktrees(repo); const primary = primaryOf(repo) ?? ""; const target = args.path ? trees.find((w) => samePath(w.path, args.path as string)) : trees.find((w) => (w.branch ?? "").includes(args.agentId as string) || w.path.includes(args.agentId as string)); if (!target) { return { ok: false as const, error: args.path ? `no worktree at '${args.path}' — nothing to release` : `no worktree found for '${args.agentId}' — nothing to release`, // An ABSENCE is reported with what WAS found, so a misaimed lookup is // distinguishable from an empty world (the positive-control rule). known: trees.map((w) => ({ path: w.path, branch: w.branch })), }; } const at = target.path; const branch = target.branch; if (samePath(at, primary)) { return { ok: false as const, error: `refusing to release ${at}: that is the PRIMARY checkout, the path everyone already has`, path: at, }; } if (isDirty(at)) { return { ok: false as const, error: `refusing to release ${at}: the tree is DIRTY. Uncommitted work is what a diff cannot show and ` + `git cannot give back, so this refusal has no override — commit it, stash it, or delete it by hand.`, path: at, branch, dirty: true, }; } const base = args.base ?? "main"; const ref = `origin/${base}`; let tip: string | null = null; try { git(repo, ["fetch", "--quiet", "origin", base]); } catch { /* a stale tip is reported below rather than guessed at */ } try { tip = git(repo, ["rev-parse", "--verify", `${ref}^{commit}`]); } catch { tip = null; } let ahead = "unknown"; if (tip) { try { ahead = git(at, ["rev-list", "--count", `${tip}..HEAD`]); } catch { /* left unknown — an unreadable count must not read as zero */ } } const holdsWork = ahead !== "0"; const landed = tip && holdsWork ? aheadIsLanded(at, tip) : true; if (!tip) { return { ok: false as const, error: `refusing to release ${at}: ${ref} does not resolve, so whether this tree's work landed cannot be measured`, path: at, branch, }; } if (holdsWork && !landed && !args.force) { return { ok: false as const, error: `refusing to release ${at}: it is ${ahead} commit(s) ahead of ${ref} and the patch-id check was ` + `INCONCLUSIVE — not proof the work is unlanded, but not proof it landed either (several commits ` + `squashed into one carry a different combined patch id). Land it, or pass force:true, which is recorded.`, path: at, branch, ahead, landedCheck: "inconclusive", }; } const plan = { path: at, branch, ahead, landedCheck: holdsWork ? (landed ? "landed" : "inconclusive") : "nothing-ahead", ...(args.force && holdsWork && !landed ? { forced: true } : {}), }; if (!args.apply) { return { ok: true as const, applied: false, would: "remove the worktree and delete its branch", ...plan, note: "reports by default; pass apply:true to remove", }; } try { git(repo, ["worktree", "remove", "--force", at]); } catch (e) { return { ok: false as const, error: `worktree remove failed: ${String((e as Error).message).split("\n")[0]}`, ...plan, }; } // PRUNE IS PART OF THE ACT, not a step the caller remembers. Every card that // said `git worktree remove` also said `git worktree prune`, which is the // clearest evidence they were one operation split across two lines of prose. try { git(repo, ["worktree", "prune"]); } catch { /* removal already succeeded; a failed prune is not worth failing the call */ } let branchDeleted = false; if (branch) { try { git(repo, ["branch", "-D", branch.replace(/^refs\/heads\//, "")]); branchDeleted = true; } catch { /* a branch that will not delete is reported, not fatal */ } } return { ok: true as const, applied: true, removed: true, branchDeleted, ...plan }; }