Skip to content

fix(coord): a pruned worktree stranded its work claims forever (BACKLOG #345) - #141

Merged
wshallwshall merged 9 commits into
mainfrom
claude/handoff-zizmor-1280-8fb1c4
Aug 2, 2026
Merged

fix(coord): a pruned worktree stranded its work claims forever (BACKLOG #345)#141
wshallwshall merged 9 commits into
mainfrom
claude/handoff-zizmor-1280-8fb1c4

Conversation

@wshallwshall

Copy link
Copy Markdown
Collaborator

What

A work claim (scripts/coord/claim.ps1) is a JSON file under <git-common-dir>/mefor-coord/claims/. It lives beside the shared object store, so it outlives the worktree that took it. prune-merged.ps1 removes worktrees and — across 1,108 lines — never touched claims. Since claim.ps1 -Take hard-blocks on any claim file that exists, every prune could leave a key unclaimable by every future session until a human happened to run -Release <key> -Force.

Nothing surfaced the condition and nothing could: the registry has no way to observe that a holder ceased to exist. That is the "a control that cannot see its own failure" class named alongside #344.

Scope — this is Half A only

Half Status
A. prune-merged.ps1 releases claims of a worktree it has proven gone this PR
B. -Take/-Release surface holder liveness not built here — see below

-List liveness landed separately in #106. Half B (the blocking paths, which is the half an operator actually hits) is deliberately left out: claim.ps1 is currently contended by #106 and by a live session's note-refresh work, and a three-way edit of one 170-line script is how merge messes start. It is recorded in the #345 item body.

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. This releases on evidence, not elapsed time: it hooks only into the branch that has already proven the directory is gone and deregistered ("OUTCOME, NOT EXIT CODE"), so no session can still be in there. A merely-quiet holder is never touched. Dry-run safety is inherited rather than 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, each with a positive control in the same invocation so neither can pass on a run that released nothing:

  • a living sibling's claim survives a run that removes two other worktrees;
  • the primary checkout's claim survives a sibling prune, even though <primary> is a strict prefix of <primary>-clean.

Two defects the tests caught, not review

  • 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.
  • 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.

Verification

The 8 new tests were run against the unpatched script first: 6 of 7 then-written tests failed, including both negative tests via their positive controls. Full file green afterwards — 71 passed.

🤖 Generated with Claude Code

#345)

A claim (scripts/coord/claim.ps1) is a JSON file under <git-common-dir>/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 <key> -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 `<primary>` is a strict prefix of `<primary>-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.
@wshallwshall
wshallwshall enabled auto-merge (squash) August 2, 2026 04:38
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.
…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.
…-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.
#136 landed four BACKLOG items (#339, #341, #342, #343) at the tail of the same file #345 appends
to, so git saw two additions at one anchor and called the whole region a conflict. There is no
semantic conflict: different items, no shared number, no shared prose.

Resolved by WORKTREES.md's recipe for this shape -- take main's BACKLOG.md wholesale, then re-insert
only this branch's added item. Taking main whole is what stops a hand-merge from silently reverting
somebody else's landed work, which is the real hazard here; a lost #345 would be obvious, a lost
#341 would not.

Verified rather than eyeballed: 268 item headings on main -> 269 after, NONE lost, exactly {345}
added, no duplicated numbers, no conflict markers, and the status invariant still scans clean.
Ordering follows main's own convention -- items are appended in landing order, not sorted, and 345
is not present anywhere on main.
@wshallwshall
wshallwshall merged commit 6de2b1e into main Aug 2, 2026
32 checks passed
@wshallwshall
wshallwshall deleted the claude/handoff-zizmor-1280-8fb1c4 branch August 2, 2026 15:34
wshallwshall added a commit that referenced this pull request Aug 2, 2026
PR #141 landed BACKLOG #345 at the file's EOF, the same append point as #347.
Resolved by keeping BOTH items, main's #345 first (it is already on main) then
#347, which is what the sandbox-codec session proposed when we compared append
points: two independent OPEN items at EOF carry no ordering meaning.

Merged rather than rebased deliberately. Each of the five #347 commits rewrites
the same block, so a rebase re-raises the same conflict five times and risks a
mid-stack resolution silently keeping an earlier draft; a merge resolves the seam
once against the FINAL text. The branch squashes on land, so the merge commit does
not reach main.

Verified rather than assumed: backlog_status_check.py reports 270 items each with
exactly one banner -- the count that session predicted for both items landing --
and #347's post-merge text still carries all three of its late revisions (the
infinitely-fast-machine discriminator, the ADR 0158 rule citation, the float64
underflow callout), so the resolution kept the final block and not an early draft.
wshallwshall added a commit that referenced this pull request Aug 2, 2026
#141 (BACKLOG #345) landed while #148 was in CI. Both PRs append a new item at EOF
after #343, so this conflicted on exactly one hunk - the predicted collision, not a
surprise. gh pr update-branch CANNOT resolve it; it fails and leaves the PR DIRTY.

Resolution: TAKE BOTH. #346 and #345 are independent OPEN items appended at the same
point, so their relative order carries no meaning (this file has not been numerically
ordered since #340 - the tail reads 340, 344, 339, 341, 342, 343). Restored the `---`
separator the conflict markers had displaced.

Verified by measurement rather than by eyeballing whether the markers were gone:
  - conflict markers remaining: 0
  - backlog_status_check.py: 270 items, each with exactly one status banner
    (269 before + #345 - the count I predicted BEFORE resolving, which is what makes
    it a check rather than a description)
  - `git diff origin/main -- docs/BACKLOG.md` = exactly 3 hunks, all mine:
    #197's banner, #339's OPEN item 2, and #346 appended
  - #345's block diffed byte-identical against origin/main - nothing of #141's lost
  - CRLF preserved, 0 bare LF

Coordination: the resolving edit was blocked by the collision gate while the #347
session held this file dirty mid-rebase. Waited for it to commit rather than
overriding the gate or rewriting the file with a shell command - routing around a
guard through a tool it does not hook is overriding it with extra steps.
wshallwshall added a commit that referenced this pull request Aug 4, 2026
…aid 93

Caught in review. I moved three of the four lines, not four: "Every one of the **92
open items** is re-scored here" (:144) still read 92 while Tiers and "sum to N" read 93.
Line 1 disagreed with lines 2-4 on the branch as pushed, which is precisely what the
"all four lines sum to N" invariant exists to catch -- and my recompute script only
rewrote the three lines it generated, never the prose sentence.

Verified this tree with the repo's OWN parser rather than my regex:
parse_items -> 93 items, 93 open, 0 closed. My whole-range banner scan agreed exactly.

That agreement is luck, not method, and worth recording as such. parse_items ends an
item's banner block at the first line that is neither blank nor a blockquote, so a `✅`
sitting AFTER an item's prose is batch-filing narrative, not that item's status. My scan
read the whole heading range and would have swallowed one. No item in this file
currently has that shape, so the two methods agree here and would diverge on a file that
does -- a reviewer's hand-rolled checker got three different wrong answers today,
including calling #3, #105 and #141 closed. Use parse_items.

NOT PREDICTING THE POST-#177 NUMBER. Once #177 lands (it closes #335), this tree becomes
93 items / 92 open / 93 rows, so the census wants 92. I am deliberately NOT writing 92
now: predicting it is carrying a delta in my head, which is the thing
re-derive-never-delta forbids. All four lines are set to 93, which is derived and correct
for THIS tree, and the number gets re-derived from the merged tree afterwards.

Worth recording because it is the rule catching its own author: the two defects PARTIALLY
CANCEL. Post-merge the line I missed (92) would have become accidentally correct while
the 93s I did update became wrong -- and a total-only assertion sails straight through
that. It is the same cancellation failure the RULE 1 amendment on this very branch warns
about, arriving in the PR that carries the warning.
wshallwshall added a commit that referenced this pull request Aug 4, 2026
…ockers, amend §D RULE 1 (#178)

* docs: file BACKLOG #1003 (lab validation), amend §D RULE 1 by owner ruling

Three changes, batched into one PR because the queue is rate-limited by PR count, not
by diff size.

1. BACKLOG #1003 — validate the lab and discharge the four hardware-gated residuals.
   A multi-VM server is ~2 weeks out. Four open items are blocked on that one missing
   thing: #99 (the live domain-lab gMSA/SSO smoke — its ONLY remaining residual), #98
   (Kerberos EPA, same DC + AD CS gate), #320 (the decisive windows-2025 sweep, blocked
   on an unregistered runner) and #351 (execute the failover patch against a real SQL
   Server, and measure ADR 0159's _acquire cost).

   TRIGGER IS "LAB AVAILABLE FOR VALIDATION", NOT "LAB VALIDATED" -- owner's correction
   to my wording, and it was circular: proving the lab does what these items need is this
   item's own first deliverable, so gating on validation means the trigger can never fire.

   Not a roadmap umbrella. Its deliverables are runs with recorded outcomes, which is the
   distinction that scored #64 a 1/10 for shipping nothing runnable.

   #351's measurement is called out as the one that is easy to lose: its patch makes the
   test deterministic, which removes the only thing currently raising the latency
   question. The item itself says a lease-election test is the wrong instrument for
   discovering latency -- so the measurement belongs on the rig, and landing the patch
   without it drops the question rather than answering it.

2. #99, #98, #320 and #351 each get a dated note that the hardware blocker HAS AN EXPIRY.
   #99 currently says its residual is "rig/provisioning the project does not own"; #320
   says its runner is unregistered. Both are true today and scheduled to become false.
   Left alone those sentences keep telling every planning pass the work is unreachable --
   the same stale-premise rot the 2026-07-28 reconcile found on five items and the
   2026-08-03 re-score found on twenty-four.

3. §D RULE 1 amended BY OWNER RULING: the ranked table and census are not owner-only,
   they are recomputed by whoever writes the ledger last. Recorded as an owner decision
   rather than a correction, because that is exactly the standing an earlier branch of
   mine lacked -- it reversed the same clause on an inter-session agreement in no
   committed document, and was rightly refused. The argument did not change; the
   authority did.

   Recorded with the condition that makes it safe: it depends on the unit being
   open-bannered HEADINGS and on the re-derivation being one operation with the
   stale-banner sweep. Without both, sessions recomputing is worse than the stale rule.
   Owner-only was never safer on correctness -- it leaves the census stale whenever the
   owner is away, and an owner re-deriving from stale banners launders the same
   false-open.

CENSUS RE-DERIVED under the corrected rule, and the two-directional heading<->row
comparison run rather than a total-only check: 93 open headings, 93 rows, zero headings
without a row, zero rows without an open heading. All four lines sum to 93.

  Tiers: P1 5, P2 19, P3 17, DEMAND-GATE 52
  Quadrants: quick win 24, big bet 5, fill-in 55, money pit 9

The bijection check is the point, not the total: an over-count and an under-count cancel,
so a total that adds up is not evidence the table and the items agree.

Number allocated atomically via alloc.ps1 (#1003; #1002 was already taken). Never grepped.

* docs(backlog): the fourth census line said 92 while the other three said 93

Caught in review. I moved three of the four lines, not four: "Every one of the **92
open items** is re-scored here" (:144) still read 92 while Tiers and "sum to N" read 93.
Line 1 disagreed with lines 2-4 on the branch as pushed, which is precisely what the
"all four lines sum to N" invariant exists to catch -- and my recompute script only
rewrote the three lines it generated, never the prose sentence.

Verified this tree with the repo's OWN parser rather than my regex:
parse_items -> 93 items, 93 open, 0 closed. My whole-range banner scan agreed exactly.

That agreement is luck, not method, and worth recording as such. parse_items ends an
item's banner block at the first line that is neither blank nor a blockquote, so a `✅`
sitting AFTER an item's prose is batch-filing narrative, not that item's status. My scan
read the whole heading range and would have swallowed one. No item in this file
currently has that shape, so the two methods agree here and would diverge on a file that
does -- a reviewer's hand-rolled checker got three different wrong answers today,
including calling #3, #105 and #141 closed. Use parse_items.

NOT PREDICTING THE POST-#177 NUMBER. Once #177 lands (it closes #335), this tree becomes
93 items / 92 open / 93 rows, so the census wants 92. I am deliberately NOT writing 92
now: predicting it is carrying a delta in my head, which is the thing
re-derive-never-delta forbids. All four lines are set to 93, which is derived and correct
for THIS tree, and the number gets re-derived from the merged tree afterwards.

Worth recording because it is the rule catching its own author: the two defects PARTIALLY
CANCEL. Post-merge the line I missed (92) would have become accidentally correct while
the 93s I did update became wrong -- and a total-only assertion sails straight through
that. It is the same cancellation failure the RULE 1 amendment on this very branch warns
about, arriving in the PR that carries the warning.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant