// branch-retention.ts — BOUNDED RETENTION for the managed `pi-subagents/*` // branch namespace (framework seam). // // WHY: every subagent run the runtime isolates gets a branch under // `pi-subagents/` (today's convention is `pi-subagents/main-<7-char-runid- // prefix>--sN-tN`; older/superseded attempts can carry other suffixes // under the same prefix). Nothing ever removes one. That namespace is exactly // the one the ORCHESTRATOR must SEARCH to locate a deliverable, so the litter // is not just disk: it caused a real wrong-artifact review (ACTUATOR-43's // review task named a stale branch among four candidates; the reviewer was // dispatched against the wrong one and only a hand-run // `git merge-base --is-ancestor` caught it). `worktree-preservation.ts`'s // `pruneStaleWorktrees` reclaims WORKTREES; this module reclaims the BRANCHES // those worktrees leave behind. // // THE RULE (KEY: AUTOPILOT-94): prune a branch ONLY when BOTH hold — // 1. its work is ALREADY AN ANCESTOR of main (provably merged), and // 2. NO live queue item and NO live run record names it. // Rule 2 is load-bearing: a wrongly pruned branch is lost work. So every // exclusion here is written to prefer KEEP when anything is uncertain — an // orphaned branch costs a little space; a pruned deliverable costs the run. // // WHAT IS NEVER PRUNED (the exclusion set): // - any branch outside the `pi-subagents/` prefix (the enumeration only ever // reads refs/heads/pi-subagents/*); // - any branch named by a NON-TERMINAL queue item — its `runId`, // `lastRunId`, `reviewerRunId`, `specPassRunId`, `runWorktree.branch`, // `finisherSource` (or its finisher baseline / landed source), or its // free-form `notes` (a live/in-flight item may still write to it); // - any branch a TERMINAL item names in its NOTES (the human-signal // channel): reported, never reclaimed — while the same terminal item's // STRUCTURED fields (run id / worktree branch) are the normal reclaim // path, because that attempt's work already landed; // - any branch referenced by a LIVE run record (a status.json whose phase is // queued/running/paused/blocked — see run-liveness.ts's LIVE_PHASES). The // run->branch link is the one the runtime actually writes (AUTOPILOT-77): // the run's `pi-worktree--sN-tN` worktree path, resolved via // `parallelBranches` — NOT a `branch` field in status.json, which does not // exist; an explicit branch-shaped string in the record is also honoured; // - any branch currently checked out in a worktree (git refuses the delete // anyway, and the worktree may belong to a run this process cannot see); // - any branch whose merge status CANNOT be determined (no `main` ref to // compare against → not provably merged → keep). // // REPORTING (the whole point of the first pass): every branch is reported as // KEPT with the reason it was kept, or PRUNED with the reason it qualified. // Two prune reasons are distinguished: // - `merged` — provably merged, and a terminal queue item still maps to // it (its attempt's work landed and the item is closed); // - `abandoned` — provably merged, but NO queue item maps to it at all (an // orphaned branch from a killed/purged run, e.g. a batch of 429-killed // attempts). // Both are provably-merged; neither can lose work. // // The pass is READ-ONLY BY DEFAULT (`dryRun: true`), and it is deliberately // NOT wired into the automatic sweep — a caller invokes it on purpose (the // `bun run prune-branches` CLI, scripts/prune-branches.ts), so the first pass // is reviewable before anything is removed. It never runs `git gc`, never // expires a reflog, and only ever issues `git branch -d` (the SAFE delete that // git itself refuses on an unmerged branch) — so a prune stays reversible // through the reflog. import { existsSync, readFileSync, readdirSync } from "node:fs"; import { join } from "node:path"; import { git } from "./git.ts"; import { loadStore, isTerminalStatus, type QueueItem } from "../queue-store.ts"; import { LIVE_PHASES } from "./run-liveness.ts"; import { parallelBranches, tipReachableFromMain } from "./worktree-preservation.ts"; /** The managed branch namespace. `pi-subagents/main-*` dominates today, but * the guard is the whole prefix — rule 4 of the ticket is "do NOT touch * branches outside `pi-subagents/`". */ export const MANAGED_BRANCH_PREFIX = "pi-subagents/"; // A terminal item (done/rejected/no-change — the shared `isTerminalStatus`, the // ONE closed set) no longer owns a run, so its branches become reclaimable once // provably merged. Every NON-terminal status (proposal/approved/blocked/active/ // ai-review/human-review/failed) is treated as live and PROTECTS any branch it // names — a `failed`/`blocked` item stays re-dispatchable/recoverable, so its // branches must survive. `no-change` joined the terminal set at AUTOPILOT-108. /** A branch's disposition in the plan. */ export interface BranchRetentionEntry { branch: string; /** The branch tip at plan time (null when the ref could not be resolved). */ tip: string | null; /** Queue item keys that name this branch (for the report). */ ownerKeys: string[]; /** Human-readable explanation of the KEEP or PRUNE. */ reason: string; } export interface BranchRetentionPlan { /** Branches that qualify for reclamation (and were deleted when applied). */ pruned: BranchRetentionEntry[]; /** Branches left in place, each with the reason it was kept. */ kept: BranchRetentionEntry[]; /** true when the plan was applied (`dryRun: false`). */ applied: boolean; /** Deletions that FAILED during apply (e.g. checked out by another * worktree) — reported, never thrown. */ failures: Array<{ branch: string; reason: string }>; } export interface BranchRetentionOptions { /** State dir holding queue.json; absent/null = no queue-item exclusions. */ stateDir?: string | null; /** Root of pi-subagents async run records (`/status.json` per run); * absent/null = no live-run-record exclusions. */ runsRoot?: string | null; /** DEFAULT TRUE — the first pass is read-only. `false` applies the plan. */ dryRun?: boolean; /** The sweep window (see the ticket's judgment call). A branch younger than * this is kept UNLESS it is provably merged — which is always required, so * a provably-merged branch is prunable at any age. Kept as an explicit, * named threshold so the policy states the window it reasons against. */ minAgeMs?: number; /** Clock override (tests). */ now?: number; /** Branch prefix to manage; defaults to the `pi-subagents/` namespace. */ prefix?: string; } /** A generous default sweep window, matching `DEFAULT_PRUNE_MIN_AGE_MS` * (worktree-preservation.ts): 48h, so no in-flight or recently-finished run * can be inside it. */ export const DEFAULT_RETENTION_MIN_AGE_MS = 48 * 60 * 60_000; /** One live run record's branch references, for the report. */ export interface LiveRunReference { runId: string; branches: string[]; } export interface LiveRunReferenceOptions { /** The repo to resolve live runs' branches against. When present, a live * run's branch is found the way the runtime actually links them: the run's * `pi-worktree--sN-tN` worktree path (`parallelBranches`, * AUTOPILOT-77). Absent → only explicit branch-shaped strings count. */ repo?: string | null; /** Branch prefix; defaults to the `pi-subagents/` namespace. */ prefix?: string; } /** Escape glob metachars so a prefix cannot widen the for-each-ref pattern. */ function globEscape(s: string): string { return s.replace(/[*?[\\]/g, "[$&]"); } /** Every branch ref under `prefix` (refs/heads/*). Only these are ever * considered — the enumeration itself is the prefix guard. */ function listManagedBranches(repo: string, prefix: string): string[] { const out = git(repo, ["for-each-ref", "--format=%(refname:short)", `refs/heads/${globEscape(prefix)}*`]); if (!out) return []; return out.split("\n").map((l) => l.trim()).filter(Boolean); } /** Branch names a worktree currently has checked out (git refuses to delete * those; and a worktree may be a live run this process cannot enumerate). */ function checkedOutBranches(repo: string): Set { const out = git(repo, ["worktree", "list", "--porcelain"]); const set = new Set(); if (!out) return set; for (const line of out.split("\n")) { if (line.startsWith("branch ")) set.add(line.slice("branch ".length).trim().replace(/^refs\/heads\//, "")); } return set; } /** The run-id-shaped tokens an item records. These are matched against a * branch name by the branch's embedded run-id prefix (the runtime names a * branch `...main-<7-char-runid-prefix>-...`). */ function runIdTokens(item: QueueItem): string[] { return [item.runId, item.lastRunId, item.reviewerRunId, item.specPassRunId].filter( (v): v is string => typeof v === "string" && v.length > 0, ); } /** Does `runId` name `branch`? The runtime embeds a truncated run id (>= 6-7 * chars) in the branch name; compare on a 7-char prefix, case-insensitively. * Short ids (tests, legacy) are not matched by prefix — default to KEEP. */ function runIdNamesBranch(runId: string, branch: string): boolean { const id = runId.toLowerCase(); if (id.length < 7) return false; return branch.toLowerCase().includes(id.slice(0, 7)); } /** Case-insensitive branch-name equality. */ function sameBranch(a: string, b: string): boolean { return a.toLowerCase() === b.toLowerCase(); } /** Does a free-form reference (branch/tag/sha, notes) name `branch`? */ function referenceNamesBranch(ref: string, branch: string): boolean { const r = ref.toLowerCase().trim(); const b = branch.toLowerCase(); if (!r) return false; return r === b || b.includes(r) || r.includes(b); } /** * Does this item's NOTES name `branch`? Notes are the human-signal channel, so * a mention is treated as a keep-and-report signal even on a TERMINAL item * (the ticket's judgment call: an unmerged branch whose item is done and * shipped is worth a human look, not a cleanup action). Structured fields are * deliberately NOT part of this predicate — a terminal item's recorded run id * / worktree branch is the NORMAL reclaim path (its work already landed). */ export function itemNotesMentionBranch(item: QueueItem, branch: string): boolean { return typeof item.notes === "string" && item.notes.toLowerCase().includes(branch.toLowerCase()); } /** * Does this item's STRUCTURED fields name `branch`? The recorded worktree * branch, the finisher source (and its recorded baseline / landed source), and * the item's run ids. Excludes `notes` (see `itemNotesMentionBranch`). */ export function itemReferencesBranch(item: QueueItem, branch: string): boolean { const rw = item.runWorktree?.branch; if (rw && sameBranch(rw, branch)) return true; for (const src of [item.finisherSource, item.finisherBaseline?.source, item.landedEvidence?.source]) { if (typeof src === "string" && src && referenceNamesBranch(src, branch)) return true; } for (const id of runIdTokens(item)) { if (runIdNamesBranch(id, branch)) return true; } return false; } /** * Does this item name `branch` in ANY channel (structured fields or notes)? * The report/plain-check union of the two predicates above. */ export function itemNamesBranch(item: QueueItem, branch: string): boolean { return itemReferencesBranch(item, branch) || itemNotesMentionBranch(item, branch); } function isRecord(v: unknown): v is Record { return typeof v === "object" && v !== null && !Array.isArray(v); } /** Collect managed-branch-shaped strings from a parsed run record. Only a * value that IS the branch name (no whitespace, nothing after it) counts — * prose that merely mentions a branch is not treated as a reference, which * keeps a live run's task text from protecting unrelated branches. */ function collectBranchStrings(value: unknown, prefix: string, out: Set): void { if (typeof value === "string") { const s = value.trim(); if (s.startsWith(prefix) && s.length > prefix.length && !/\s/.test(s)) out.add(s); return; } if (Array.isArray(value)) { for (const v of value) collectBranchStrings(v, prefix, out); return; } if (isRecord(value)) { for (const v of Object.values(value)) collectBranchStrings(v, prefix, out); } } /** * Live run records that reference managed branches. A run is LIVE only when its * status.json names a live phase (LIVE_PHASES — queued/running/paused/blocked, * the same definition run-liveness.ts uses); a terminal run record protects * nothing. A live run's branches are resolved TWO ways: * 1. the REAL runtime link — the run's worktree directory path embeds the * FULL runId (`pi-worktree--sN-tN`), so `parallelBranches(repo, * runId)` finds the branch that worktree has checked out. This is how a * live pi run names a branch at all: its status.json carries NO branch * field (verified against the live run inventory); * 2. a defensive scan for a branch-SHAPED string that IS a managed branch * name (covers any backend/record that names the branch directly). * Best-effort: an unreadable entry is skipped, never thrown. */ export function liveRunReferences(runsRoot: string, opts: LiveRunReferenceOptions = {}): LiveRunReference[] { const prefix = opts.prefix ?? MANAGED_BRANCH_PREFIX; const repo = opts.repo ?? null; const out: LiveRunReference[] = []; let names: string[]; try { names = readdirSync(runsRoot).filter((n) => !n.startsWith(".")); } catch { return out; // no run-record inventory — nothing to exclude on } for (const name of names) { const statusPath = join(runsRoot, name, "status.json"); if (!existsSync(statusPath)) continue; let status: unknown; try { status = JSON.parse(readFileSync(statusPath, "utf8")); } catch { continue; // unreadable/malformed — absence of evidence, not a reference } if (!isRecord(status)) continue; const phase = typeof status.state === "string" ? status.state : typeof status.status === "string" ? status.status : null; if (!phase || !LIVE_PHASES.has(phase)) continue; // only a LIVE record protects const runId = typeof status.runId === "string" && status.runId ? status.runId : name; const branches = new Set(); collectBranchStrings(status, prefix, branches); if (repo && runId) { for (const b of parallelBranches(repo, runId)) { if (b.startsWith(prefix)) branches.add(b); } } if (branches.size === 0) continue; out.push({ runId, branches: [...branches] }); } return out; } /** The age of a branch tip in ms, from its commit time; null when unknown. */ function branchAgeMs(repo: string, branch: string, now: number): number | null { const raw = git(repo, ["log", "-1", "--format=%ct", branch]); if (!raw) return null; const seconds = Number(raw); if (!Number.isFinite(seconds) || seconds <= 0) return null; return now - seconds * 1000; } /** * PLAN (and optionally APPLY) the bounded retention pass. Read-only by default. * Never throws for git failures — an unresolvable branch is KEPT, loudly. */ export function retainManagedBranches(repo: string, opts: BranchRetentionOptions = {}): BranchRetentionPlan { const dryRun = opts.dryRun ?? true; const prefix = opts.prefix ?? MANAGED_BRANCH_PREFIX; const minAgeMs = opts.minAgeMs ?? DEFAULT_RETENTION_MIN_AGE_MS; const now = opts.now ?? Date.now(); const plan: BranchRetentionPlan = { pruned: [], kept: [], applied: !dryRun, failures: [] }; const store = opts.stateDir ? loadStore(opts.stateDir) : null; const items = store ? Object.values(store.items) : []; const liveRefs = opts.runsRoot ? liveRunReferences(opts.runsRoot, { repo, prefix }) : []; const checkedOut = checkedOutBranches(repo); // `main` must exist to prove merge; without it nothing is provably merged, // so every branch is kept (default-to-KEEP on undeterminable status). const hasMain = ["refs/remotes/origin/main", "refs/heads/main"].some( (ref) => git(repo, ["rev-parse", "--verify", "--quiet", ref]) !== null, ); for (const branch of listManagedBranches(repo, prefix)) { const tip = git(repo, ["rev-parse", "--verify", `${branch}^{commit}`]); if (!tip) { plan.kept.push({ branch, tip: null, ownerKeys: [], reason: "branch ref could not be resolved — kept (status undeterminable)" }); continue; } const openItems = items.filter((it) => !isTerminalStatus(it.status) && itemNamesBranch(it, branch)); const terminalNotes = items.filter((it) => isTerminalStatus(it.status) && itemNotesMentionBranch(it, branch)); const terminalOwners = items.filter((it) => isTerminalStatus(it.status) && itemReferencesBranch(it, branch)); const liveRun = liveRefs.find((r) => r.branches.includes(branch)); const ownerKeys = [...openItems, ...terminalNotes, ...terminalOwners].map((it) => it.key); // An OPEN item claiming the branch outranks every other consideration — // it may still write to that branch, so it is never pruned. if (openItems.length > 0) { plan.kept.push({ branch, tip, ownerKeys, reason: `referenced by open item ${openItems[0].key} (${openItems[0].status}) — never pruned` }); continue; } // A live run record (queued/running/paused/blocked) keeps it for the same // reason. Grounded in the real worktree-path link and checked before the // checked-out guard so the specific, evidence-backed reason is reported. if (liveRun) { plan.kept.push({ branch, tip, ownerKeys, reason: `referenced by a live run record (run ${liveRun.runId})` }); continue; } if (checkedOut.has(branch)) { plan.kept.push({ branch, tip, ownerKeys, reason: "checked out in a worktree (git refuses the delete; a live run may own it)" }); continue; } if (!hasMain) { plan.kept.push({ branch, tip, ownerKeys, reason: "no main ref to compare against — not provably merged, kept" }); continue; } // A mention in a terminal item's NOTES is a human signal: report it, never // act on it (the ticket's "done and shipped but unmerged → report"). This // holds even when the branch is merged, so a notes mention can never be // silently reclaimed. if (terminalNotes.length > 0) { const owner = terminalNotes[0]; plan.kept.push({ branch, tip, ownerKeys, reason: `named in terminal item ${owner.key}'s notes (${owner.status}${owner.shippedAt ? ", shipped" : ""}) — reported for a human look, not pruned`, }); continue; } const merged = tipReachableFromMain(repo, tip); if (!merged) { const age = branchAgeMs(repo, branch, now); const withinWindow = age !== null && age < minAgeMs; plan.kept.push({ branch, tip, ownerKeys, reason: withinWindow ? `unmerged, inside the ${Math.round(minAgeMs / 3_600_000)}h sweep window — kept` : "unmerged — recovery may still need it", }); continue; } // Provably merged + unreferenced (no open item, no live record, no notes // mention). Distinguish WHY it qualified: a terminal item that still maps // via its structured fields means the attempt's work landed (`merged`); // nothing maps to it at all means orphaned litter (`abandoned`). const reason = terminalOwners.length > 0 ? "merged" : "abandoned"; plan.pruned.push({ branch, tip, ownerKeys, reason }); if (!dryRun) { // `-d` (never `-D`): the SAFE delete, git-refused unless the branch is // genuinely merged into HEAD — a second, independent guard on top of the // merge-base check. Reversible through the reflog; no gc, no expire. if (git(repo, ["branch", "-d", branch]) === null) { plan.failures.push({ branch, reason: "git branch -d failed (checked out elsewhere, or refused as unmerged)" }); // move it out of the "removed" list — it is still present plan.pruned = plan.pruned.filter((e) => e.branch !== branch); } } } return plan; }