Skip to content

feat(worktree): report reapable venvs behind nine conjuncts, delete none - #1244

Merged
wshallwshall merged 3 commits into
mainfrom
guard-reapvenvs
Sep 17, 2026
Merged

wshallwshall merged 3 commits into
mainfrom
guard-reapvenvs

Conversation

@wshallwshall

Copy link
Copy Markdown
Collaborator

Virtual environments are 19.66 GB, 83 percent of a 63 GB worktree class. .venv/ is gitignored and
constraints.lock is tracked, so a venv is rebuildable state: deleting one costs a rebuild, deleting
a worktree costs work. Different blast radii, so different gates.

This reports. It deletes nothing. Every adversarial pass over a reaper for this workspace returned
NEEDS_A_GUARD, so the deliverable is the dry run.

There is no venv deletion path, verified three ways

An independent verifier established this before the branch was opened.

Armed first. The removal-verb search fires: run against scripts/worktree/remove.ps1 it returns 3
hits including a live Remove-Item -LiteralPath $f.FullName -Force -EA Stop. The same pattern over
this branch's added script lines returns 0.

Cmdlet inventory over the whole new block: Test-Path, Get-ChildItem, Get-Item, Join-Path,
Split-Path, Measure-Object, the *-Object filters, and the script's own helpers. No removal verb
of any kind. Every git call it reaches is rev-parse, for-each-ref, status --porcelain or
gh pr list.

Asserted on disk, not on the report. test_there_is_no_venv_deletion_path_anywhere_in_the_script
runs -ReapVenvs -Apply, gets REAPABLE, then asserts pyvenv.cfg and site-packages/mod.py still
exist.

The exit-code defect this branch introduced, and fixed

Set-Exit $EXIT_REFUSED ran at line 1223, before $removed is initialised at 1477, so it could not
carry the guard the -Name path has at 1717. A -ReapVenvs -Apply run could remove worktrees and
still exit 2, which the header defines as REFUSED, nothing was attempted.

Reachability was established by walking the AST, not by grep -- every exit, return, throw,
break and continue between the two points was classified by whether it sits inside a
FunctionDefinitionAst. None can terminate the script or skip the apply loop. Set-Exit is defined
once and has no side effect.

Red before, green after:

AssertionError: removed 2 worktree(s) and reported exit 2;
                2 is documented as REFUSED -- nothing was attempted
2 failed, 98 deselected          LASTEXITCODE: 1

5 passed, 95 deselected          LASTEXITCODE: 0

The three neighbouring refusal tests run as controls in that second set: they are dry runs and must
stay at exit 2. The refusal cause is C9's absent transcript store, not -IdleHours 0, so the removing
half and the refusing half move independently.

Exit 2 is still not a promise that nothing happened, and the docs now say so

A mid-run fence death refuses inside the apply loop and carries no $removed guard either. A run can
remove two worktrees, lose the fence on the third, and exit 2.

That is not fixed here. It is a second behaviour change to a destructive tool's exit codes and it
was not briefed. Both copies of the enumeration now read "something you asked for was not attempted"
rather than a repaired universal, which is true of every path. The -Name path carries the identical
hole and is also untouched.

Four sites carried the claim, at three different counts

scripts/worktree/prune-merged.ps1:120   the header enumeration
scripts/worktree/prune-merged.ps1:256   an inline comment on $EXIT_REFUSED
docs/WORKTREES.md:325                   the doc copy
docs/WORKTREES.md:227                   asserts it without using the phrase

The fourth was found by searching every mention of "exit" rather than the phrase. The header wraps the
phrase across two lines, so a line-oriented grep misses it -- the surviving occurrences were verified
with a multiline pattern against an origin/main control that returned 1 and 2.

Readings

python -m pytest tests/test_worktree_prune_merged.py
100 passed in 245.16s        LASTEXITCODE: 0

Baseline was 98; the two new tests are the delta. git rev-parse HEAD and git status --porcelain
were identical before and after both full runs, so neither measured a moving tree. PowerShell parser:
0 errors. Non-ASCII in the script: 0, against a control on docs/WORKTREES.md returning 185.

Live receipt, read-only, one -RepoRoot per repository: 67 venvs across 108 of 109 registered
worktrees, 0 reapable, 51.13 GB held, stopped by C6 clean 42 and C7 idle 25.

What is measured and what is not

C9 has decided nothing yet. It fails on all 67 trees and was the deciding conjunct on zero of
them -- every one was already stopped by a lower-numbered check. Its marginal contribution is
unmeasured, not disproven, and it should not be published as having saved anything.

Nine conjuncts are seven per-tree predicates plus one population gate plus one run-level gate. C3 is
recorded PASS on every row unconditionally, because reaching the row already means it passed.

docs/WORKTREES.md:213 now records what the venv pass does NOT reuse from the worktree rule:
Get-NestedWorktrees, which vetoes a tree containing another registered worktree. Dropping it is
sound -- removing a parent orphans its children, which cannot happen when you delete only .venv --
and nothing recorded the drop until now. Confirmed by call-site enumeration at 686, 819 and 1542.

wshallwshall added 3 commits September 17, 2026 10:51
Adds -ReapVenvs to prune-merged.ps1. It identifies reapable `.venv`
directories and PRINTS them. There is no venv deletion path in the
script at all -- not behind -Apply, not behind a confirmation. An
adversarial review returned NEEDS_A_GUARD on every reaper proposed, so
the dry run ships first.

A venv is rebuildable state and a worktree is not, so the two have
different blast radii. The cheaper loss is still fenced by the ENTIRE
worktree conjunction, reused rather than re-derived, plus two more:

  C1 venv-present   C4 unlocked     C7 idle
  C2 rebuildable    C5 unoccupied   C8 merged
  C3 fence-avail    C6 clean        C9 unowned

C2 is `constraints.lock`, which is what new.ps1 installs from, and
deliberately NOT `uv.lock` -- a neighbouring lockfile that no install
here reads. A mutant reading uv.lock is caught by its own test.

C9 is new and is the conjunct the other eight miss. The owning session
id is the six-hex token a Claude-managed slug carries; it looks for
<config-root>/projects/*<id>*/*.jsonl across every .claude* root. NO ID
PARSED AND NO TRANSCRIPT FOUND BOTH MEAN SKIP -- absence of a transcript
is the absence of evidence, not proof of death.

Every count carries its denominator, because a claim of "462 of 462
process module lists read, 0 unreadable" did not survive re-measurement
(464 processes, 222 readable, 242 unreadable). "Nobody was reapable" and
"the check could not run" print different text and different exit codes:
the second refuses the whole pass and exits 2.

Raises the -IdleHours default from 36 to 72. The largest idle reading
ever taken on a worktree somebody was demonstrably in was 34.4h against
a 36h window, so the old margin was 1.6h. 72 is that reading doubled and
outlives a weekend.

Measured read-only against both repositories at this tree:

  MessageFoundry: 68 venvs across 109 of 110 registered worktrees,
    0 reapable, 51.99 GB (55828257948 bytes) held.
    Stopped by C6 clean 45, C7 idle 23.
    C9 fails on 68 of 68: 51 trees (37.24 GB) parse no id, 17 trees
    (14.75 GB) have an owner who wrote inside the window.
    One tree, manager-3aee14-sec-f05-leakgate (0.87 GB), passes both
    C5 and C7 while C9 reports its owner wrote 48.85 h ago -- C9
    catching what neither liveness signal saw.
  korus: 13 of 14 worktrees examined, 0 carry a venv, nothing to fence.

Tests: 19 new, one planted candidate per conjunct that satisfies every
other one, each carrying a REAPABLE positive control in the same
invocation. All 19 fail against the unpatched script. Two mutants were
run as controls: C2 reading uv.lock fails 5 tests, C9 that can never
FAIL fails exactly the 3 C9 tests.
…pted

-ReapVenvs computes its verdict BEFORE the apply loop, and it set EXIT_REFUSED
from there. $removed does not exist yet at that point, so the guard the -Name
path has always carried could not be applied: `-ReapVenvs -Apply` could remove
worktrees and still exit 2, which this script's own header defines as "nothing
was attempted". An automated caller reads that as a safe no-op.

The Set-Exit moves to sit beside the -Name guard, after the apply loop, and
takes the same `$removed -gt 0` shape. Reachability was established over the
AST rather than by grep: between the old site and the apply loop there is no
top-level exit, return, throw or break, and no read of $exit.

Nothing destructive changes, and there is still no venv deletion path anywhere.

The operator line naming the refusal also moves out of the "if the code is 2"
branch, because that is exactly the branch the new guard takes the run out of.

Two tests, both red against the unfixed script for the right reason: the first
reported "removed 2 worktree(s) and reported exit 2". The refusal cause is C9's
absent transcript store rather than -IdleHours 0, so the removing half and the
refusing half of the run move independently.
Both copies of the exit-code list still read "2 REFUSED -- nothing was
attempted ... (bad cwd, unavailable fence, a -Name that matched nothing)".
-ReapVenvs added four refusal causes and neither copy moved: the fence down,
transcript roots unreadable, no config root carrying a projects/ directory,
and -IdleHours 0 emptying both idle windows.

A closed list is what went stale, so it is replaced rather than extended. 2
now reads as per-REQUEST: something you asked for was not attempted. Two cases
are named where a run removed worktrees and still refused something -- the
-Name and -ReapVenvs paths report 1 once a removal has happened, and a fence
that dies part way through the apply loop still sets 2 over removals that
already landed.

THREE SITES, NOT TWO. prune-merged.ps1 states the same universal twice: the
header and the inline comment on $EXIT_REFUSED itself, which a search for the
header phrasing walks straight past. docs/WORKTREES.md holds one copy of the
enumeration, plus a -ReapVenvs sentence that asserted the pass exits 2.

Also, two corrections the same reading turned up:

* the -ReapVenvs parameter comment said "It prints a receipt and returns". The
  block falls through, which is why -ReapVenvs -Apply prunes worktrees and why
  the refusal code has to be decided after the apply loop.
* docs/WORKTREES.md called C1-C8 "the worktree rule above, reused rather than
  re-derived". One conjunct is dropped: the worktree pass vetoes a tree that
  contains another registered worktree and the venv pass does not. Defensible
  -- deleting only .venv cannot orphan a child -- but nothing recorded it.
@wshallwshall
wshallwshall added this pull request to the merge queue Sep 17, 2026
@github-actions github-actions Bot added the ci-red A required check went red. Attribute it before retrying. label Sep 17, 2026
@github-actions

Copy link
Copy Markdown

Security failed while this pull request was in the merge queue, so the queue ejected it.

Its own head can still be green: the queue revalidates the merge, and the path gates that skip on a pull request run there. Read the run before retrying.

https://github.com/MEFORORG/MessageFoundry/actions/runs/35276993902

Merged via the queue into main with commit 6896b39 Sep 17, 2026
41 checks passed
@wshallwshall
wshallwshall deleted the guard-reapvenvs branch September 17, 2026 21:42
wshallwshall pushed a commit that referenced this pull request Sep 17, 2026
Hoisting those lines out of "if the run exited 2" fixed when they print and
broke what they claim. `Exit ${exit}:` gets the arithmetic right and the
meaning wrong: on the very run the fence line now exists for -- fence down
AND a directory broken on disk -- it printed

    Exit 3: the occupancy fence was unavailable, so nothing was eligible.
    Exit 3: a directory is broken on disk RIGHT NOW.

The first is false as a causal statement. The fence sets 2; the broken
directory is what makes it 3. An operator fixes the fence and the code stays
3. The previous commit's own test pinned that wording, so it was about to
become the published reading.

Every tail line now opens with the outcome word, which is already this file's
vocabulary -- the three preamble refusals print bare `REFUSED:` and a failed
removal prints bare `FAILED:`. The run's code stays in the `Done.` summary
and in $LASTEXITCODE, which are the only surfaces that ever knew it.

Not `FAILED (1)`: that would have read as a sibling of the per-candidate
`FAILED (exit $removeExit)` twelve hundred lines up, which carries a git exit
code and is a different thing.

The -ReapVenvs line takes the same treatment rather than staying the odd one
out. It landed with `Exit ${exit}:` in #1244 and has the identical hole.

A GREEN RUN MUST STAY SILENT, AND NOTHING PINNED IT. Before the hoist that
was structural. Now it holds only because the sites setting those conditions
also raise the code, and every assertion in the file was a presence check.
test_a_clean_run_prints_no_exit_code_line_at_all checks absence, with the
positive control in the same invocation -- the run prunes two worktrees and
exits 0. Mutated by forcing the -Name line to print unconditionally: the test
fails with "a green run explained a code it did not exit with: FAILED:" while
the control assertion still holds, so the failure is attributable.

THE FENCE TEST READ TWO PROCESSES AND ONE CODE. It established exit 3 from
the -Json run, then string-matched a separate text run whose own code it
never read -- and the script branches on $Json in its preamble refusals, so
equal codes were an assumption. It now reads the text run's returncode.

THE SHA CITATION WOULD NOT RESOLVE. The comment cited edf01a9, which is
reachable from neither origin/main nor HEAD -- guard-reapvenvs squash-merged,
so that content is on main as 6896b39. A fresh clone gets `fatal: bad
object`. Same family as this repo's rule against citing a #N you have not
allocated. Replaced with #1244 (6896b39), which is an ancestor of both. The
control for that reading is tests/test_worktree_prune_merged.py's own
a824cc7 citation, which IS an ancestor of main.

Three sites still defined 2 as "nothing was ATTEMPTED", which the base
retracted and this branch did not restore -- a -Name miss on a dry run
attempts the whole decision pass and still exits 2. What 2 promises is that
nothing was REMOVED. Corrected at prune-merged.ps1's venv-placement comment
and at two sites in the venv test. None was introduced here; one sits twelve
lines from code this branch touches.

104 tests pass in the file.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ci-red A required check went red. Attribute it before retrying.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant