ADR-0069 — WorkspaceGCLoop: Autonomous Worktree Garbage Collection¶
Status: Proposed Date: 2026-05-19 Enforced by: tests/test_workspace_gc_loop.py, tests/regressions/test_workspace_gc_landed_safety.py, tests/regressions/test_issue_11503.py, tests/regressions/test_issue_11507.py, tests/regressions/test_issue_11570.py, tests/regressions/test_issue_11571.py, tests/architecture/test_workspace_gc_landed_guard.py, tests/scenarios/test_workspace_gc_landed_guard_scenario.py
Context¶
The implementation phase creates a git worktree per issue via WorkspacePort.create. When a PR is merged, the post-work cleanup normally destroys the worktree. Three leak classes exist where cleanup does not run:
- A PR is merged manually via the GitHub UI (not through the orchestrator's merge path).
- A human resolves a HITL issue and closes the PR, bypassing the orchestrator.
- The orchestrator crashes or is restarted while a cleanup step is in flight.
Over time these leaks accumulate worktree directories on disk, orphaned branches on the remote, and stale StateTracker entries. The disk pressure and remote branch clutter are visible noise; the stale state entries can cause the pipeline to treat an issue as in-flight when it is not.
Decision¶
Introduce WorkspaceGCLoop, a BaseBackgroundLoop that runs a five-phase GC pass on every tick:
- Phase 1 — tracked workspaces: for each entry in
StateTracker.get_active_workspaces(), require issue-policy safety plus the shared clean-and-landed predicate before removing state and callingWorkspacePort.destroy(). - Phase 2 — orphaned disk directories: scan the worktree root for directories that have no
StateTrackerentry, then apply the same issue-policy and landed checks before destruction. - Phase 3 — orphaned local branches: list local branches in every issue namespace; leave alone any whose issue is tracked, active, in the pipeline, in a retry window, or labelled, and any checked out in a registered worktree; force-delete the rest only after the same landed predicate proves the branch tip landed on
origin/<base>. - Phase 4 — stale branch state: prune branch-state entries only after their issue is safe and no worktree remains.
- Phase 5 — all-root worktrees: enumerate authoritative
git worktree list --porcelainentries under configured roots and apply the same landed predicate before direct worktree removal.
Issue state is necessary but never sufficient for worktree destruction. A closed-as-not-planned issue can still own unpushed commits (#11503), and the standard-path phases must not bypass the guard used by the all-root phase (#11507).
The landed predicate is exact-HEAD-aware (#11502):
origin/<base>..HEADat zero proves the HEAD is already ancestral.- An empty two-rev tree diff (
origin/<base>versusHEAD) recognizes a fresh squash merge even though the original commit SHAs never landed. - Once the base advances and that tree diff naturally diverges, GitHub PR
history is authoritative only when exactly one merged PR matches the
configured integration base, branch name, and worktree's current
HEADSHA. A PR into another base—or a merely merged PR on a reused branch name—is not evidence that this HEAD landed onorigin/<base>.
Malformed git output, unknown refs, absent registered paths, dirty worktrees,
branch/issue/path identity mismatches, GitHub read failures, truncated result
pages, and ambiguous exact matches all fail closed. An attributed candidate
must contain a .git marker and report its own canonical candidate path from
git rev-parse --show-toplevel before status is read; this prevents a nested
non-git directory or misdirected gitfile from borrowing another checkout's
proof. An empty, existing, unattributed non-git directory is the only non-git
candidate considered provably empty. Git comparisons are pinned to the
initially captured OID and the clean HEAD identity is re-read before
destruction, preventing a concurrent branch move from mixing proofs.
Cap at _MAX_GC_PER_CYCLE = 20 collections per tick to avoid long-running passes. State removal precedes destroy() so a crash between the two steps leaves the entry gone rather than leaking permanently (destroy() is idempotent).
Kill-switch: enabled_cb("workspace_gc") AND config.workspace_gc_loop_enabled.
Consequences¶
- Worktree leaks become self-healing; operators do not need to run manual
git worktree prunecommands. - The pipeline's active-workspace view in
StateTrackerreflects reality within one GC interval. - The 20-per-cycle cap means large backlogs drain gradually; acceptable because GC is low-priority background work.
- Retry-window safety contract: a worktree is never collected while an in-flight attempt may still be committing into it.
_is_safe_to_gc(and the orphan-branch phase) consult_in_retry_window, which skips whenever either the implementation counter (get_issue_attempts) or theauto_agentconvergence-ledger counter (get_auto_agent_attempts) is in-window (0 < attempts < max). Both counters are bumped before a run and cleared on success/close, so between those moments the issue is absent from every active set even though a live session owns the worktree. Consulting only the implementation counter let GC sweep an actively-running auto-agent worktree and lose its unpushed commits (#10459, the #10403 race). The residual last-attempt gap (attempts == maxon the final run) is the same theoretical window the implementation guard has always had; fully closing it needs a live session lock/heartbeat, tracked separately. - Destroy-target identity contract: phase 1 compares the state-recorded path
with the config-derived path that
WorkspacePort.destroy(issue)will actually remove. It never inspects one directory and deletes another. - Lossless ownership contract: once at cycle start, GC validates the raw
active_workspaceskeys and canonicalizes their paths into a reverse path-to-owner map reused by phases 1, 2, and 5. Every owned path is skipped even when its directory name or current branch attributes to another issue. Non-integer/non-canonical/duplicate-equivalent keys and empty, relative, NUL-bearing, non-string, or unresolvable paths make ownership unknowable and disable the entire destructive cycle before workspace, branch, or state mutation. GC never consumes the ordinary lossy normalized state view for this decision. - Residual proof-to-delete race: the predicate re-reads a clean, unchanged HEAD immediately before authorizing collection, but that read and the later filesystem/git deletion are not one atomic operation. Existing active, pipeline, retry-window, minimum-age, and stop gates make a concurrent writer in this interval abnormal. Eliminating the interval entirely requires a shared workspace ownership lock spanning every writer and GC implementation.
- Branch-tip proof contract (#11571): phase 3 is under the same proof as
every worktree destroy. The ladder's rungs (ancestry, squash-tree equality,
exact-HEAD merged PR into the configured base) are one shared generator with
two identity front-ends — the worktree's clean
HEADfor phases 1, 2 and 5, andrev-parse --verify refs/heads/<branch>^{commit}for phase 3 — and one driver in the loop, so there is exactly one place a proof can be wrong. The tip is re-read after positive proof beforegit branch -D, mirroring the worktree status re-read. Phase 3's active/pipeline/retry/label guards remain the liveness gate for in-flight sessions; it deliberately does not add_is_safe_to_gc's per-branch issue-state reads, because a tip that provably landed cannot lose work whatever the issue state, and a tip that did not is never deleted. A branch checked out in any registered worktree — prunable (directory gone) and locked registrations included, exactly the setgit branch -Drefuses — is the all-root sweep's to reap and is skipped at DEBUG, not surfaced as a per-cycle error. - Gone-versus-unavailable contract (#11570): a tracked entry whose destroy
target has nothing at it (not even a dangling symlink) while
workspace_base/<repo_slug>is a present directory is pruned fromactive_workspaceswith nothing destroyed and no branch touched — there is nothing to destroy and nothing to lose, and leaving it made phases 3–4 skip that issue forever. The branch-state entry is left to phase 4 and the branch ref to phase 3, each under its own guard. A missing workspace root is the transient-mount shape (#6413) and stays fail-closed, as does every other preflight failure (present-but-unlanded, non-git attributed directory, identity mismatch): the prune keys on the path being absent, never on the proof failing.
Alternatives considered¶
- GC at merge-path only. Already the first line of defense, but does not cover manual merges, HITL closures, or crash-mid-cleanup.
- Cron script outside the orchestrator. Possible but adds an out-of-process dependency; the orchestrator already has the state context needed to decide what's safe to GC.