Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 9 additions & 5 deletions docs/BACKLOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8454,15 +8454,15 @@ 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 — 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*.
> **SHIPPED 2026-08-02 — both halves (PR #141 + PR #151).** Deleting a worktree stranded the work-claims it held, and the claim registry had no way to notice: the orphan then blocked that key for every future session, while the tool's own advice could not distinguish it from a colleague who was mid-build. **Half A** — `prune-merged.ps1` releases the claims of a worktree it has *proven* gone, matching on full normalised path equality and reporting them in the receipt. **Half B** — `-Take` and `-Release` now probe holder liveness through the same helper `-List` uses, so the blocking paths stop recommending `-Force` on a holder nobody looked at.

**Cluster:** Developer Experience & CI. **Priority:** P2. **Verdict:** build. **Severity:** medium.

**What:** two halves of one hole.
**What** (the defect as found, 2026-08-02 — past tense throughout; both halves have since shipped, see *Status*): 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 `<git-common-dir>/mefor-coord/claims/<key>.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 <key> -Force`. Nothing surfaces the condition; nothing times it out (correctly — see *Non-goal*).
*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 **was** no handling of coordination claims — verified by search, not assumed. A claim is a JSON file at `<git-common-dir>/mefor-coord/claims/<key>.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 <key> -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.
*Half B — the registry could not see that a holder was gone.* `claim.ps1`'s only staleness signal was **age ≥ 12h**, advisory text emitted by `-List` alone. `-Take` — the path an operator actually hits — printed the same "held by another session" block whether the holder was deleted, dead, or actively committing. `-Release` went further and *advised* `-Force` ("If that session is gone, re-run with `-Force`") without ever checking whether it was. PR #106 fixed `-List` by testing the holder path and printing `[HOLDER GONE …]`, but deliberately did not touch `-Take` or `-Release`so the *blocking* paths stayed blind, which is the half that matters: `-List` is where you browse, `-Take` is where you are stopped.

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

Expand All @@ -8479,7 +8479,11 @@ The failure is also **self-concealing in the dangerous direction.** Age-stalenes

*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.
*Half B — done, PR #151.* Deferred once on purpose: `claim.ps1` had three sessions in it at once on 2026-08-02 (#106's `-List` liveness, #140's note-refresh on the `-Take` self-refresh path, and this), and a contended 170-line script is where a semantic conflict lands green. Built once both merged, which cost one round-trip and no rework.

The three surfaces now share **one** liveness helper, because they had been disagreeing: `-List` probed the holder while `-Take` and `-Release` did not probe at all. `-Take` blocked by a vanished holder now names the exact take-over commands; blocked by a *living* one it withholds the `-Force` recipe entirely and says quiet is not dead. `-Release` no longer prints "If that session is gone, re-run with `-Force`" — advice it gave unconditionally, about a holder it had never looked at, at exactly the moment an operator was deciding whether to take someone else's key.

**The asymmetry is the design, not an implementation detail.** A vanished worktree is a fact and the one state safe to act on unasked; *present*, *undatable* and *unprobeable* all read as "coordinate first", never "probably fine". A probe hardwired to `gone` would pass every positive test, so each one is paired with the negative case that catches it. `-Force` itself is untouched: this reports, it does not enforce — refusing to override a live claim would strand every key whose holder is merely unreachable, which is this same bug one level up.

**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*).

Expand Down
102 changes: 85 additions & 17 deletions scripts/coord/claim.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,38 @@ function Get-Mine([string]$Path) {
[pscustomobject]@{ Claim = $c; IsMine = ($held -ieq $me) }
}

# ONE liveness rule, three call sites (BACKLOG #345 Half B).
#
# `-List` learned this first, and that was the wrong half to fix alone: -List is where you BROWSE, and
# `-Take`/`-Release` are where you are STOPPED. Both blocking paths printed the same "held by another
# worktree" block whether the holder had been deleted, had died, or was committing that minute -- and
# `-Release` went further and RECOMMENDED `-Force` ("If that session is gone...") on a holder it had
# never looked at. Advice that cannot distinguish the two cases is advice to guess, and the guess that
# frees a live session's key causes the duplicate build this whole registry exists to prevent.
#
# Reports only what it can PROVE. A vanished directory is a fact and the one state safe to act on
# unasked. Everything else is 'unknown' or a quiet-hours count -- never "probably fine": a session can
# be alive and simply not committing, so silence is not evidence of death.
function Get-HolderLiveness([string]$HeldPath) {
try {
if (-not (Test-Path -LiteralPath $HeldPath)) {
return [pscustomobject]@{ State = 'gone'; QuietHours = $null }
}
$ct = & git -C $HeldPath log -1 --format=%ct 2>$null
if ($ct) {
$quiet = [int]((Get-Date) - [System.DateTimeOffset]::FromUnixTimeSeconds([long]$ct).LocalDateTime).TotalHours
return [pscustomobject]@{ State = 'present'; QuietHours = $quiet }
}
# Present on disk but no commit to date it by -- a brand-new worktree looks exactly like this.
return [pscustomobject]@{ State = 'unknown'; QuietHours = $null }
}
catch {
# Say so rather than returning 'gone'. A failed probe that reported death would turn an
# unreadable path into a licence to release someone's live claim.
return [pscustomobject]@{ State = 'failed'; QuietHours = $null }
}
}

function Show-List {
$files = @(Get-ChildItem $claims -Filter *.json -EA SilentlyContinue | Sort-Object Name)
if (-not $files) { Write-Host "No active claims."; return }
Expand All @@ -102,20 +134,16 @@ function Show-List {
$age = ""
try {
$hrs = [int]((Get-Date) - [datetime]::Parse($c.claimed)).TotalHours
if (-not (Test-Path $held)) {
# The only state that is genuinely safe to act on without asking anyone.
$age = " [HOLDER GONE -- worktree no longer exists; release with -Force]"
}
else {
$ct = & git -C $held log -1 --format=%ct 2>$null
if ($ct) {
$quiet = [int]((Get-Date) - [System.DateTimeOffset]::FromUnixTimeSeconds([long]$ct).LocalDateTime).TotalHours
$age = " [held ${hrs}h; holder last committed ${quiet}h ago]"
if ($quiet -ge 12) { $age += " -- QUIET, confirm with the holder before releasing" }
}
else {
$age = " [held ${hrs}h; holder liveness UNKNOWN -- confirm before releasing]"
# Shared with -Take and -Release, so all three surfaces answer "is the holder there?" the
# same way. They used to disagree: this one probed, the other two did not probe at all.
$live = Get-HolderLiveness $held
switch ($live.State) {
'gone' { $age = " [HOLDER GONE -- worktree no longer exists; release with -Force]" }
'present' {
$age = " [held ${hrs}h; holder last committed $($live.QuietHours)h ago]"
if ($live.QuietHours -ge 12) { $age += " -- QUIET, confirm with the holder before releasing" }
}
default { $age = " [held ${hrs}h; holder liveness UNKNOWN -- confirm before releasing]" }
}
} catch {
# Say so. An empty annotation reads as "nothing notable about this claim", which is the same
Expand All @@ -139,8 +167,28 @@ if ($Release) {
Write-Host " held by: $($info.Claim.worktree) [$($info.Claim.branch)]"
Write-Host " since : $($info.Claim.claimed)"
Write-Host " note : $($info.Claim.note)"
# DO NOT recommend -Force without looking. This line used to read "If that session is gone,
# re-run with -Force" unconditionally -- an instruction to guess, printed at exactly the moment
# the operator is deciding whether to take someone else's key. Now the recommendation is only
# made in the one state that can be proven, and the live case says the opposite.
$live = Get-HolderLiveness $info.Claim.worktree
Write-Host ""
Write-Host "If that session is gone, re-run with -Force."
switch ($live.State) {
'gone' {
Write-Host " HOLDER GONE -- that worktree no longer exists on disk." -ForegroundColor Green
Write-Host " Safe to take over: claim.ps1 -Release $Release -Force"
}
'present' {
Write-Host " HOLDER IS STILL THERE -- that worktree exists and last committed $($live.QuietHours)h ago." -ForegroundColor Red
Write-Host " Do NOT -Force it on the strength of a quiet period: a session can be alive and"
Write-Host " simply not committing. Ask that session first -- releasing a live claim is how two"
Write-Host " sessions end up building the same thing."
}
default {
Write-Host " HOLDER LIVENESS UNKNOWN -- the worktree exists but could not be dated." -ForegroundColor Yellow
Write-Host " Confirm with that session before using -Force."
}
}
exit 1
}
Remove-Item -LiteralPath $file -Force
Expand Down Expand Up @@ -250,10 +298,30 @@ try {
Write-Host " held by: $($info.Claim.worktree) [$($info.Claim.branch)]"
Write-Host " since : $($info.Claim.claimed)"
Write-Host " note : $($info.Claim.note)"
# THE BLOCKING PATH IS WHERE THIS MATTERS MOST. -List is where you browse; this is where a session
# is stopped and has to decide between waiting, picking other work, and taking the key. It used to
# offer -Force as a flat third option with no way to tell a dead holder from a live one, so the
# cheapest way past the gate was also the one that causes the duplicate build it exists to prevent.
$live = Get-HolderLiveness $info.Claim.worktree
Write-Host ""
Write-Host "Do NOT build it in parallel -- that is the duplicate-work this gate exists to stop."
Write-Host "Coordinate with that session, pick different work, or if it is dead:"
Write-Host " pwsh -NoProfile -File scripts\coord\claim.ps1 -Release $Take -Force"
switch ($live.State) {
'gone' {
Write-Host " HOLDER GONE -- that worktree no longer exists on disk, so nobody is building this." -ForegroundColor Green
Write-Host " Take it over with:"
Write-Host " pwsh -NoProfile -File scripts\coord\claim.ps1 -Release $Take -Force"
Write-Host " pwsh -NoProfile -File scripts\coord\claim.ps1 -Take $Take -Note ""<what>"""
}
'present' {
Write-Host " HOLDER IS STILL THERE -- that worktree exists and last committed $($live.QuietHours)h ago." -ForegroundColor Red
Write-Host " Do NOT build it in parallel -- that is the duplicate-work this gate exists to stop,"
Write-Host " and do NOT -Force it: quiet is not dead. Coordinate with that session or pick"
Write-Host " different work. Its note above says what it is doing."
}
default {
Write-Host " HOLDER LIVENESS UNKNOWN -- the worktree exists but could not be dated." -ForegroundColor Yellow
Write-Host " Treat it as live: coordinate with that session before -Force."
}
}
exit 1
}
try {
Expand Down
Loading
Loading