From e0381e381d3df4fdb8b32d9f9e49ad0524952ab7 Mon Sep 17 00:00:00 2001 From: wshallwshall Date: Sun, 2 Aug 2026 11:32:43 -0500 Subject: [PATCH] fix(coord): -Take and -Release recommended -Force on a holder nobody had looked at (BACKLOG #345 Half B) #106 taught `-List` to probe whether a claim's holder still exists on disk. That was the wrong half to fix alone: `-List` is where you BROWSE, and `-Take`/`-Release` are where a session is STOPPED and has to choose between waiting, picking other work, and taking the key. 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, printing "If that session is gone, re-run with -Force" unconditionally, about a holder it had never probed. That is an instruction to guess, issued at the exact moment the guess is expensive: the cheapest way past the gate was also the one that frees a live session's key. This file's own header records what that costs -- three sessions fixed the same npm advisory on 2026-07-24, two PRs closed as duplicates, and the survivor had not tested what the others found. All three surfaces now share ONE helper, because they had been disagreeing: -List probed, the other two did not probe at all. `-Take` blocked by a vanished holder names the exact take-over commands; blocked by a living one it withholds the -Force recipe entirely and says quiet is not dead. THE ASYMMETRY IS THE DESIGN. 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 session can be alive and simply not committing, so silence is not evidence of death. A probe hardwired to 'gone' would pass every positive assertion, so each is paired with the negative case that catches it. The catch returns 'failed', never 'gone': an unreadable path must not become a licence to release someone's live claim. -Force is deliberately untouched. This reports; it does not enforce. Refusing to override a live claim would strand every key whose holder is merely unreachable -- the same orphan, one level up. Verified: 4 of the 9 new tests FAIL against main's claim.ps1 and the other 5 are regression guards that must pass on both sides. One assertion was rewritten mid-build after it failed on the PATCHED code -- it searched for the token `-Force`, which the live-holder text contains inside "do NOT -Force it"; the property wanted is "no runnable recipe", so it now asserts the absence of the command line. Re-checked against unpatched afterwards to prove the rewrite had not made it vacuous. 41 coordination tests green. --- docs/BACKLOG.md | 14 +- scripts/coord/claim.ps1 | 102 +++++++++++--- tests/test_coord_claim_liveness.py | 214 +++++++++++++++++++++++++++++ 3 files changed, 308 insertions(+), 22 deletions(-) create mode 100644 tests/test_coord_claim_liveness.py diff --git a/docs/BACKLOG.md b/docs/BACKLOG.md index d48a56cd..4eea556c 100644 --- a/docs/BACKLOG.md +++ b/docs/BACKLOG.md @@ -8448,15 +8448,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 `/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 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 `/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. +*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. @@ -8473,7 +8473,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*). diff --git a/scripts/coord/claim.ps1 b/scripts/coord/claim.ps1 index 958aa235..b7b86057 100644 --- a/scripts/coord/claim.ps1 +++ b/scripts/coord/claim.ps1 @@ -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 } @@ -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 @@ -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 @@ -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 """"" + } + '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 { diff --git a/tests/test_coord_claim_liveness.py b/tests/test_coord_claim_liveness.py new file mode 100644 index 00000000..04d81ef2 --- /dev/null +++ b/tests/test_coord_claim_liveness.py @@ -0,0 +1,214 @@ +# SPDX-License-Identifier: AGPL-3.0-or-later +# Copyright (C) 2026 MessageFoundry Organization and contributors +"""The BLOCKING paths must know whether the holder still exists (BACKLOG #345 Half B). + +``-List`` learned holder-liveness first, and that was the wrong half to fix on its own: ``-List`` is +where you *browse*, ``-Take`` and ``-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, printing "If that session is gone, +re-run with -Force" on a holder it had never looked at. + +That is an instruction to guess, issued at exactly the moment an operator is deciding whether to take +someone else's key, and the cheapest way past the gate was also the one that causes the duplicate +build the gate exists to prevent. The 2026-07-24 incident in ``claim.ps1``'s own header is what that +costs: three sessions fixed the same advisory, two PRs were closed as duplicates, and the one that +merged had not tested what the others found. + +**The asymmetry is the whole design.** A vanished worktree is a *fact* and the one state safe to act +on unasked. Everything else -- present, undatable, unprobeable -- must read as "coordinate first", +never as "probably fine". A session can be alive and simply not committing, so silence is not +evidence of death. Every test below therefore pairs a positive case with the negative one that would +pass if the probe simply always said "gone". +""" + +from __future__ import annotations + +import os +import shutil +import subprocess +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[1] +CLAIM = ROOT / "scripts" / "coord" / "claim.ps1" +TIMEOUT = 60 + +pytestmark = pytest.mark.skipif( + shutil.which("pwsh") is None or os.name != "nt", + reason="claim.ps1 needs pwsh on Windows", +) + + +def git(repo: Path, *args: str) -> str: + proc = subprocess.run( + ["git", "-C", str(repo), *args], capture_output=True, text=True, timeout=TIMEOUT, check=True + ) + return proc.stdout + + +@pytest.fixture +def repo(tmp_path: Path) -> Path: + r = tmp_path / "repo" + r.mkdir() + subprocess.run(["git", "init", "-q", "-b", "main", str(r)], check=True, capture_output=True) + git(r, "config", "user.email", "t@example.invalid") + git(r, "config", "user.name", "t") + (r / "f.txt").write_text("x", encoding="utf-8") + git(r, "add", "-A") + git(r, "commit", "-qm", "base") + return r + + +def claim(cwd: Path, *args: str) -> subprocess.CompletedProcess[str]: + return subprocess.run( + ["pwsh", "-NoProfile", "-NonInteractive", "-File", str(CLAIM), *args], + cwd=str(cwd), + capture_output=True, + text=True, + timeout=TIMEOUT, + check=False, + ) + + +def peer_holding(repo: Path, tmp_path: Path, key: str, note: str = "the peer's work") -> Path: + """A second worktree that holds ``key``. Returned so a test can delete it.""" + peer = tmp_path / "peer-wt" + git(repo, "worktree", "add", "-q", "-b", "peer-branch", str(peer)) + assert claim(peer, "-Take", key, "-Note", note).returncode == 0 + return peer + + +def orphan(repo: Path, peer: Path) -> None: + """Delete the holder's directory, leaving its claim behind -- the orphan #345 is about. + + ``git worktree remove`` would also deregister it; the claim file lives beside the SHARED object + store either way, which is precisely why it outlives its worktree. + """ + shutil.rmtree(peer) + + +# -------------------------------------------------------------------------------------------------- +# -Take: the path a blocked session actually hits +# -------------------------------------------------------------------------------------------------- + + +def test_take_blocked_by_a_VANISHED_holder_says_so(repo: Path, tmp_path: Path) -> None: + peer = peer_holding(repo, tmp_path, "k") + orphan(repo, peer) + + proc = claim(repo, "-Take", "k", "-Note", "mine now") + + assert proc.returncode == 1, "the key is still held; blocking is correct" + assert "HOLDER GONE" in proc.stdout + # Naming the exact command is the point: an operator told only "it is gone" still has to guess how + # to proceed, and the guess people reach for is editing the registry by hand. + assert "-Release k -Force" in proc.stdout + assert "BLOCKED" in proc.stdout + + +def test_take_blocked_by_a_LIVING_holder_does_not_offer_force(repo: Path, tmp_path: Path) -> None: + """The load-bearing negative. A probe hardwired to 'gone' passes every test above and fails this. + + Before this change the output was identical in both cases and ended with a bare `-Force` recipe -- + so the fastest way past the gate was to take a live session's key. + """ + peer_holding(repo, tmp_path, "k") # still on disk + + proc = claim(repo, "-Take", "k", "-Note", "mine now") + + assert proc.returncode == 1 + assert "HOLDER IS STILL THERE" in proc.stdout + assert "HOLDER GONE" not in proc.stdout + # The property is "no runnable RECIPE", not "the token -Force never appears". The live-holder text + # deliberately says "do NOT -Force it", so a bare token search fails on the prohibition itself -- + # which is the assertion telling you it is measuring the wrong thing, not the code. + assert "-Release k -Force" not in proc.stdout, ( + "a live holder must not be handed a copy-pasteable -Force command" + ) + assert "quiet is not dead" in proc.stdout + + +def test_take_reports_the_holders_note_either_way(repo: Path, tmp_path: Path) -> None: + """The note is what tells a blocked session whether to wait or pick other work.""" + peer_holding(repo, tmp_path, "k", note="rebuilding the codec") + assert "rebuilding the codec" in claim(repo, "-Take", "k").stdout + + +# -------------------------------------------------------------------------------------------------- +# -Release: it used to RECOMMEND -Force without looking +# -------------------------------------------------------------------------------------------------- + + +def test_release_of_a_VANISHED_holders_claim_recommends_force(repo: Path, tmp_path: Path) -> None: + peer = peer_holding(repo, tmp_path, "k") + orphan(repo, peer) + + proc = claim(repo, "-Release", "k") + + assert proc.returncode == 1, "still refuses without -Force; it reports, it does not act" + assert "HOLDER GONE" in proc.stdout + assert "-Release k -Force" in proc.stdout + # And the refusal must not have silently released it. + assert claim(repo, "-Take", "k", "-Note", "x").returncode == 1 + + +def test_release_of_a_LIVING_holders_claim_warns_instead_of_advising_force( + repo: Path, tmp_path: Path +) -> None: + """The exact string this replaces was 'If that session is gone, re-run with -Force.' + + Printed unconditionally, on a holder never probed. This asserts the advice inverted for the live + case rather than merely gaining a new line beside it. + """ + peer_holding(repo, tmp_path, "k") + + proc = claim(repo, "-Release", "k") + + assert proc.returncode == 1 + assert "HOLDER IS STILL THERE" in proc.stdout + assert "If that session is gone, re-run with -Force." not in proc.stdout, ( + "the unconditional recommendation must be gone, not supplemented" + ) + assert "Ask that session first" in proc.stdout + + +def test_force_still_works_on_a_live_holder(repo: Path, tmp_path: Path) -> None: + """This reports; it must not have become an enforcement. + + Refusing to -Force a live claim would strand every key whose holder is merely unreachable -- the + orphan problem again, one level up. The operator keeps the override; they just stop being told to + use it blind. + """ + peer_holding(repo, tmp_path, "k") + assert claim(repo, "-Release", "k", "-Force").returncode == 0 + assert claim(repo, "-Take", "k", "-Note", "now free").returncode == 0 + + +def test_releasing_your_OWN_claim_never_probes_liveness(repo: Path) -> None: + """The holder is this worktree; there is nothing to warn about and no reason to slow it down.""" + assert claim(repo, "-Take", "k", "-Note", "mine").returncode == 0 + proc = claim(repo, "-Release", "k") + assert proc.returncode == 0 + assert "HOLDER" not in proc.stdout + + +# -------------------------------------------------------------------------------------------------- +# -List keeps its behaviour: the refactor shares one rule, it does not change what -List reports +# -------------------------------------------------------------------------------------------------- + + +def test_list_still_flags_a_vanished_holder(repo: Path, tmp_path: Path) -> None: + peer = peer_holding(repo, tmp_path, "k") + orphan(repo, peer) + out = claim(repo, "-List").stdout + assert "HOLDER GONE" in out + assert "release with -Force" in out + + +def test_list_still_reports_a_living_holders_quiet_hours(repo: Path, tmp_path: Path) -> None: + """Anti-regression for the shared helper: 'present' must still carry its commit age.""" + peer_holding(repo, tmp_path, "k") + out = claim(repo, "-List").stdout + assert "holder last committed" in out + assert "HOLDER GONE" not in out