// worktree-preservation.ts — WORKTREE HANDOFF PRESERVATION (framework seam). // // The managed-worktree lifecycle belongs to the runtime (pi-subagents creates // the `pi-parallel--*` branch + worktree and is DOCUMENTED to DELETE // BOTH at run end — success AND failure/timeout — inside its own cleanup // finally-blocks, before any completion event reaches this framework). A // worker that committed to its parallel branch therefore had those commits // deleted with the branch: they became dangling objects (three live incidents // needed a manual `git fsck` rescue). Interception is impossible (deletion // happens first), so this module preserves PRE-EMPTIVELY: // // CORRECTION (AUTOPILOT-74, empirical audit): that deletion is NOT reliable. // A live session accumulated 106 worktrees (~1.2GB) across `git worktree // list`, 64 already merged into main and some 2+ days old — the runtime never // reclaimed them. So the premise this module is built on ("assume deletion // happens, protect against it") still holds — deletion clearly CAN happen, as // the fsck incidents show — but "leftovers are rare/short-lived" does not. // The audit also found the overwhelming majority of those leftovers (104/105) // are NOT on the `pi-parallel-*` shape this module tracks at all — they are // `pi-subagents/main---s0-t`, the runtime's general per-task // worktree-isolation feature (used for any subagent/Task call, not only this // framework's queue dispatch). `pruneStaleWorktrees` below is this repo's own // defensive reclaim pass for the disk-growth symptom — deliberately // branch-shape-agnostic (it inspects whatever `git worktree list` reports, // not just `pi-parallel-*`) but conservative enough to never touch anything // AUTOPILOT-60's prior-attempt recovery could still need: only // merged-into-main + past a generous age buffer + a clean tree qualify, and // it is NOT wired into the automatic sweep — a caller invokes it // deliberately. // // CORRECTION (AUTOPILOT-77, live reproduction): the claim above that this // repo "has no code creating or matching" the `pi-subagents/main-*` shape was // itself the root cause of a second, worse bug — `parallelBranches()` (the // TIP JOURNAL's own resolver, used by `runWorktreePath`/`recordActiveWorktrees` // too) matched ONLY the legacy `pi-parallel--*` ref pattern, which no // real worker/reviewer branch this session was ever named. The tip journal // (`handoffs.jsonl`) never got a single line written; every downstream // consumer — the auto-ship lane (shipping.ts), AUTOPILOT-60's prior-attempt // recovery guidance, AUTOPILOT-70's checkpoint safety net — silently // degraded with zero trace. `parallelBranches`/`runWorktreePath` below now // resolve PRIMARILY via the run's own live worktree, path-matched by the FULL // runId embedded in the worktree directory (`pi-worktree--sN-tN`, // always the full id, even though the branch it checks out may only carry a // TRUNCATED one) — see `worktreeEntriesForRunPath` — with the legacy // ref-prefix match kept as a harmless fallback. // // 1. TIP JOURNAL — every capture appends {runId, key, branch, tipSha, ts} // to /handoffs.jsonl and writes the tip into a durable, // gc-safe ref `refs/orchestrator/keep/` (invisible in `git // branch` listings; only written AFTER a successful rev-parse). Once // commits are reachable from that ref, pi-subagents' later // `git branch -D` becomes harmless. // 2. CAPTURE POINTS ride machinery that already runs (no new timers): // the periodic sweep (every sweep source) plus queue-state transitions // (dispatch recorded / completion flip / zombie-flip — wired by the // runner + queue ops). A worker killed between sweeps still gets its // last commits journalled at the state change. // 3. RETENTION — keep refs are pruned when the queue item's work is SETTLED // (terminal `done`/`rejected`) OR when the journaled tip becomes // reachable from main (origin/main, falling back to the local main), // whichever comes first. failed/blocked items keep their ref until then. // A `no-change` item is terminal but DELIBERATELY keeps its ref merge- // gated like an in-flight item (see prunePreservedRefs) — a refusal's // commits never merged and may still be cited. // // All git calls are best-effort: any failure leaves the store untouched and // returns what was captured so far — preservation must never break a sweep. import { appendFileSync, existsSync, mkdirSync, readFileSync, realpathSync } from "node:fs"; import { join } from "node:path"; import { git, gitExitCode } from "./git.ts"; import { loadStore, mutateStore, updateItem, isTerminalStatus } from "../queue-store.ts"; /** Durable keep-ref namespace (gc-safe, hidden from `git branch`). */ export const KEEP_REF_PREFIX = "refs/orchestrator/keep/"; /** The managed-worktree branch prefix the runtime uses. */ export const PARALLEL_BRANCH_PREFIX = "pi-parallel-"; /** One journal line in /handoffs.jsonl. */ export interface HandoffEntry { runId: string; key: string; branch: string; tipSha: string; ts: string; } export function handoffsLogPath(stateDir: string): string { return join(stateDir, "handoffs.jsonl"); } /** Ref-safe component: run ids are uuid-shaped but never trust that. */ export function keepRefFor(runId: string): string { const clean = runId.replace(/[^A-Za-z0-9._-]/g, "_").replace(/\.\./g, "__"); return `${KEEP_REF_PREFIX}${clean}`; } /** Escape glob metachars so the runId cannot widen the for-each-ref pattern. */ function globEscape(s: string): string { return s.replace(/[*?[\\]/g, "[$&]"); } /** * Live worktree entries whose directory PATH embeds the full runId verbatim * (AUTOPILOT-77). The runtime's worktree directory convention is * `pi-worktree--sN-tN` — the FULL runId, always — even for the * runtime's CURRENT branch-naming convention * (`pi-subagents/main-<7-char-hash>-sN-tN`), whose branch name only carries a * TRUNCATED runId and can no longer be matched by name alone. The path is * therefore the reliable link now. Best-effort: only finds anything while the * worktree directory still physically exists on disk (defined below, * `listWorktrees` — hoisted function declaration, safe to call from here). */ function worktreeEntriesForRunPath(repo: string, runId: string): Array<{ path: string; branch: string | null }> { if (!runId) return []; return listWorktrees(repo).filter((e) => e.path.includes(runId)); } /** * The worker's parallel branches for one run — TWO resolution paths, unioned * (AUTOPILOT-77 root-cause fix): * 1. PATH match (`worktreeEntriesForRunPath`) — the run's own live * worktree, path-matched by the FULL runId. The reliable link against * the runtime's CURRENT convention (see above). * 2. Legacy ref-prefix match (`pi-parallel-*`) — the OLDER * convention this constant was originally written against. Kept as a * harmless fallback: it catches any pre-existing/dangling branch still * on that convention, worktree or not (e.g. this module's own test * fixtures, or any branch a human or an older runtime build left * behind). * Best-effort like every call in this module: only resolves anything while * the worktree directory (path 1) or the branch ref (path 2) is still * alive — the periodic sweep this module rides exists specifically to * capture ahead of the runtime's own end-of-run cleanup of both. */ export function parallelBranches(repo: string, runId: string): string[] { const found = new Set(); for (const e of worktreeEntriesForRunPath(repo, runId)) { if (e.branch) found.add(e.branch); } const out = git(repo, [ "for-each-ref", "--format=%(refname:short)", `refs/heads/${PARALLEL_BRANCH_PREFIX}${globEscape(runId)}*`, ]); if (out) { for (const line of out.split("\n")) { const b = line.trim(); if (b) found.add(b); } } return [...found]; } function lastJournaledTip(stateDir: string, runId: string, branch: string): string | null { try { const p = handoffsLogPath(stateDir); if (!existsSync(p)) return null; let last: string | null = null; for (const line of readFileSync(p, "utf8").split("\n")) { if (!line.trim()) continue; try { const e = JSON.parse(line) as HandoffEntry; if (e.runId === runId && e.branch === branch && typeof e.tipSha === "string") last = e.tipSha; } catch { // corrupt line — skip } } return last; } catch { return null; } } /** * Preserve one run's parallel-branch tips: journal each NEW tip + write it * into the durable keep ref (ref write only after a successful rev-parse). * Returns the entries appended this call (empty when nothing new). * * `knownBranch` (AUTOPILOT-85, journal ROOT-CAUSE fix): an explicit branch * name from OUTSIDE live-repo discovery — the completion event's own * reported branch (`CompletionEvent.branch`, see types.ts), when the runner * has one. `parallelBranches` below discovers branches by re-deriving them * from what is STILL ON DISK right now (a live worktree path match, or the * legacy `pi-parallel-*` ref-prefix) — which finds NOTHING for a run whose * worktree/branch the runtime already deleted before this call fires (the * documented finally-block cleanup, see the file header). A caller that * already knows the branch name from elsewhere (the completion event, or a * queue item's own `runWorktree.branch` recorded during an earlier ACTIVE * sweep) can hand it in here so it gets the SAME rev-parse-gated attempt as * every discovered branch — still fails closed (a genuinely deleted ref * yields nothing, exactly like today), but now covers the race where the ref * is still resolvable and discovery simply never matched the name (the * runtime's current branch-naming convention isn't `pi-parallel-*`, and no * live worktree directory survived to path-match against). */ export function preserveRunWorktree(input: { stateDir: string; repo: string; runId: string; key: string; ts?: string; knownBranch?: string | null; }): HandoffEntry[] { const ts = input.ts ?? new Date().toISOString(); const entries: HandoffEntry[] = []; const branches = new Set(parallelBranches(input.repo, input.runId)); if (input.knownBranch) branches.add(input.knownBranch); for (const branch of branches) { // Resolve the commit tip through the branch ref — a successful rev-parse // is the gate for everything below (a gone/corrupt branch journals nothing). const tipSha = git(input.repo, ["rev-parse", "--verify", `${branch}^{commit}`]); if (!tipSha || tipSha === lastJournaledTip(input.stateDir, input.runId, branch)) continue; if (git(input.repo, ["update-ref", keepRefFor(input.runId), tipSha]) === null) continue; entries.push({ runId: input.runId, key: input.key, branch, tipSha, ts }); } if (entries.length) { try { mkdirSync(input.stateDir, { recursive: true }); appendFileSync(handoffsLogPath(input.stateDir), entries.map((e) => JSON.stringify(e)).join("\n") + "\n", "utf8"); } catch { // The durable REF already protects the commits; the journal is evidence. } } return entries; } /** * WHERE this run's managed worktree lives on disk, from `git worktree list`. * * AUTOPILOT-47 C: keep-refs preserve COMMITTED work; a reaped run's UNCOMMITTED * changes survive only in its worktree directory, and nothing recorded which * directory that was — salvage meant matching run ids to `pi-worktree-*` paths * by hand. TWO resolution paths (AUTOPILOT-77), same union `parallelBranches` * uses: * 1. PATH match — the worktree directory itself embeds the full runId * (`worktreeEntriesForRunPath`); works even when the checked-out * branch's own name only carries a truncated runId. * 2. Legacy fallback — cross-reference `parallelBranches`' ref-prefix match * against `git worktree list`'s reported branch per entry (the ORIGINAL * approach, still needed when the worktree directory's path does not * itself embed the runId). * Returns null when neither path finds a live worktree for this run * (best-effort, like every git call here). */ export function runWorktreePath(repo: string, runId: string): { path: string; branch: string } | null { for (const e of worktreeEntriesForRunPath(repo, runId)) { if (e.branch) return { path: e.path, branch: e.branch }; } const branches = new Set(parallelBranches(repo, runId)); if (branches.size === 0) return null; for (const e of listWorktrees(repo)) { if (e.branch && branches.has(e.branch)) return { path: e.path, branch: e.branch }; } return null; } /** * Record each ACTIVE item's worktree path on the item, so a post-mortem reads * it instead of reconstructing it. Runs in the sweep because the worktree does * NOT exist yet when dispatch returns — the runtime creates it after the spawn * — so the first sweep after dispatch is the earliest honest moment. * Idempotent: writes only when the resolved path differs from what is recorded. */ export function recordActiveWorktrees(stateDir: string): Array<{ key: string; path: string }> { const store = loadStore(stateDir); if (!store) return []; const resolved: Array<{ key: string; runId: string; path: string; branch: string }> = []; for (const it of Object.values(store.items)) { if (it.status !== "active" || !it.runId || !it.cwd) continue; if (it.runWorktree && it.runWorktree.runId === it.runId) continue; // already recorded for THIS run let found: { path: string; branch: string } | null = null; try { found = runWorktreePath(it.cwd, it.runId); } catch { continue; // best-effort per item } if (found) resolved.push({ key: it.key, runId: it.runId, ...found }); } if (resolved.length === 0) return []; try { mutateStore(stateDir, (s) => { for (const r of resolved) { const it = s.items[r.key]; if (!it || it.runId !== r.runId) continue; // the run moved on between read and write updateItem(s, r.key, { runWorktree: { runId: r.runId, path: r.path, branch: r.branch } }); } }); } catch { return []; // recording a path must never break the sweep } return resolved.map((r) => ({ key: r.key, path: r.path })); } /** One auto-checkpoint the sweep made because a worker's worktree carried * real, uncommitted changes. Surfaced on the item's notes (AUTOPILOT-70) so * a pattern of rescues is visible instead of folklore — the recurrence, not * any single rescue, is the signal worth a human's attention. */ export interface CheckpointEntry { key: string; runId: string; path: string; branch: string; sha: string; } /** * CHECKPOINT UNCOMMITTED WORK (AUTOPILOT-70): a worker that stages real edits * but never runs `git commit` leaves work no keep-ref can rescue — a keep-ref * journals a COMMIT tip (preserveRunWorktree above); staged/unstaged changes * are not a git object at all, so they vanish the instant the runtime deletes * the worktree directory — which it does at run end, success AND failure, * BEFORE any completion event reaches this framework (see the header). * Observed live 4x in one session: a worker reported success with real, * reviewed-quality work sitting ONLY in `git status --short`, on a branch * with ZERO commits — silently loseable the moment the worktree was swept. * * Runs in the SAME sweep pass as preserveActiveItems, on the WORKTREE * directory (not the bare repo `preserveRunWorktree` reads), because a dirty * index/working-tree only exists there. A dirty tree gets ONE checkpoint * commit — `git add -A` + `git commit` INSIDE THE WORKTREE, on the worker's * own `pi-parallel-*` branch — which turns "staged, invisible to keep-refs" * into "committed, captured by the very next preserveRunWorktree call in this * same sweep". This never touches the item's main cwd/checkout, never merges, * never pushes: it is the same "commit early and often" discipline the worker * is asked to do itself, applied as a backstop when it didn't. * * Best-effort like every call in this module: `git status --porcelain` * failing (unreadable worktree, mid-rebase, detached weirdness) or `git * commit` failing (nothing staged after `add -A` races with a worker's own * concurrent commit, no user.name/email configured) yields no checkpoint — * never a thrown error, never a broken sweep. */ export function checkpointUncommittedWork(input: { path: string; runId: string; branch: string; key: string }): CheckpointEntry | null { const { path, runId, branch, key } = input; const dirty = git(path, ["status", "--porcelain"]); if (!dirty) return null; // clean tree (or unreadable) — nothing to rescue if (git(path, ["add", "-A"]) === null) return null; const msg = `checkpoint(${key}): auto-preserved by framework sweep — worker left uncommitted changes uncommitted`; if (git(path, ["commit", "--no-verify", "-q", "-m", msg]) === null) return null; // e.g. add raced a worker commit, nothing left staged const sha = git(path, ["rev-parse", "--verify", "HEAD"]); if (!sha) return null; return { key, runId, path, branch, sha }; } /** * Checkpoint every ACTIVE item's worktree (see checkpointUncommittedWork) and * append a note onto the item recording the rescue — the note is what makes * this framework-level, not a silent side effect: the operator sees WHICH * items needed a safety-net commit, which is the signal that the worker-agent * prompt is not (yet, or ever) reliably preventing this class of mistake. * Runs in the SAME sweep call as preserveActiveItems/recordActiveWorktrees * (framework/runner.ts), BEFORE them, so a fresh checkpoint's tip is journaled * into the keep-ref in this very tick instead of waiting one more sweep. */ export function checkpointActiveItems(stateDir: string, now?: string): CheckpointEntry[] { const store = loadStore(stateDir); if (!store) return []; const ts = now ?? new Date().toISOString(); const out: CheckpointEntry[] = []; for (const it of Object.values(store.items)) { if (it.status !== "active" || !it.runId || !it.cwd) continue; let found: { path: string; branch: string } | null = null; try { found = runWorktreePath(it.cwd, it.runId); } catch { continue; // best-effort per item } if (!found) continue; let entry: CheckpointEntry | null = null; try { entry = checkpointUncommittedWork({ path: found.path, runId: it.runId, branch: found.branch, key: it.key }); } catch { continue; // best-effort per item } if (!entry) continue; out.push(entry); const note = `[auto-checkpoint] ${ts} — the framework committed UNCOMMITTED changes it found in this run's worktree ` + `(${entry.branch} @ ${entry.sha.slice(0, 8)}) because a worker left real edits staged/unstaged instead of ` + `committing them itself. This is a SAFETY NET, not normal — the work is now preserved as a commit, but verify ` + `nothing else was lost and note the recurrence if this keeps happening.`; try { mutateStore(stateDir, (s) => { const fresh = s.items[it.key]; if (!fresh || fresh.runId !== it.runId) return; // the run moved on between read and write updateItem(s, it.key, { notes: fresh.notes ? `${fresh.notes}\n\n${note}` : note }); }); } catch { // the commit already landed on the branch even if the note write failed } } return out; } /** Preserve every ACTIVE item's run (the sweep's per-tick capture pass). */ export function preserveActiveItems(stateDir: string, ts?: string): HandoffEntry[] { const store = loadStore(stateDir); if (!store) return []; const out: HandoffEntry[] = []; for (const it of Object.values(store.items)) { if (it.status !== "active" || !it.runId || !it.cwd) continue; try { out.push(...preserveRunWorktree({ stateDir, repo: it.cwd, runId: it.runId, key: it.key, ts })); } catch { // best-effort per item — one broken repo must not skip the rest } } return out; } /** Is `tip` already an ancestor of `main`? A tip on EITHER the LOCAL * `refs/heads/main` or the remote-tracking `refs/remotes/origin/main` is * provably merged — `main` is one logical branch and either ref is a valid * statement of it. The previous implementation returned at the FIRST existing * ref, so a STALE `refs/remotes/origin/main` (this repo's case: it lagged * local `main` by dozens of merges) made every post-lag merge look unmerged * — AUTOPILOT-94's retention KEEP reason for a genuinely-merged branch was a * factual lie, and auto-dispatch's stale-vs-main label said "NOT in main" * for already-merged work. No `main` ref, or the tip on neither → false * (false is the callers' do-not-act default, so an unresolvable base is * never mistaken for merged). */ export function tipReachableFromMain(repo: string, tip: string): boolean { for (const mainRef of ["refs/heads/main", "refs/remotes/origin/main"]) { if (git(repo, ["rev-parse", "--verify", "--quiet", mainRef]) === null) continue; // exit 0 exactly when reachable if (gitExitCode(repo, ["merge-base", "--is-ancestor", tip, mainRef]) === 0) return true; // not an ancestor of THIS main ref — check the other before giving up } return false; } function readJournal(stateDir: string): HandoffEntry[] { try { const p = handoffsLogPath(stateDir); if (!existsSync(p)) return []; const out: HandoffEntry[] = []; for (const line of readFileSync(p, "utf8").split("\n")) { if (!line.trim()) continue; try { const e = JSON.parse(line) as HandoffEntry; if (e.runId && e.key && e.branch && e.tipSha) out.push(e); } catch { // corrupt line — skip } } return out; } catch { return []; } } /** * REVIEW POINTERS for the deterministic human handover: the latest journaled * branch@tip per runId for one queue item, as concrete review targets. This is * what makes the auto-flag actually REVIEWABLE — the old auto-flag pointed at * `item.cwd` + the prose "the reviewed work (queue item K)", which is not a * pointer (the user asked for paths/links, and got neither). */ export function reviewPointersFor(stateDir: string, key: string): Array<{ branch: string; tipSha: string }> { const out: Array<{ branch: string; tipSha: string }> = []; for (const e of readJournal(stateDir)) { if (e.key !== key) continue; const last = out.find((p) => p.branch === e.branch); if (last) last.tipSha = e.tipSha; // tips advance — newest wins else out.push({ branch: e.branch, tipSha: e.tipSha }); } return out; } /** Best-effort web URL for a commit. Parsing is STRUCTURAL, not shape-guessing: * URL-shaped remotes go through the standard URL parser (credentials, ports, * IPv6, tilde owners, nested GitLab subgroups all handled correctly); the one * legacy non-URL shape — scp-style `git@host:path` — is the explicit fallback. * Anything whose web layout would NOT follow the cgit convention * (`https:////commit/`) returns null instead of a silently * wrong link: non-http(s) schemes (ssh:// carries a transport port the web UI * doesn't share; file:// is local), explicit ports, Azure-DevOps /_git/ paths. * Those repos still get the LOCAL pointers (path + branch@sha + diff command), * which is why the enrichment is additive, never either/or. */ export function webUrlForCommit(repo: string, tipSha: string): string | null { const url = git(repo, ["remote", "get-url", "origin"]); if (!url) return null; let host = ""; let path = ""; try { const u = new URL(url); if (u.protocol !== "https:" && u.protocol !== "http:") return null; if (u.port) return null; // nonstandard web port — can't guess; local pointers cover it if (u.pathname.includes("/_git/")) return null; // Azure-DevOps layout — transform would be silently wrong host = u.hostname; path = u.pathname.replace(/^\/+/, ""); } catch { // scp-style (the only common non-URL shape): git@host:owner/repo.git const scp = url.replace(/^[^@]+@/, ""); const i = scp.indexOf(":"); if (i <= 0) return null; host = scp.slice(0, i); path = scp.slice(i + 1); } const clean = path.replace(/\.git$/, "").replace(/\/+$/, ""); if (!host || !clean.includes("/")) return null; // need owner/repo — a bare repo path has no forge convention return `https://${host}/${clean}/commit/${tipSha}`; } /** Files the worker changed on its parallel branch vs main — the DELIVERABLE * pointers (a document the user must read is a path, not a diff command). * Markdown/docs sort first (they are usually the deliverable); capped so a * 100-file code change cannot flood the handover. Best-effort: null on any * git failure. */ export function deliverablePathsFor(repo: string, tipSha: string, base = "main", cap = 6): string[] | null { const diff = git(repo, ["diff", "--name-only", `${base}...${tipSha}`]); if (!diff) return null; const files = diff.split("\n").map((f) => f.trim()).filter(Boolean); const docs = files.filter((f) => /\.(md|mdx|txt|rst)$/i.test(f) || /^docs\//.test(f)); const rest = files.filter((f) => !docs.includes(f)); return [...docs, ...rest].slice(0, cap); } /** * RETENTION: delete keep refs whose job is done — the item's work is SETTLED * (terminal `done`/`rejected`) or the journaled tip became reachable from main. * Returns the refs deleted this call. Idempotent: already-deleted refs no-op. */ export function prunePreservedRefs(stateDir: string): string[] { const store = loadStore(stateDir); if (!store) return []; const deleted: string[] = []; const seen = new Set(); // Newest entry per runId wins (tips advance; the latest is what matters). const latest = new Map(); for (const e of readJournal(stateDir)) { latest.set(e.runId, e); seen.add(e.runId); } for (const [runId, entry] of latest) { const ref = keepRefFor(runId); const item = store.items[entry.key]; // KEEP-REF RETENTION — a keep ref exists to rescue a run's commits, so it is // deleted once that rescue is moot: the item's work is SETTLED (`done` = // landed; `rejected` = deliberately dropped) OR the journaled tip is // provably reachable from main. // // `no-change` is terminal (it IS in `isTerminalStatus`) but is DELIBERATELY // NOT in this set — this is a decision, not an omission (AUTOPILOT-113). A // no-change run produced no deliverable and its commits never merged, its // reviewer rationale may cite work on that branch, and the item is // re-openable (no-change → approved). So its keep ref stays merge-gated // exactly like an in-flight item — PRESERVED until provably merged — rather // than pruned on status alone like done/rejected. (Contrast branch-retention, // which CAN reclaim a no-change branch because it ADDITIONALLY requires a // provable merge; this set prunes with NO such check, so no-change must not // join it.) const statusSettled = item !== undefined && isTerminalStatus(item.status) && item.status !== "no-change"; const merged = statusSettled ? false : tipReachableFromMain(item?.cwd ?? ".", entry.tipSha); if (!statusSettled && !merged) continue; // The ref lives in the item's repo — same place the branch was deleted. if (git(item?.cwd ?? ".", ["update-ref", "-d", ref]) !== null || git(item?.cwd ?? ".", ["rev-parse", "--verify", "--quiet", ref]) === null) { deleted.push(ref); } } return deleted; } /** Does `sha` still resolve to a commit in `repo`? HONESTY CHECK for * review-target building (AUTOPILOT-57, framework/review-targets.ts): a * journaled branch@tip can outlive the branch itself (the runtime deletes * `pi-parallel-*` branches at run end; the keep-ref usually rescues the * commit, but a ref prune or a manual cleanup can still take it) — printing * a diff command against a gone commit is a dead pointer, worse than no * pointer at all. */ export function commitResolvesIn(repo: string, sha: string): boolean { return git(repo, ["cat-file", "-e", `${sha}^{commit}`]) !== null; } /** A generous default retention window: 16x this repo's own run-budget * ceiling (`BUDGET_MAX_MS`, framework/run-budget.ts, 3h), so no in-flight or * recently-finished run's worktree can plausibly still be under this age. */ export const DEFAULT_PRUNE_MIN_AGE_MS = 48 * 60 * 60_000; export interface WorktreePruneResult { /** Worktree directories removed this call (`git worktree remove`). */ removedWorktrees: string[]; /** Branches removed this call (`git branch -D`) — only when `deleteBranches`. */ removedBranches: string[]; /** Every worktree NOT removed, with the reason it was left alone. */ skipped: Array<{ path: string; reason: string }>; } function resolveRealpath(p: string): string { try { return realpathSync(p); } catch { return p; // gone or otherwise unresolvable — compare the raw path } } /** Parse `git worktree list --porcelain` into one entry per worktree. Git * guarantees the FIRST entry is always the main worktree (never a linked * one), which is how callers here tell it apart without a separate check. */ function listWorktrees(repo: string): Array<{ path: string; branch: string | null; locked: boolean }> { const out = git(repo, ["worktree", "list", "--porcelain"]); if (!out) return []; const entries: Array<{ path: string; branch: string | null; locked: boolean }> = []; let cur: { path: string; branch: string | null; locked: boolean } | null = null; for (const line of out.split("\n")) { if (line.startsWith("worktree ")) { if (cur) entries.push(cur); cur = { path: line.slice("worktree ".length).trim(), branch: null, locked: false }; } else if (line.startsWith("branch ") && cur) { cur.branch = line.slice("branch ".length).trim().replace(/^refs\/heads\//, ""); } else if (line.startsWith("locked") && cur) { cur.locked = true; } } if (cur) entries.push(cur); return entries; } /** * PRUNE STALE MANAGED WORKTREES (AUTOPILOT-74) — a defensive disk-reclaim * pass this repo can run on its own (plain git plumbing, no pi-subagents * dependency — AUTOPILOT-62 already established pi-subagents is not even a * dependency of this repo) instead of relying on whatever cleanup the * external runtime does or does not perform. Deliberately BRANCH-SHAPE * AGNOSTIC (does not assume `pi-parallel-*`; see the file header) — it * inspects every worktree `git worktree list` reports and removes one only * when EVERY safety gate passes: * - never the main worktree (always list entry 0 — git guarantees this) * - never the calling process's own cwd (never delete out from under the caller) * - not locked (`git worktree lock`) — an explicit "still needed" signal * - has a resolvable branch (a detached-HEAD worktree is left alone — no * cheap way to prove its commits are safe elsewhere) * - the branch tip is an ANCESTOR of main (`tipReachableFromMain`) — the * same reachability check keep-ref retention already uses; an unmerged * branch is exactly what AUTOPILOT-60's prior-attempt recovery may still * need, so it is NEVER a candidate regardless of age * - the branch tip is older than `minAgeMs` (default `DEFAULT_PRUNE_MIN_AGE_MS`) * - the working tree is CLEAN (`git status --porcelain` empty) — uncommitted * changes are never destroyed even when the committed tip is merged * Runs `git worktree prune` first (reclaims registrations whose directories * are already gone — always safe, nothing left to lose there). Every * worktree that fails a gate is reported in `skipped` with the reason, never * silently dropped. All git calls are best-effort, matching every other * function in this module: a failure skips that one worktree, never throws. */ export function pruneStaleWorktrees( repo: string, opts?: { minAgeMs?: number; deleteBranches?: boolean; now?: number }, ): WorktreePruneResult { const minAgeMs = opts?.minAgeMs ?? DEFAULT_PRUNE_MIN_AGE_MS; const deleteBranches = opts?.deleteBranches ?? true; const now = opts?.now ?? Date.now(); const result: WorktreePruneResult = { removedWorktrees: [], removedBranches: [], skipped: [] }; git(repo, ["worktree", "prune"]); // reclaim gone-directory registrations — always safe const entries = listWorktrees(repo); if (entries.length === 0) return result; const mainPath = resolveRealpath(entries[0].path); const cwd = resolveRealpath(process.cwd()); for (let i = 0; i < entries.length; i++) { const entry = entries[i]; const real = resolveRealpath(entry.path); if (i === 0 || real === mainPath) continue; // never the main checkout if (real === cwd || cwd.startsWith(real + "/")) { result.skipped.push({ path: entry.path, reason: "is the calling process's current directory" }); continue; } if (entry.locked) { result.skipped.push({ path: entry.path, reason: "locked (git worktree lock)" }); continue; } if (!entry.branch) { result.skipped.push({ path: entry.path, reason: "detached HEAD — no branch to verify safety" }); continue; } const tip = git(repo, ["rev-parse", "--verify", `${entry.branch}^{commit}`]); if (!tip) { result.skipped.push({ path: entry.path, reason: "branch unresolvable" }); continue; } let merged = false; try { merged = tipReachableFromMain(repo, tip); } catch { merged = false; } if (!merged) { result.skipped.push({ path: entry.path, reason: "branch not merged into main — may still be needed (AUTOPILOT-60 prior-attempt recovery)" }); continue; } const commitTsRaw = git(repo, ["log", "-1", "--format=%ct", entry.branch]); const commitTs = commitTsRaw ? Number(commitTsRaw) * 1000 : null; if (!commitTs || Number.isNaN(commitTs)) { result.skipped.push({ path: entry.path, reason: "could not determine branch commit age" }); continue; } const ageMs = now - commitTs; if (ageMs < minAgeMs) { result.skipped.push({ path: entry.path, reason: `too recent (${Math.round(ageMs / 3_600_000)}h old, retention is ${Math.round(minAgeMs / 3_600_000)}h)` }); continue; } const status = git(entry.path, ["status", "--porcelain"]); if (status === null) { result.skipped.push({ path: entry.path, reason: "could not read worktree status" }); continue; } if (status !== "") { result.skipped.push({ path: entry.path, reason: "working tree has uncommitted changes" }); continue; } if (git(repo, ["worktree", "remove", entry.path]) === null) { result.skipped.push({ path: entry.path, reason: "git worktree remove failed" }); continue; } result.removedWorktrees.push(entry.path); if (deleteBranches && git(repo, ["branch", "-D", entry.branch]) !== null) { result.removedBranches.push(entry.branch); } } return result; }