feat(worktrees): reap worktrees whose work has already landed (PP-49x5) - #1797
Conversation
Nothing in PinPoint ever removed an agent worktree that was still on disk. Measured on main today: 60 worktrees, 66 GB, 59 of 96 port slots consumed — 43 of them on branches whose PR already merged with a clean tree, plus 8 bridge worktrees carrying zero commits ahead of origin/main. All three existing mechanisms miss this by construction. worktree_cleanup.py only runs when something *else* initiates removal, and a background agent that commits, pushes and ends never initiates. worktree_orphan_sweep.py reconciles slot entries whose directory is *gone*, so a worktree still on disk is "active" to it — and its Supabase volumes are deliberately protected. stale-worktrees.sh is hard-scoped to ../pinpoint-worktrees/*. The predicate matters more than the plumbing. stale-worktrees.sh uses "no open PR + clean", which is indistinguishable from an agent working right now that hasn't opened its PR yet — at measurement time zero worktrees had an open PR while at least two agents were live, so that rule would have deleted live work. worktree_reap.py reaps only on positive proof that the work is already on main: a merged PR whose headRefOid *is* the local HEAD with a clean tree, or zero commits ahead of origin/main with no PR. The SHA equality is load-bearing — branches are squash-merged, so an is-ancestor test gives a false negative on every merged branch. - scripts/worktree_reap.py: dry-run by default; delegates removal to worktree_cleanup.py and surfaces its exit codes with their meanings rather than flattening them to "failed" (the standing complaint in PP-r7tv). An unreachable gh makes the affected branches UNKNOWN, not "no PR" — they classify REVIEW and the run exits non-zero, so an outage is a no-op rather than a mass deletion. - merge-pr.sh reaps the merged branch's worktree in a fail-open subshell mirroring the post-merge huddle notice; the merge already happened, so no post-step may propagate an error. - The SessionStart hook runs it alongside the sweep, dry-run, under the same 6h throttle with its own 10s budget (2.2s over 62 worktrees in practice). - Corrects the false premise in AGENTS.md, CLAUDE.md and the orchestrator skill that the WorktreeRemove hook handles finished agent worktrees. test_merge_pr_automerge.py now shadows python3 for the same reason it shadows bd: unshadowed, a test merge would run worktree_reap.py --apply against this machine's real checkout. Dry-run against the live host: 52 REAP (44 merged, 8 empty), 7 REVIEW, 3 KEEP. feature/pp-4zcj-oxlint and its untracked plan doc survive twice over — dirty tree, and a live process cwd. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VypoeEVeXdF7Y3KfyWYeP8
Self-review pass. `reap()` shadowed the module name tests import it under, so it is now `run_cleanup()`. The `--apply` path gained a "Reaped N of M" line for the interactive run, with the failure count tracked separately from the set of distinct exit codes — deriving one from the other would report "Reaped 1 of 2" when two worktrees failed with the same code, leaving a leak unaccounted for. Also drops a `not not_quiet` double negative. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VypoeEVeXdF7Y3KfyWYeP8
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
There was a problem hiding this comment.
Pull request overview
Adds an explicit “reap finished worktrees” mechanism to prevent long-lived agent worktrees from accumulating on disk and consuming Supabase port slots, while keeping safety grounded in positive proof (merged-SHA equality or zero-commits-ahead) and preserving dry-run-by-default discipline.
Changes:
- Introduces
scripts/worktree_reap.pyto classify and (optionally) remove worktrees whose work has already landed, delegating deletion toworktree_cleanup.pyand preserving meaningful exit codes. - Hooks reaping into
merge-pr.sh(fail-open) and the Claude SessionStart audit hook (dry-run only, time-capped). - Adds a comprehensive pytest suite for the classifier and hardens the merge workflow test harness to stub
python3to prevent real reaps during tests.
Reviewed changes
Copilot reviewed 8 out of 8 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
| scripts/worktree_reap.py | New reaper script: PR-state lookup + git-state predicate + optional delegation to worktree_cleanup.py. |
| scripts/workflow/merge-pr.sh | Runs a fail-open reap pass for the merged branch’s worktree after a successful merge. |
| scripts/tests/test_worktree_reap.py | Adds end-to-end classifier tests using real throwaway repos/worktrees and stubbed gh/cleanup. |
| scripts/tests/test_merge_pr_automerge.py | Stubs python3 and asserts the new post-merge reap step doesn’t affect merge exit status. |
| CLAUDE.md | Updates documentation to distinguish cleanup-on-removal vs reaping-finished-worktrees. |
| AGENTS.md | Documents the new reaping tool and the SessionStart dual-audit behavior. |
| .claude/hooks/session-start-orphan-sweep.sh | Adds a second dry-run audit pass for reaping finished worktrees, each with its own 10s cap. |
| .agents/skills/pinpoint-orchestrator/SKILL.md | Updates worktree-health guidance and adds the new reap command. |
`gh` resolves which repository to query from its own working directory, and worktree_reap.py is invoked with an arbitrary cwd — a SessionStart hook, or merge-pr.sh from wherever the shell happens to be. `--repo-dir` steered `git worktree list` but not `gh`. The benign failure is every branch coming back UNKNOWN, making the reap a no-op. The one that matters is running from inside a *different* git repo: `gh pr list --head <branch>` would then answer from that repo, and a same-named branch with a merged PR there would read as positive proof that this repo's worktree had landed. Passing `cwd=repo_dir` makes the answer independent of the caller. Caught by Copilot review on #1797. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VypoeEVeXdF7Y3KfyWYeP8
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 8 out of 8 changed files in this pull request and generated no new comments.
Suppressed comments (2)
scripts/worktree_reap.py:81
- CLEANUP_EXIT_MEANINGS[1] is more specific than worktree_cleanup.py’s documented EXIT_FAILED meaning (which can also be a usage error). This could mislead someone interpreting a propagated exit code 1.
1: "FAILED — worktree not removed; slot kept to avoid a port collision",
scripts/worktree_reap.py:69
- EXIT_GH_UNAVAILABLE is set to 1, but 1 is also a valid propagated exit code from worktree_cleanup.py (EXIT_FAILED). That makes a top-level exit status of 1 ambiguous ("gh unavailable" vs "cleanup failed"), which undermines the goal of preserving meaningful cleanup exit codes for callers.
This issue also appears on line 81 of the same file.
#: At least one branch's PR state could not be determined, so the report is
#: incomplete and nothing that depended on that state was reaped. Deliberately
#: the same value as `worktree_orphan_sweep.EXIT_DOCKER_UNKNOWN`: in both
#: scripts, 1 means "this run could not see everything".
EXIT_GH_UNAVAILABLE = 1
…nge (PP-49x5) Surfacing worktree_cleanup.py's exit codes verbatim is the point of this script (PP-r7tv), and that only works if its own statuses can't be mistaken for one of them. Two collisions did exactly that: - EXIT_GH_UNAVAILABLE was 1, matching worktree_orphan_sweep's "could not see everything" convention — but 1 is also cleanup's EXIT_FAILED, so a top-level 1 meant either "gh was unreachable" or "a cleanup failed". The sweep can use 1 because it propagates nobody else's codes. This script can't. Now 6. - run_cleanup() returned 1 when cleanup couldn't be *launched* at all, which is a different problem with a different fix than cleanup exiting 1. Now a distinct EXIT_CLEANUP_UNRUNNABLE = 7. CLEANUP_EXIT_MEANINGS[1] also claimed more than worktree_cleanup.py does — it said "worktree not removed" where cleanup documents "usage error, or the git worktree removal itself failed". Narrowed wordings mislead whoever reads a propagated code, so it now matches the source. Locked by test_this_scripts_own_exit_codes_never_collide_with_cleanups, which fails if any future status lands inside 0-4. Both raised as suppressed comments in Copilot's review of 288f255. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VypoeEVeXdF7Y3KfyWYeP8
|
Acted on both suppressed comments from the last review (953601d), because they land squarely on what this PR claims to do.
Chasing that turned up a third instance you did not see:
—Claude |
Why
Nothing in PinPoint ever removed an agent worktree that was still on disk. Measured on
maintoday: 60 worktrees, 66 GB, 59 of 96 port slots consumed (61% of the address space). 43 were on branches whose PR already merged with a clean tree; 8 more were bridge worktrees carrying zero commits ahead oforigin/main.All three existing mechanisms miss this by construction:
WorktreeRemovehook →worktree_cleanup.pyExitWorktreeis scoped toEnterWorktreesessions and doesn't apply toAgent(isolation:"worktree").worktree_orphan_sweep.pystale-worktrees.sh../pinpoint-worktrees/*.The predicate matters more than the plumbing
stale-worktrees.shuses "no open PR + clean", which is indistinguishable from an agent working right now that hasn't opened its PR yet. At the moment of measurement zero worktrees had an open PR while at least two agents were live — that rule would have deleted live work.So this reaps only on positive proof that the work is already on
main:HEADis that PR'sheadRefOidand clean tree. The SHA equality is load-bearing: branches are squash-merged, so their commits are never ancestors ofmainand anis-ancestortest gives a false negative on every merged branch.HEADpast the merged SHA = post-merge work → REVIEW.origin/main.Unknown is never mergedness. If
ghcan't be queried, those branches are UNKNOWN — not "no PR". They classify REVIEW, never REAP, and the run exits non-zero. Same discipline as the sweep's "unknown is never zero" (PP-5o7b) andworktree_cleanup.py's volume query (PP-3w4g). A totalghoutage is a no-op, not a mass deletion.What's here
scripts/worktree_reap.py(new) — dry-run by default. Delegates removal toworktree_cleanup.py(Supabase stop, Docker volumes, slot dealloc already live there) and surfaces its exit codes with their meanings rather than flattening them to "failed" — flattening is the standing complaint in PP-r7tv. Never the main worktree, never one containing the invoking cwd, never a live process's cwd (Linux/proc, macOSlsof; unavailable → note and continue, since it's defence in depth, not the safety floor).--quietskipsdu.scripts/workflow/merge-pr.sh— reaps the merged branch's worktree in a fail-open subshell mirroring the post-merge huddle notice exactly (set +e,|| true). The merge already happened; no post-step may propagate an error. The reap re-derives the verdict itself — merge-pr.sh passes no opinion about safety..claude/hooks/session-start-orphan-sweep.sh— second dry-run pass alongside the sweep, same 6h throttle, its own 10s budget.AGENTS.md§4,CLAUDE.md, and the orchestrator skill all asserted that agent worktrees "are handled by theWorktreeRemovehook". That premise is false and is why nobody noticed.Tests
scripts/tests/test_worktree_reap.py(26 tests) — real throwaway git repos with real worktrees so the commit-graph questions get real answers;ghandworktree_cleanup.pystubbed, so nothing reaches GitHub and nothing is removed. Covers every classifier edge includingtest_squash_merged_branch_is_still_reaped, which anis-ancestorimplementation fails, andtest_untracked_file_alone_blocks_the_empty_reap.test_merge_pr_automerge.pynow shadowspython3for the same reason it shadowsbd: unshadowed, a test merge would runworktree_reap.py --applyagainst this machine's real checkout. Plus a case pinning that a failing reap doesn't change merge-pr.sh's exit status.Verification
pnpm run checkgreen. Live dry-run against the host (62 worktrees): 52 REAP (44 merged, 8 empty), 6 REVIEW, 4 KEEP, 2.2s in--quiet(inside the hook's 10s ceiling), 7.9s withdu.feature/pp-4zcj-oxlintand its untracked plan doc — the file that exists nowhere else on disk — survive twice over: dirty tree and a live process cwd.--applywas deliberately not run. The tool ships; Tim runs the reclamation.Out of scope
Branch reaping (PP-m22p, 183 leaked
worktree-agent-*branches), folding instale-worktrees.sh, and the REVIEW worktrees themselves — those need judgment, not a rule.worktree_cleanup.py's contract is unchanged; theWorktreeRemovehook still calls it.Closes PP-49x5.
🤖 Generated with Claude Code
https://claude.ai/code/session_01VypoeEVeXdF7Y3KfyWYeP8