Skip to content

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

Open
wshallwshall wants to merge 4 commits into
mainfrom
claude/handoff-zizmor-1280-8fb1c4
Open

fix(coord): a pruned worktree stranded its work claims forever (BACKLOG #345)#141
wshallwshall wants to merge 4 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

…OG #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.
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