Skip to content

feat(worktrees): reap worktrees whose work has already landed (PP-49x5) - #1797

Merged
timothyfroehlich merged 4 commits into
mainfrom
worktree-agent-aa5228b1b5b59e150
Aug 2, 2026
Merged

feat(worktrees): reap worktrees whose work has already landed (PP-49x5)#1797
timothyfroehlich merged 4 commits into
mainfrom
worktree-agent-aa5228b1b5b59e150

Conversation

@timothyfroehlich

Copy link
Copy Markdown
Owner

Why

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 (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 of origin/main.

All three existing mechanisms miss this by construction:

Mechanism Why it misses
WorktreeRemove hook → worktree_cleanup.py Only fires when something else initiates removal. A background agent that commits, pushes and ends never initiates. ExitWorktree is scoped to EnterWorktree sessions and doesn't apply to Agent(isolation:"worktree").
worktree_orphan_sweep.py Reconciles slot entries whose directory is gone. A worktree still on disk is "active" by its definition — invisible, and its Supabase volumes are deliberately protected.
stale-worktrees.sh 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 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:

  • REAP/merged — merged PR and local HEAD is that PR's headRefOid and clean tree. The SHA equality is load-bearing: branches are squash-merged, so their commits are never ancestors of main and an is-ancestor test gives a false negative on every merged branch. HEAD past the merged SHA = post-merge work → REVIEW.
  • REAP/empty — no merged or open PR, clean tree, zero commits ahead of origin/main.
  • REVIEW — unmerged commits or a dirty tree (untracked files count). Reported every run, never touched.
  • KEEP — open PR, or a live process whose cwd is inside the worktree.

Unknown is never mergedness. If gh can'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) and worktree_cleanup.py's volume query (PP-3w4g). A total gh outage is a no-op, not a mass deletion.

What's here

  • scripts/worktree_reap.py (new) — dry-run by default. Delegates removal to worktree_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, macOS lsof; unavailable → note and continue, since it's defence in depth, not the safety floor). --quiet skips du.
  • 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.
  • DocsAGENTS.md §4, CLAUDE.md, and the orchestrator skill all asserted that agent worktrees "are handled by the WorktreeRemove hook". 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; gh and worktree_cleanup.py stubbed, so nothing reaches GitHub and nothing is removed. Covers every classifier edge including test_squash_merged_branch_is_still_reaped, which an is-ancestor implementation fails, and test_untracked_file_alone_blocks_the_empty_reap.

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. Plus a case pinning that a failing reap doesn't change merge-pr.sh's exit status.

Verification

pnpm run check green. 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 with du. feature/pp-4zcj-oxlint and its untracked plan doc — the file that exists nowhere else on disk — survive twice over: dirty tree and a live process cwd.

--apply was deliberately not run. The tool ships; Tim runs the reclamation.

Out of scope

Branch reaping (PP-m22p, 183 leaked worktree-agent-* branches), folding in stale-worktrees.sh, and the REVIEW worktrees themselves — those need judgment, not a rule. worktree_cleanup.py's contract is unchanged; the WorktreeRemove hook still calls it.

Closes PP-49x5.

🤖 Generated with Claude Code

https://claude.ai/code/session_01VypoeEVeXdF7Y3KfyWYeP8

timothyfroehlich and others added 2 commits August 1, 2026 14:36
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
@vercel

vercel Bot commented Aug 1, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
pin-point Ready Ready Preview Aug 1, 2026 8:10pm

Request Review

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.py to classify and (optionally) remove worktrees whose work has already landed, delegating deletion to worktree_cleanup.py and 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 python3 to 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.

Comment thread scripts/worktree_reap.py
`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

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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
@timothyfroehlich

Copy link
Copy Markdown
Owner Author

Acted on both suppressed comments from the last review (953601d), because they land squarely on what this PR claims to do.

EXIT_GH_UNAVAILABLE = 1 colliding with cleanup's EXIT_FAILED — right, and I had talked myself into it during design by pointing at worktree_orphan_sweep.EXIT_DOCKER_UNKNOWN = 1. That precedent does not transfer: the sweep is free to use 1 because it propagates nobody else's codes. This script propagates worktree_cleanup.py's, so a top-level 1 was ambiguous between "gh was unreachable" and "a cleanup failed" — the same information loss as flattening, reached from the other direction. EXIT_GH_UNAVAILABLE is now 6, and 0-4 are reserved for cleanup exclusively.

Chasing that turned up a third instance you did not see: run_cleanup() also returned 1 when cleanup could not be launched. Nothing ran in that case, so there is no cleanup verdict to report — now EXIT_CLEANUP_UNRUNNABLE = 7.

CLEANUP_EXIT_MEANINGS[1] overclaiming — also right. worktree_cleanup.py documents 1 as "usage error, or the git worktree removal itself failed"; mine said only the second half. A narrower wording than the source is worse than none, since the whole point is that a caller can trust the code it was handed. Now matches.

test_this_scripts_own_exit_codes_never_collide_with_cleanups fails if any future status lands inside cleanup's range, so the invariant is now structural rather than remembered.

—Claude

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 8 out of 8 changed files in this pull request and generated no new comments.

@timothyfroehlich timothyfroehlich added the ready-for-review PR passed CI and has no unresolved review comments label Aug 1, 2026
@timothyfroehlich
timothyfroehlich merged commit aece686 into main Aug 2, 2026
22 checks passed
@timothyfroehlich
timothyfroehlich deleted the worktree-agent-aa5228b1b5b59e150 branch August 2, 2026 14:48
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ready-for-review PR passed CI and has no unresolved review comments

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants