From c542dcc84b64078fe75b9b50aa4a9fc0fcf6a98d Mon Sep 17 00:00:00 2001 From: wshallwshall Date: Sat, 1 Aug 2026 23:34:53 -0500 Subject: [PATCH 1/4] fix(coord): a pruned worktree stranded its work claims forever (BACKLOG #345) A claim (scripts/coord/claim.ps1) is a JSON file under /mefor-coord/claims/. It lives beside the SHARED object store, so it outlives the worktree that took it. prune-merged.ps1 removed worktrees and, across 1,108 lines, never touched claims -- and `claim.ps1 -Take` hard-blocks on any claim file that exists. So every prune could leave a key unclaimable by every future session until a human happened to run `-Release -Force`. Nothing surfaced the condition and nothing could: the registry has no way to observe that a holder ceased to exist. Released on EVIDENCE, never on a timer. claim.ps1 is right that an auto-expiring claim silently re-opens the race it exists to prevent; this hooks only into the branch that has already PROVEN the directory is gone and deregistered ("OUTCOME, NOT EXIT CODE"), so there is no session left to collide with. A merely-quiet holder is never touched. Dry-run safety is inherited, not re-asserted: the whole apply loop is gated on -Apply. The false positive is worse than the bug. Releasing a claim held by a different, LIVING worktree hands its key away and invites the duplicate build the registry exists to stop, so the match is full normalised equality via ConvertTo-Norm -- no leaf name, no prefix, no StartsWith. Two tests pin it: a living sibling's claim survives, and the PRIMARY checkout's claim survives a sibling prune even though `` is a strict prefix of `-clean`. Both carry a positive control in the same invocation, so neither can pass on a run that released nothing. Two defects found by the tests rather than by review, both of the shape this file already guards against elsewhere: * an unreadable claim was counted once per REMOVED worktree, so one blocked key reported as 2. It belongs to the registry, not to any worktree -- not being able to read it is precisely not knowing whose it is -- so it is now surveyed once per run and reported at run level. * that survey ran only inside the removal branch, making it invisible to a dry run, and its Set-Exit sat AFTER the -Json block that emits the receipt and exits. The receipt would have carried exitCode 0 over a key nothing can claim -- the surface CI reads. `claims.scanned` distinguishes "read the registry, found it clean" from "never looked" (no git-common-dir, or no claims directory): an empty `unreadable` list is not a green light. Verified: the 8 new tests FAIL against the unpatched script before they pass against the patched one, so they can see the class. Full file green, 71 tests. --- docs/BACKLOG.md | 29 ++++ scripts/worktree/prune-merged.ps1 | 150 ++++++++++++++++++ tests/test_worktree_prune_merged.py | 238 ++++++++++++++++++++++++++++ 3 files changed, 417 insertions(+) diff --git a/docs/BACKLOG.md b/docs/BACKLOG.md index 5b39004f..75ac8c2f 100644 --- a/docs/BACKLOG.md +++ b/docs/BACKLOG.md @@ -8330,3 +8330,32 @@ Two worked instances the same day. **#74** went green on 2026-07-30 and sat unme **Source:** stuck-CI triage, 2026-08-01. Instance 1 measured across 11 windows-2025 runs; instance 2 reported and diagnosed by the HA-construct-recheck session from PR #129's sqlserver leg. --- + +## 345. prune-merged.ps1 orphans coordination claims; claim.ps1 cannot see a vanished holder + +> πŸ”’ **Filed 2026-08-02 β€” not started.** Value **6/10** Β· Difficulty **3/10** Β· _fill-in_. Deleting a worktree strands the work-claims it held, and the claim registry has no way to notice. The orphan then blocks that key for every future session, and the tool's own advice cannot distinguish it from a colleague who is mid-build. + +**Cluster:** Developer Experience & CI. **Priority:** P2. **Verdict:** build. **Severity:** medium. + +**What:** two halves of one hole. + +*Half A β€” the orphan is created and nothing cleans it up.* [`scripts/worktree/prune-merged.ps1`](../scripts/worktree/prune-merged.ps1) removes worktrees. Across its 1,108 lines there is **no** handling of coordination claims β€” verified by search, not assumed. A claim is a JSON file at `/mefor-coord/claims/.json` carrying the holder's `worktree` path; the file lives beside the *shared* object store, so it outlives the worktree that created it. Prune deletes the directory, the claim file remains, and [`claim.ps1`](../scripts/coord/claim.ps1)'s `-Take` hard-blocks on any existing claim file. The key is then unclaimable until a human happens to run `-Release -Force`. Nothing surfaces the condition; nothing times it out (correctly β€” see *Non-goal*). + +*Half B β€” the registry cannot see that a holder is gone.* On `main`, `claim.ps1`'s only staleness signal is **age β‰₯ 12h**, and it is advisory text emitted by `-List` alone. `-Take` β€” the path an operator actually hits β€” prints the same "held by another session" block whether the holder is deleted, dead, or actively committing. `-Release` goes further and *advises* `-Force` ("If that session is gone, re-run with `-Force`") without ever checking whether it is gone. PR #106 (branch `claim-liveness`) fixes this for `-List` by testing the holder path and printing `[HOLDER GONE …]`; it deliberately does not touch `-Take` or `-Release`, so the blocking path stays blind after it lands. + +**Why:** the claim registry exists so a collision becomes visible *before* the work. An orphaned claim inverts that: it is a permanent false positive that teaches sessions the gate is noise. This is the second defect class named in the 2026-08-01 stuck-CI triage β€” *a control that cannot observe its own failure*. Ask "if this were broken, what would tell me?" and the answer is the control itself, which is the defect. + +The failure is also **self-concealing in the dangerous direction.** Age-staleness and holder-death present identically, so the only remedy on offer is `-Force`, applied on a signal that cannot tell the two apart. PR #106's own filing records a claim reported `STALE ~21h` whose holder had committed **two minutes earlier** β€” releasing on that advice hands the key to a second session to rebuild what someone is mid-flight on, which is the exact duplicate-build the registry was built to stop. + +**Proposed:** +1. **Release on proven deletion.** When `prune-merged.ps1` *confirms* a worktree is gone, release the claims whose `worktree` matches it, and report each release in the receipt. Match on the holder path using byte-identical normalisation to `claim.ps1`'s writer (backslashβ†’forward, `TrimEnd('/')`, case-insensitive) β€” a near-miss silently releases nothing, and a too-loose match silently releases **someone else's live claim**, which is strictly worse than the orphan being fixed. Honour the script's dry-run: a preview must release nothing. +2. **Teach `-Take` and `-Release` the liveness `-List` already knows** (layered on #106, not duplicating it). `-Take` blocked by a vanished holder should say so and name the exact `-Force` command; `-Release` should not recommend `-Force` on a holder it never checked. +3. **Prove the guard can see the class before trusting it.** Each test fails on purpose first β€” a released-claim assertion that passes against unpatched code is measuring nothing. Cover at least: claim released on prune; claim held by a *different, living* worktree left alone; dry-run releases nothing; receipt counts honestly. + +**Non-goal:** auto-expiring claims. `claim.ps1`'s own docs give the reason β€” an auto-expiring claim silently re-opens the race it exists to prevent. Releasing on *proven* worktree deletion is a different act: it is evidence, not a timer. + +**Related:** [`scripts/worktree/prune-merged.ps1`](../scripts/worktree/prune-merged.ps1); [`scripts/coord/claim.ps1`](../scripts/coord/claim.ps1); [`docs/WORKTREES.md`](WORKTREES.md); PR #106 (`-List` liveness, the half already built); PR #74 (the prune hardening this sits beside β€” liveness *veto* before deletion, where this is cleanup *after*); #344 (the sibling defect class, *a bound stated independently of the thing it bounds*). + +**Source:** zizmor-1280 handoff, 2026-08-02, which reported claim `7` stranded by a prune and filed the mechanism as unbuilt. Half A and Half B were then re-verified against the code directly rather than inherited: the absent claim handling by search over `prune-merged.ps1`, the `-Take`/`-Release` blindness by reading both `main` and `claim-liveness`. + +--- diff --git a/scripts/worktree/prune-merged.ps1 b/scripts/worktree/prune-merged.ps1 index 69852fb0..4bce6030 100644 --- a/scripts/worktree/prune-merged.ps1 +++ b/scripts/worktree/prune-merged.ps1 @@ -623,6 +623,11 @@ function Get-Decision { # branches were kept on a run whose summary said 0. 'kept' is now only ever set by a keep. BranchOutcome = 'not attempted' BranchDetail = '' + # Declared on EVERY candidate, not just removed ones, so the field's absence never has to be + # interpreted: an empty array reads as "nothing was stranded here", which is true of a candidate + # that was skipped, and a consumer never has to distinguish missing from empty. + ClaimsReleased = @() + ClaimsUnreleased = @() } } @@ -729,6 +734,86 @@ function Read-OrphanLedger { catch { return @() } } +# --- Coordination claims stranded by a removal (BACKLOG #345) ------------------------------------- +# A work claim (scripts/coord/claim.ps1) is a JSON file under /mefor-coord/claims/. It +# lives beside the SHARED object store, so it OUTLIVES the worktree that took it. Removing a worktree +# therefore strands its claims, and `claim.ps1 -Take` hard-blocks on any claim file that exists β€” so the +# key becomes unclaimable by every future session until a human happens to run `-Release -Force`. +# Nothing surfaced that condition and nothing could: the registry has no way to observe that a holder +# ceased to exist, which is the same "a control that cannot see its own failure" shape as #344. +# +# WHY THIS IS NOT AN EXPIRING CLAIM. claim.ps1's docs are right that an auto-expiring claim silently +# re-opens the race it exists to prevent, and that reasoning is untouched here. This releases on +# EVIDENCE, not on elapsed time: it runs only from the branch that has already proven the directory is +# gone AND deregistered, so there is no session left in there to collide with. A claim whose holder is +# merely quiet is never touched by this. +$claimsDir = if ($gitCommonDir) { Join-Path $gitCommonDir 'mefor-coord/claims' } else { '' } + +# An UNREADABLE claim belongs to the REGISTRY, not to any one worktree β€” by definition we could not read +# whose it is. So it is surveyed ONCE, here, rather than discovered inside the removal loop. Two bugs +# came out of doing it the other way, and both are the same mistake in different clothes: +# * counted once per removed worktree, so one blocked key reported as 2 β€” the over-counting the branch +# counters in this script were already fixed for once; +# * invisible to a dry run, because a dry run never reaches the removal branch. A preview that reports +# a tidy registry over an unclaimable key is the same silence this whole feature removes, and it is +# the surface an operator checks BEFORE deciding to act. +# Reading is not mutating, so this is safe on the dry-run path β€” the same rule the orphan ledger follows. +$claimsUnreadable = @{} +# NEVER LOOKED is not CLEAN. $gitCommonDir is allowed to be empty (`rev-parse` can fail), and the claims +# directory need not exist at all β€” in both cases the survey below does not run, and `unreadable: []` +# next to `released: 0` reads exactly like a registry that was checked and found tidy. Recorded so a +# consumer can tell the two apart, per occupancy.ps1's rule that an empty list is not a green light. +$claimsScanned = [bool]($claimsDir -and (Test-Path -LiteralPath $claimsDir)) +if ($claimsScanned) { + foreach ($f in @(Get-ChildItem -LiteralPath $claimsDir -Filter *.json -File -EA SilentlyContinue | Sort-Object Name)) { + try { Get-Content -LiteralPath $f.FullName -Raw -EA Stop | ConvertFrom-Json -EA Stop | Out-Null } + catch { + $claimsUnreadable[(ConvertTo-Norm $f.FullName)] = [pscustomobject]@{ + file = $f.Name; detail = $_.Exception.Message + } + } + } +} + +function Remove-ClaimsHeldBy { + param([string]$Path) + + if (-not $claimsDir -or -not (Test-Path -LiteralPath $claimsDir)) { return @() } + + # THE FALSE POSITIVE IS WORSE THAN THE BUG. Releasing a claim held by a DIFFERENT, living worktree + # hands its key to another session and invites the duplicate build the registry exists to stop β€” + # strictly worse than the orphan being fixed. So match on the full normalised path and nothing else: + # no leaf name, no prefix, no StartsWith. ConvertTo-Norm is the repo's own normaliser + # (forward slashes, no trailing slash, lowercased) and agrees with claim.ps1's writer, which records + # `worktree = $repo` from `git rev-parse --path-format=absolute` and compares it back with `-ieq` + # over the same slash/trim folding. + $target = ConvertTo-Norm $Path + if (-not $target) { return @() } + + $released = @() + foreach ($f in @(Get-ChildItem -LiteralPath $claimsDir -Filter *.json -File -EA SilentlyContinue | Sort-Object Name)) { + try { + $c = Get-Content -LiteralPath $f.FullName -Raw -EA Stop | ConvertFrom-Json -EA Stop + } + catch { + # UNREADABLE IS NOT ABSENT. A claim file we cannot parse might name this worktree, so we + # cannot say it does not β€” and we must not delete it on a guess either. Already surveyed and + # reported by the run-level pass above; skipping it here must never become a silent drop. + continue + } + if ((ConvertTo-Norm ([string]$c.worktree)) -ne $target) { continue } + try { + Remove-Item -LiteralPath $f.FullName -Force -EA Stop + $released += [pscustomobject]@{ key = [string]$c.key; outcome = 'released'; detail = [string]$c.note } + } + catch { + # Same rule as the removal itself: report the outcome, never assume the call worked. + $released += [pscustomobject]@{ key = [string]$c.key; outcome = 'failed'; detail = $_.Exception.Message } + } + } + return $released +} + # Still broken RIGHT NOW: on disk, and not registered. An entry that was repaired (re-added) or fully # deleted clears itself, so the ledger cannot nag about a state that no longer exists. function Get-LiveOrphans([object[]]$Ledger) { @@ -772,6 +857,7 @@ $priorOrphans = @(Get-LiveOrphans $ledger) # --- Apply --------------------------------------------------------------------------------------- $removed = 0; $failed = 0; $branchesDeleted = 0; $branchesKept = 0; $orphaned = 0 +$claimsReleased = 0; $claimsUnreleased = 0 $newOrphans = @() if (-not $occ.Available) { Set-Exit $EXIT_REFUSED } if ($priorOrphans.Count -gt 0) { Set-Exit $EXIT_ORPHANED } @@ -885,6 +971,30 @@ if ($Apply -and $prunable.Count -gt 0) { if ($removeExit -eq 0 -and -not $dirExists -and -not $stillRegistered) { $removed++ $d.Outcome = 'removed' + # Only here. This is the branch that has PROVEN the directory is gone and deregistered, so it + # is the only place where "no session can still be working in there" is a fact rather than an + # assumption β€” and, because the whole apply loop is gated on -Apply, a dry run cannot reach + # it. Claims are released before the branch is touched so a Remove-BranchSafely failure + # cannot leave the claim stranded behind a removal that did happen. + $cl = @(Remove-ClaimsHeldBy -Path $d.Path) + $d.ClaimsReleased = @($cl | Where-Object { $_.outcome -eq 'released' } | ForEach-Object { $_.key }) + # Only claims PROVEN to belong to this worktree and still not cleared. An unreadable file is + # not here β€” we never learned whose it was, so attributing it to this decision would be a + # guess dressed as a fact. It is a run-level condition instead. + $d.ClaimsUnreleased = @($cl | Where-Object { $_.outcome -eq 'failed' }) + $claimsReleased += $d.ClaimsReleased.Count + $claimsUnreleased += $d.ClaimsUnreleased.Count + foreach ($x in $cl) { + if ($x.outcome -eq 'released') { Write-Note " released claim '$($x.key)' (held by this worktree)" 'DarkCyan' } + else { + # A claim we could not clear is still an orphan β€” the exact condition this exists to + # remove β€” so it must be loud, and it must move the exit code. Silence here would + # report a tidy prune over a key that is now permanently blocked. + Set-Exit $EXIT_FAILED + Write-Note " CLAIM NOT RELEASED '$($x.key)': $($x.detail)" 'Red' + Write-Note " It is now orphaned. Clear it with: pwsh -NoProfile -File scripts\coord\claim.ps1 -Release '$($x.key)' -Force" 'Red' + } + } $b = Remove-BranchSafely -Decision $d $d.BranchOutcome = $b.Outcome $d.BranchDetail = $b.Detail @@ -987,6 +1097,11 @@ if (-not $Json -and $fenceVetoed -gt $fenceVetoedAtDecision) { # wrong-cwd case this script now refuses outright. if ($namedMisses.Count -gt 0) { Set-Exit $(if ($removed -gt 0) { $EXIT_FAILED } else { $EXIT_REFUSED }) } +# BEFORE the report, not after it. The -Json branch below emits the receipt and EXITS, so an exit-code +# decision made after it would be reached only on the human path -- the receipt would carry exitCode 0 +# over a key nothing can claim, and a CI consumer reading the JSON would see a clean run. +if ($claimsUnreadable.Count -gt 0) { Set-Exit $EXIT_FAILED } + # Persist the orphan ledger: what is still broken, plus anything this run broke. Written only under # -Apply -- a dry run reports the same state without touching anything. $ledgerOut = @() @@ -1050,6 +1165,21 @@ if ($Json) { skipped = $skipped branchesDeleted = $branchesDeleted branchesKept = $branchesKept + # Coordination claims cleared because their holder was removed (BACKLOG #345). + # `claimsUnreleased` is reported beside it rather than folded in: a claim we could not clear + # is still an orphan, and a single "claimsHandled" number would let a partial sweep read as a + # complete one β€” the same over-claiming the branch counters above were fixed for. + claimsReleased = $claimsReleased + claimsUnreleased = $claimsUnreleased + claimsUnreadable = $claimsUnreadable.Count + } + # Listed, not just counted: the operator cannot clear a key whose file we will not name. + # `scanned` disambiguates an empty list β€” false means the registry was never read, not that it + # was read and found clean. + claims = [pscustomobject]@{ + scanned = $claimsScanned + dir = $claimsDir + unreadable = @($claimsUnreadable.Values) } exitCode = $exit } | ConvertTo-Json -Depth 6 | Write-Output @@ -1072,6 +1202,26 @@ else { $orphanText = if ($orphaned) { " ($orphaned ORPHANED, counted inside failed)" } else { "" } Write-Host "Done. removed $removed, failed $failed$orphanText, skipped $skipped of $($decisions.Count) candidate(s)." -ForegroundColor $(if ($exit -eq $EXIT_OK) { 'Green' } else { 'Red' }) Write-Host " branches: $branchesDeleted deleted, $branchesKept kept." -ForegroundColor DarkGray + # Printed only when there is something to say. A standing "claims: 0 released" on every run trains + # the eye to skip the line, which is precisely where the non-zero case needs to be noticed. + if ($claimsReleased -gt 0 -or $claimsUnreleased -gt 0) { + $claimColour = if ($claimsUnreleased -gt 0) { 'Red' } else { 'DarkGray' } + Write-Host " claims: $claimsReleased released$(if ($claimsUnreleased -gt 0) { ", $claimsUnreleased STILL ORPHANED" })." -ForegroundColor $claimColour + } +} + +# Run-level, and OUTSIDE the -Apply summary: an unreadable claim is a property of the registry, not of +# any removal, so a dry run that finds one must say so too rather than printing a tidy preview over a +# key nothing can take. +if ($claimsUnreadable.Count -gt 0) { + Write-Host "" + Write-Host "$($claimsUnreadable.Count) coordination claim(s) could not be READ, so it is unknown whose they are:" -ForegroundColor Red + foreach ($u in $claimsUnreadable.Values) { + Write-Host " $($u.file) -- $($u.detail)" -ForegroundColor Red + } + Write-Host " They were left in place: deleting a claim we cannot attribute could free a key someone" -ForegroundColor Red + Write-Host " is mid-build on. Read them by hand, then clear with claim.ps1 -Release -Force." -ForegroundColor Red + Write-Host " Until then those keys are unclaimable and claim.ps1 -Take will block on them." -ForegroundColor Red } # Orphans from an EARLIER run. Git no longer lists them, so nothing else in this report would. diff --git a/tests/test_worktree_prune_merged.py b/tests/test_worktree_prune_merged.py index 85682c2c..23063537 100644 --- a/tests/test_worktree_prune_merged.py +++ b/tests/test_worktree_prune_merged.py @@ -36,6 +36,7 @@ import json import os +import re import shutil import subprocess import sys @@ -1504,3 +1505,240 @@ def _find_free_pid() -> int: proc.wait(timeout=30) time.sleep(0.3) return proc.pid + + +# -------------------------------------------------------------------------------------------------- +# Coordination claims stranded by a removal (BACKLOG #345) +# +# A claim lives beside the SHARED object store, so it outlives the worktree that took it. Removing the +# worktree used to leave the claim file behind, and `claim.ps1 -Take` hard-blocks on any claim file that +# exists -- so the key became unclaimable by every future session until a human ran `-Release -Force`. +# +# The dangerous direction here is the FALSE POSITIVE, not the miss: releasing a claim held by a +# different, LIVING worktree hands its key away and invites the duplicate build the registry exists to +# stop. So the negative test below is the load-bearing one, and it carries a positive control in the +# same invocation to prove it did not pass by the run doing nothing at all. +# -------------------------------------------------------------------------------------------------- + + +def _claims_dir(fx: Fixture) -> Path: + """Where claim.ps1 puts claims: /mefor-coord/claims.""" + common = Path( + subprocess.run( + [ + "git", + "-C", + str(fx.primary), + "rev-parse", + "--path-format=absolute", + "--git-common-dir", + ], + check=True, + capture_output=True, + text=True, + ).stdout.strip() + ) + d = common / "mefor-coord" / "claims" + d.mkdir(parents=True, exist_ok=True) + return d + + +def write_claim(fx: Fixture, key: str, worktree: Path | str, *, note: str = "work") -> Path: + """Write a claim exactly as claim.ps1 does: folded filename, UTF-8 with NO BOM. + + The BOM matters -- claim.ps1 comments that the python-side gate reads these with + ``encoding="utf-8"`` and a BOM makes ``json.loads`` raise, which would be swallowed into "not + claimed" and silently disable the gate. A fixture that wrote one would be testing a file shape the + real tool never produces. + """ + safe = re.sub(r"[^a-z0-9._-]+", "-", key.strip().lower()).strip("-") + f = _claims_dir(fx) / f"{safe}.json" + f.write_bytes( + json.dumps( + { + "key": key, + "note": note, + "branch": "some-branch", + "worktree": str(worktree), + "claimed": "2026-08-02T00:00:00.0000000+00:00", + } + ).encode("utf-8") + ) + return f + + +def test_a_claim_held_by_a_pruned_worktree_is_released(fx: Fixture, sleeper: int) -> None: + """The orphan this exists to remove. + + Written with BACKSLASHES, which is what `str(Path)` gives on Windows, while claim.ps1 records the + forward-slash form from `git rev-parse --path-format=absolute`. Both must match the same worktree + or the release silently does nothing -- a miss that would look exactly like success. + """ + live_record(fx, sleeper, fx.primary) + claim = write_claim(fx, "clean-work", fx.sibling("clean"), note="the pruned session's work") + + res = run(fx, "-Apply", "-IdleHours", "0") + + d = by_leaf(res, "clean") + assert d["Outcome"] == "removed" + assert d["ClaimsReleased"] == ["clean-work"] + assert d["ClaimsUnreleased"] == [] + assert not claim.exists(), "the claim file outlived its worktree and now blocks the key forever" + assert res["counts"]["claimsReleased"] == 1 + assert res["counts"]["claimsUnreleased"] == 0 + assert res["_exit"] == 0 + + +def test_a_claim_held_by_a_LIVING_worktree_is_never_released(fx: Fixture, sleeper: int) -> None: + """The false positive, which is worse than the bug being fixed. + + `dirty` is never pruned (it has modified files), so its claim must survive a run that removes two + OTHER worktrees. Asserting only "the file still exists" would pass against a script that released + nothing at all, so this carries `clean`'s release as the positive control in the same invocation. + """ + live_record(fx, sleeper, fx.primary) + survivor = write_claim(fx, "dirty-work", fx.sibling("dirty"), note="someone is mid-build") + control = write_claim(fx, "clean-work", fx.sibling("clean")) + + res = run(fx, "-Apply", "-IdleHours", "0") + + assert by_leaf(res, "dirty")["Decision"] == "SKIP" + assert survivor.exists(), "a live session's claim was handed to whoever asks for it next" + assert json.loads(survivor.read_text(encoding="utf-8"))["key"] == "dirty-work" + # Positive control: the run DID release claims, so the survival above is a decision, not a no-op. + assert not control.exists() + assert res["counts"]["claimsReleased"] == 1 + + +def test_a_claim_naming_a_PREFIX_of_the_pruned_path_is_not_released( + fx: Fixture, sleeper: int +) -> None: + """Anti-substring control. `` is a strict prefix of `-clean`. + + A `StartsWith` or leaf-name match would release the PRIMARY checkout's claim while pruning a + sibling -- and the primary is where the operator is sitting. Full normalised equality is the only + match that cannot do this. + """ + live_record(fx, sleeper, fx.primary) + primary_claim = write_claim(fx, "primary-work", fx.primary, note="held by the primary checkout") + control = write_claim(fx, "clean-work", fx.sibling("clean")) + + res = run(fx, "-Apply", "-IdleHours", "0") + + assert primary_claim.exists(), "pruning a sibling released the PRIMARY checkout's claim" + assert not control.exists() + assert res["counts"]["claimsReleased"] == 1 + + +def test_a_dry_run_releases_no_claim(fx: Fixture, sleeper: int) -> None: + """A preview that mutates the shared registry is not a preview. + + The anti-vacuity half matters as much as the assertion: `clean` must come back PRUNE, or this + passes for free on a run that had no candidates. + """ + live_record(fx, sleeper, fx.primary) + claim = write_claim(fx, "clean-work", fx.sibling("clean")) + + res = run(fx, "-IdleHours", "0") # no -Apply + + assert by_leaf(res, "clean")["Decision"] == "PRUNE", "nothing would have been removed anyway" + assert claim.exists() + assert res["counts"]["claimsReleased"] == 0 + assert fx.sibling("clean").exists() + + +def test_an_unreadable_claim_is_reported_not_silently_swept(fx: Fixture, sleeper: int) -> None: + """A claim we cannot parse might name this worktree, so we can neither clear it nor ignore it. + + Reporting it is the whole point: a `continue` here would let the receipt describe a clean sweep it + never made, leaving a permanently-blocked key that nothing mentions again. It must also move the + exit code -- the condition is exactly the orphan this feature removes. + + It is counted ONCE PER RUN, not once per removal. This run removes two worktrees (`clean` and + `gone`) and each consults the same claims directory; the first version of this attributed the + corrupt file to every removal and reported 2 for one blocked key. An unreadable claim belongs to no + worktree by definition -- not being able to read it is exactly not knowing whose it is. + """ + live_record(fx, sleeper, fx.primary) + bad = _claims_dir(fx) / "corrupt.json" + bad.write_bytes(b"{not json at all") + + res = run(fx, "-Apply", "-IdleHours", "0") + + assert res["counts"]["removed"] == 2, "two removals must both have consulted the registry" + assert by_leaf(res, "clean")["Outcome"] == "removed", "the run must still have acted" + assert res["counts"]["claimsUnreadable"] == 1 + assert [u["file"] for u in res["claims"]["unreadable"]] == ["corrupt.json"] + assert res["claims"]["scanned"] is True + # Not attributed to a decision: we never learned whose it was. + assert by_leaf(res, "clean")["ClaimsUnreleased"] == [] + assert by_leaf(res, "gone")["ClaimsUnreleased"] == [] + assert bad.exists(), "an unparseable claim must not be deleted on a guess" + assert res["_exit"] != 0, "a key left permanently blocked is not a successful prune" + + +def test_an_unreadable_claim_reds_a_DRY_RUN_too(fx: Fixture, sleeper: int) -> None: + """The receipt is the surface CI reads, and it exits before the human summary is printed. + + An exit-code decision made after the -Json branch would be reached only on the text path, so the + receipt would carry exitCode 0 over a key nothing can claim. A dry run finds the same condition + because the condition belongs to the registry, not to any removal. + """ + live_record(fx, sleeper, fx.primary) + (_claims_dir(fx) / "corrupt.json").write_bytes(b"{not json at all") + + res = run(fx, "-IdleHours", "0") # no -Apply + + assert by_leaf(res, "clean")["Decision"] == "PRUNE", "anti-vacuity: the run had real candidates" + assert res["counts"]["removed"] == 0 + assert res["counts"]["claimsUnreadable"] == 1 + assert res["_exit"] != 0, "the JSON receipt reported a clean run over an unclaimable key" + + +def test_the_human_summary_reports_released_claims(fx: Fixture, sleeper: int) -> None: + """The text surface is separate from the receipt and can lie on its own.""" + live_record(fx, sleeper, fx.primary) + write_claim(fx, "clean-work", fx.sibling("clean")) + + proc = run_text(fx, "-Apply", "-IdleHours", "0") + + assert "released claim 'clean-work'" in proc.stdout + assert "claims: 1 released" in proc.stdout + + +def test_the_summary_stays_silent_when_no_claim_was_involved(fx: Fixture, sleeper: int) -> None: + """A standing `claims: 0 released` on every run trains the eye to skip the line.""" + live_record(fx, sleeper, fx.primary) + proc = run_text(fx, "-Apply", "-IdleHours", "0") + assert "claims:" not in proc.stdout + + +def test_an_absent_registry_reports_NOT_SCANNED_rather_than_clean( + fx: Fixture, sleeper: int +) -> None: + """`unreadable: []` next to `released: 0` reads exactly like a registry checked and found tidy. + + A repo that has never used claim.ps1 has no claims directory at all, so the survey does not run. + Without `scanned` there is no field distinguishing "read it, nothing wrong" from "never looked" -- + the silent-instrument shape this whole item is about, reintroduced in the receipt. + """ + live_record(fx, sleeper, fx.primary) + # Deliberately do NOT call _claims_dir(): it creates the directory as a side effect. + res = run(fx, "-Apply", "-IdleHours", "0") + + assert res["counts"]["removed"] == 2, "anti-vacuity: the run really did prune" + assert res["claims"]["scanned"] is False + assert res["claims"]["unreadable"] == [] + assert res["counts"]["claimsReleased"] == 0 + assert res["_exit"] == 0, "an absent registry is not an error, just an unknown" + + +def test_an_empty_registry_reports_SCANNED(fx: Fixture, sleeper: int) -> None: + """The other half of the pair -- without it, `scanned` could be hardcoded false and still pass.""" + live_record(fx, sleeper, fx.primary) + _claims_dir(fx) # exists, but holds nothing + + res = run(fx, "-Apply", "-IdleHours", "0") + + assert res["claims"]["scanned"] is True + assert res["claims"]["unreadable"] == [] From add20be537159e5a8b6dfa2b7742e10a26727443 Mon Sep 17 00:00:00 2001 From: wshallwshall Date: Sat, 1 Aug 2026 23:41:01 -0500 Subject: [PATCH 2/4] docs(backlog): #345's banner said "not started" on the PR that builds it That is the exact defect the backlog-hygiene gate exists for -- #60 shipped while its banner still read "PRE-RESERVED", and the stale banner was then repeated as fact in a merged PR. The gate did not fire here because it only inspects engine/IDE paths and this item is script-and-test work, which is precisely why the banner had to be corrected by hand rather than trusted to CI. Records what actually shipped (Half A) against what did not (Half B), and why Half B was backed out: claim.ps1 had three sessions in it at once, so the deferral is a coordination decision, not an estimate of difficulty. Someone reading this item needs that distinction to know it is pickup-able. The first draft of this named the contending BRANCH and the leak gate blocked it -- a worktree slug is internal naming and this ledger is public. Described by the code path it touches instead. --- docs/BACKLOG.md | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/docs/BACKLOG.md b/docs/BACKLOG.md index 75ac8c2f..a4e18ef4 100644 --- a/docs/BACKLOG.md +++ b/docs/BACKLOG.md @@ -8333,7 +8333,7 @@ Two worked instances the same day. **#74** went green on 2026-07-30 and sat unme ## 345. prune-merged.ps1 orphans coordination claims; claim.ps1 cannot see a vanished holder -> πŸ”’ **Filed 2026-08-02 β€” not started.** Value **6/10** Β· Difficulty **3/10** Β· _fill-in_. Deleting a worktree strands the work-claims it held, and the claim registry has no way to notice. The orphan then blocks that key for every future session, and the tool's own advice cannot distinguish it from a colleague who is mid-build. +> πŸ”’ **Filed 2026-08-02 β€” Half A BUILT (PR #141), Half B open.** Value **6/10** Β· Difficulty **3/10** Β· _fill-in_. Deleting a worktree strands the work-claims it held, and the claim registry has no way to notice. The orphan then blocks that key for every future session, and the tool's own advice cannot distinguish it from a colleague who is mid-build. **The root cause is fixed** β€” `prune-merged.ps1` now releases the claims of a worktree it has *proven* gone. **Half B is deliberately not built there**; see *Status*. **Cluster:** Developer Experience & CI. **Priority:** P2. **Verdict:** build. **Severity:** medium. @@ -8354,6 +8354,12 @@ The failure is also **self-concealing in the dangerous direction.** Age-stalenes **Non-goal:** auto-expiring claims. `claim.ps1`'s own docs give the reason β€” an auto-expiring claim silently re-opens the race it exists to prevent. Releasing on *proven* worktree deletion is a different act: it is evidence, not a timer. +**Status (2026-08-02).** + +*Half A β€” done, PR #141.* `prune-merged.ps1` releases the claims of a worktree it has proven gone, matching on full normalised path equality, and reports them in both the receipt and the summary. Two defects surfaced during the build, each the shape this ledger keeps recording: an unreadable claim was counted once per *removed worktree* rather than once per run (one blocked key reporting as 2), and the survey that found it sat inside the removal branch with its `Set-Exit` *after* the `-Json` block that emits the receipt and exits β€” so a dry run could not see the condition and the receipt would have carried `exitCode: 0` over an unclaimable key. `claims.scanned` now separates "read the registry, found it clean" from "never looked". + +*Half B β€” open, and blocked on coordination rather than difficulty.* Teaching `-Take`/`-Release` the liveness `-List` now has is a ~20-line change, but `claim.ps1` had three sessions in it at once on 2026-08-02: #106 (the `-List` half, merged), a concurrent session's note-refresh work on the `-Take` self-refresh path, and this item. It was backed out rather than merged into that three-way, because a contended 170-line script is exactly where a semantic conflict lands green. **Whoever picks it up: check `claim.ps1`'s recent history and the live work claims before editing.** The behaviour still wanted β€” `-Take` blocked by a vanished holder should say so and name the `-Force` command; `-Release` should stop recommending `-Force` on a holder it never checked. + **Related:** [`scripts/worktree/prune-merged.ps1`](../scripts/worktree/prune-merged.ps1); [`scripts/coord/claim.ps1`](../scripts/coord/claim.ps1); [`docs/WORKTREES.md`](WORKTREES.md); PR #106 (`-List` liveness, the half already built); PR #74 (the prune hardening this sits beside β€” liveness *veto* before deletion, where this is cleanup *after*); #344 (the sibling defect class, *a bound stated independently of the thing it bounds*). **Source:** zizmor-1280 handoff, 2026-08-02, which reported claim `7` stranded by a prune and filed the mechanism as unbuilt. Half A and Half B were then re-verified against the code directly rather than inherited: the absent claim handling by search over `prune-merged.ps1`, the `-Take`/`-Release` blindness by reading both `main` and `claim-liveness`. From d8cf601f97f1bdeefdcdd70b0ef81df68037ebe4 Mon Sep 17 00:00:00 2001 From: wshallwshall Date: Sat, 1 Aug 2026 23:48:24 -0500 Subject: [PATCH 3/4] docs(worktrees): the prune receipt grew a claims surface and nothing described it WORKTREES.md is where the pruner's outcome contract is written down -- exit codes, which counts are subsets of which, why `git worktree prune` is never run. A new field in that receipt that only the source explains is the same drift this section already guards against elsewhere. States the property that makes the release safe rather than just the behaviour: it fires on PROVEN deletion, so a quiet holder is untouched and a dry run releases nothing, and the match is full path equality because freeing a LIVING worktree's key is worse than the orphan being cleaned up. --- docs/WORKTREES.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/docs/WORKTREES.md b/docs/WORKTREES.md index 1d85314f..dab7b9b8 100644 --- a/docs/WORKTREES.md +++ b/docs/WORKTREES.md @@ -161,6 +161,20 @@ directory is broken on disk right now. `3` outranks `2` because damage on disk o act. In the JSON receipt `counts.orphaned` is a *subset* of `counts.failed` (`failedNonOrphan` is spelled out alongside it); `removed + failed + skipped` covers every candidate exactly once. +**A removal releases the work claims the worktree held.** A claim ([`claim.ps1`](../scripts/coord/claim.ps1)) +lives under `/mefor-coord/claims/`, beside the *shared* object store, so it outlives the +worktree that took it β€” and `-Take` blocks on any claim file that exists. A prune therefore used to leave +the key unclaimable by every future session until someone ran `-Release -Force` by hand. Released +only from the branch that has already proven the directory gone *and* deregistered, so it is evidence +rather than a timer: a claim whose holder is merely **quiet** is never touched, and a dry run releases +nothing. The match is full normalised path equality β€” releasing a *living* worktree's claim would hand +its key away and cause the duplicate build the registry exists to prevent, which is worse than the orphan +being cleaned up. Reported as `counts.claimsReleased` and, when one could not be cleared, +`counts.claimsUnreleased` (the key stays blocked, and the run goes red). An unreadable claim file belongs +to no worktree β€” not being able to read it is precisely not knowing whose it is β€” so it is surveyed once +per run under `claims.unreadable` and left in place. `claims.scanned` is `false` when there is no claims +directory to read: an empty `unreadable` list is not a green light. (BACKLOG #345.) + **A branch is never force-deleted on a stale verdict.** `git branch -d` refuses a branch merged only into `origin/main` whenever the local `main` lags β€” which it usually does β€” so `-D` used to be the routine path and git's last protection was overridden every time. Now `-d` is tried first, and `-D` From dd1d9993c05ca5f6e68d4f3e2e3588bf32960da3 Mon Sep 17 00:00:00 2001 From: wshallwshall Date: Sat, 1 Aug 2026 23:51:43 -0500 Subject: [PATCH 4/4] style(worktree): my #345 comments put 13 non-ASCII em-dashes in a BOM-less script prune-merged.ps1 had exactly ZERO non-ASCII bytes before this branch and no BOM -- every comment in it uses the ASCII `--`. A BOM-less file whose encoding is only inferred is a bad place to start introducing multi-byte characters: pwsh 7 assumes UTF-8 and would be fine, Windows PowerShell 5.1 assumes the ANSI codepage and would not, and nothing in the repo pins which one an operator runs it with. The file is back to 0 non-ASCII bytes. Comments only; CRLF line endings verified byte-identical in count before and after, and the claim tests re-run green. --- scripts/worktree/prune-merged.ps1 | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/scripts/worktree/prune-merged.ps1 b/scripts/worktree/prune-merged.ps1 index 4bce6030..92855486 100644 --- a/scripts/worktree/prune-merged.ps1 +++ b/scripts/worktree/prune-merged.ps1 @@ -737,7 +737,7 @@ function Read-OrphanLedger { # --- Coordination claims stranded by a removal (BACKLOG #345) ------------------------------------- # A work claim (scripts/coord/claim.ps1) is a JSON file under /mefor-coord/claims/. It # lives beside the SHARED object store, so it OUTLIVES the worktree that took it. Removing a worktree -# therefore strands its claims, and `claim.ps1 -Take` hard-blocks on any claim file that exists β€” so the +# therefore strands its claims, and `claim.ps1 -Take` hard-blocks on any claim file that exists -- so the # key becomes unclaimable by every future session until a human happens to run `-Release -Force`. # Nothing surfaced that condition and nothing could: the registry has no way to observe that a holder # ceased to exist, which is the same "a control that cannot see its own failure" shape as #344. @@ -749,18 +749,18 @@ function Read-OrphanLedger { # merely quiet is never touched by this. $claimsDir = if ($gitCommonDir) { Join-Path $gitCommonDir 'mefor-coord/claims' } else { '' } -# An UNREADABLE claim belongs to the REGISTRY, not to any one worktree β€” by definition we could not read +# An UNREADABLE claim belongs to the REGISTRY, not to any one worktree -- by definition we could not read # whose it is. So it is surveyed ONCE, here, rather than discovered inside the removal loop. Two bugs # came out of doing it the other way, and both are the same mistake in different clothes: -# * counted once per removed worktree, so one blocked key reported as 2 β€” the over-counting the branch +# * counted once per removed worktree, so one blocked key reported as 2 -- the over-counting the branch # counters in this script were already fixed for once; # * invisible to a dry run, because a dry run never reaches the removal branch. A preview that reports # a tidy registry over an unclaimable key is the same silence this whole feature removes, and it is # the surface an operator checks BEFORE deciding to act. -# Reading is not mutating, so this is safe on the dry-run path β€” the same rule the orphan ledger follows. +# Reading is not mutating, so this is safe on the dry-run path -- the same rule the orphan ledger follows. $claimsUnreadable = @{} # NEVER LOOKED is not CLEAN. $gitCommonDir is allowed to be empty (`rev-parse` can fail), and the claims -# directory need not exist at all β€” in both cases the survey below does not run, and `unreadable: []` +# directory need not exist at all -- in both cases the survey below does not run, and `unreadable: []` # next to `released: 0` reads exactly like a registry that was checked and found tidy. Recorded so a # consumer can tell the two apart, per occupancy.ps1's rule that an empty list is not a green light. $claimsScanned = [bool]($claimsDir -and (Test-Path -LiteralPath $claimsDir)) @@ -781,7 +781,7 @@ function Remove-ClaimsHeldBy { if (-not $claimsDir -or -not (Test-Path -LiteralPath $claimsDir)) { return @() } # THE FALSE POSITIVE IS WORSE THAN THE BUG. Releasing a claim held by a DIFFERENT, living worktree - # hands its key to another session and invites the duplicate build the registry exists to stop β€” + # hands its key to another session and invites the duplicate build the registry exists to stop -- # strictly worse than the orphan being fixed. So match on the full normalised path and nothing else: # no leaf name, no prefix, no StartsWith. ConvertTo-Norm is the repo's own normaliser # (forward slashes, no trailing slash, lowercased) and agrees with claim.ps1's writer, which records @@ -797,7 +797,7 @@ function Remove-ClaimsHeldBy { } catch { # UNREADABLE IS NOT ABSENT. A claim file we cannot parse might name this worktree, so we - # cannot say it does not β€” and we must not delete it on a guess either. Already surveyed and + # cannot say it does not -- and we must not delete it on a guess either. Already surveyed and # reported by the run-level pass above; skipping it here must never become a silent drop. continue } @@ -973,13 +973,13 @@ if ($Apply -and $prunable.Count -gt 0) { $d.Outcome = 'removed' # Only here. This is the branch that has PROVEN the directory is gone and deregistered, so it # is the only place where "no session can still be working in there" is a fact rather than an - # assumption β€” and, because the whole apply loop is gated on -Apply, a dry run cannot reach + # assumption -- and, because the whole apply loop is gated on -Apply, a dry run cannot reach # it. Claims are released before the branch is touched so a Remove-BranchSafely failure # cannot leave the claim stranded behind a removal that did happen. $cl = @(Remove-ClaimsHeldBy -Path $d.Path) $d.ClaimsReleased = @($cl | Where-Object { $_.outcome -eq 'released' } | ForEach-Object { $_.key }) # Only claims PROVEN to belong to this worktree and still not cleared. An unreadable file is - # not here β€” we never learned whose it was, so attributing it to this decision would be a + # not here -- we never learned whose it was, so attributing it to this decision would be a # guess dressed as a fact. It is a run-level condition instead. $d.ClaimsUnreleased = @($cl | Where-Object { $_.outcome -eq 'failed' }) $claimsReleased += $d.ClaimsReleased.Count @@ -987,8 +987,8 @@ if ($Apply -and $prunable.Count -gt 0) { foreach ($x in $cl) { if ($x.outcome -eq 'released') { Write-Note " released claim '$($x.key)' (held by this worktree)" 'DarkCyan' } else { - # A claim we could not clear is still an orphan β€” the exact condition this exists to - # remove β€” so it must be loud, and it must move the exit code. Silence here would + # A claim we could not clear is still an orphan -- the exact condition this exists to + # remove -- so it must be loud, and it must move the exit code. Silence here would # report a tidy prune over a key that is now permanently blocked. Set-Exit $EXIT_FAILED Write-Note " CLAIM NOT RELEASED '$($x.key)': $($x.detail)" 'Red' @@ -1168,13 +1168,13 @@ if ($Json) { # Coordination claims cleared because their holder was removed (BACKLOG #345). # `claimsUnreleased` is reported beside it rather than folded in: a claim we could not clear # is still an orphan, and a single "claimsHandled" number would let a partial sweep read as a - # complete one β€” the same over-claiming the branch counters above were fixed for. + # complete one -- the same over-claiming the branch counters above were fixed for. claimsReleased = $claimsReleased claimsUnreleased = $claimsUnreleased claimsUnreadable = $claimsUnreadable.Count } # Listed, not just counted: the operator cannot clear a key whose file we will not name. - # `scanned` disambiguates an empty list β€” false means the registry was never read, not that it + # `scanned` disambiguates an empty list -- false means the registry was never read, not that it # was read and found clean. claims = [pscustomobject]@{ scanned = $claimsScanned