# AUTOPILOT-94 — bounded retention for the `pi-subagents/*` branch namespace

## The problem

Every subagent run the runtime isolates gets a branch under `pi-subagents/`
(today's convention: `pi-subagents/main-<7-char-runid-prefix>-<rand>-sN-tN`),
and **nothing ever removes one**. `git branch --no-merged HEAD` reported 133
unmerged branches. That namespace is exactly the one the ORCHESTRATOR must
SEARCH to locate a deliverable, so the litter is not just disk: ACTUATOR-43's
review task named a STALE branch (`main-dcddb83`, which does not contain
`e1761dc`) among four candidates, and a reviewer was dispatched against it by
mistake — caught only by a hand-run `git merge-base --is-ancestor`.

`worktree-preservation.ts`'s `pruneStaleWorktrees` reclaims WORKTREES. This
change adds `retainManagedBranches` (`src/framework/branch-retention.ts`) to
reclaim the BRANCHES those worktrees leave behind, runnable via
`bun run prune-branches`.

## The rule

Prune a `pi-subagents/*` 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. Every exclusion
defaults to KEEP when status cannot be determined — an orphaned branch costs a
little space; a pruned deliverable costs the run.

Never pruned:

- any branch outside the `pi-subagents/` prefix (the enumeration only 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`;
- any branch referenced by a **live run record** (a `status.json` in a
  queued/running/paused/blocked phase — `run-liveness.ts`'s `LIVE_PHASES`).
  The run→branch link is the one the runtime actually writes (AUTOPILOT-77):
  the run's `pi-worktree-<full-runId>-sN-tN` worktree path, resolved via
  `parallelBranches` — **not** a `branch` field in `status.json`, which does
  not exist;
- any branch currently checked out in a worktree (git refuses the delete; the
  worktree may belong to a run this process cannot see);
- any branch whose merge status cannot be determined (no `main` ref).

Reported, never pruned: a branch named only in a TERMINAL item's NOTES. A
done-and-shipped item whose branch is unmerged is a signal worth a human look,
not a cleanup action.

Prune reasons are distinguished: `merged` — a terminal item still maps to the
branch via its structured fields; `abandoned` — no item maps to it at all.

The pass is READ-ONLY BY DEFAULT (`dryRun: true`); applying it is an explicit
`--apply`. It only ever issues `git branch -d` (the safe delete git refuses on
an unmerged branch), never `-D`, never `git gc`, never a reflog expire — a
mistaken prune stays reversible through the reflog.

## Merge resolution (the AUTOPILOT-94 review fix)

`tipReachableFromMain` (`worktree-preservation.ts`) previously returned at the
FIRST existing main ref in the order `refs/remotes/origin/main`,
`refs/heads/main` — so a STALE remote-tracking ref made every post-lag merge
look unmerged. In this repo `refs/remotes/origin/main` (`78fe0f58`) lags local
`refs/heads/main` by dozens of merges. It now accepts a tip that is an ancestor
of EITHER ref. This also fixes the same class of misinformation in
auto-dispatch's stale-vs-main label (`priorAttemptContext`), which told workers
"NOT in main" about already-merged work.

## First read-only pass against the real repo

Run on 2026-09-14, HEAD `9a3d69a`, repo tip = the retention branch, local
`main` = `6da0d22`, `origin/main` = `78fe0f58`. Command:

```
bun run prune-branches          # dry-run is the default; nothing was removed
```

Plan (140 managed branches enumerated): **pruned = 2, kept = 138**.

Reasons, by category:

| disposition | count | why |
| --- | --- | --- |
| PRUNE — `abandoned` | 2 | provably merged (ancestor of local and origin main), no queue item maps to it, not checked out |
| KEEP — checked out in a worktree | 134 | a live worktree has the branch checked out (git would refuse the delete anyway) |
| KEEP — referenced by a live run record | 3 | run's worktree path embeds the full runId (`parallelBranches` link) |
| KEEP — referenced by an open item | 1 | open item `AUTOPILOT-90` (`blocked`) names it |

The two PRUNED branches:

- `pi-subagents/main-05ae8d8-5435-s0-t0` (`abandoned`)
- `pi-subagents/main-23cf575-068a-s0-t0` (`abandoned`)

Notable KEEPs: `pi-subagents/main-276cbf7-e4c8-s0-t0`, `main-b7f7cf9-ed47-s0-t0`,
`main-bce4f2f-2df9-s0-t0` are each held by a LIVE run record (resolved through
the worktree-path link, not any field in `status.json`); `main-dfc5f8b-d368-s0-t0`
is held by open item `AUTOPILOT-90`.

The large checked-out KEEP count is expected and honest: this repo currently
has ~142 registered worktrees, nearly one per managed branch, so most litter is
pinned by a live worktree. As `pruneStaleWorktrees` reclaims those worktrees,
the freed branches become prunable by this pass — which is why the merge gate
fix matters: with a stale `origin/main` those branches would have been reported
"unmerged" and kept forever, exactly the litter this ticket exists to bound.

The full plan (every branch + reason) is reproduced by `bun run prune-branches`
(or `bun run prune-branches -- --json`). This first pass was deliberately NOT
applied.
