From 05a37c19ab9f566a9c10e180c32842a83c830c20 Mon Sep 17 00:00:00 2001 From: wshallwshall Date: Thu, 17 Sep 2026 10:37:01 -0500 Subject: [PATCH 1/3] fix(coord): count a session in a deregistered checkout as unplaceable occupancy.ps1 dropped a record whose cwd matched no registered worktree on a bare `continue`, so it reached no counter. RecordsUnplaceable could not rise past the two shapes the matcher already counted, Available could not go false for a third, and a session left behind by a worktree git had stopped listing was INVISIBLE rather than UNPLACEABLE. That is a fence that cannot fail, so its green light measured nothing -- and prune-merged.ps1 and claim.ps1 both gate on it. The boundary is the whole of the change and it cuts both ways. Another repository's session must not cost a refusal, because most records on this host are exactly that, so a fault needs evidence the cwd is a checkout of THIS repo: * the directory is still there -- read its own .git pointer and compare git directories. That survives deregistration where `git rev-parse` does not, and a plain `-*` lookalike fails it; * the directory is gone -- nothing on disk can say whose it was, so the `-` naming new.ps1 builds is the only evidence left. The name test is admitted on this branch only, never on a path that exists. Placement moves ahead of the availability verdict: while the verdict was computed first, a failed placement had nowhere to go. tests/test_coord_occupancy_unplaceable.py asserts BOTH directions, because a test that only proves refusal passes against a fence wired shut. Against the unpatched script the two fault tests FAIL and the two availability tests pass; patched, all four pass. Measured on this host with the patch applied: 6 config roots, 7 records, 0 unplaceable, 110 worktrees, 3 sessions placed -- so the new fault does not disable the live fence. --- scripts/coord/occupancy.ps1 | 157 ++++++++++++-- tests/test_coord_occupancy_unplaceable.py | 237 ++++++++++++++++++++++ 2 files changed, 374 insertions(+), 20 deletions(-) create mode 100644 tests/test_coord_occupancy_unplaceable.py diff --git a/scripts/coord/occupancy.ps1 b/scripts/coord/occupancy.ps1 index 1959fa4f6..334a07c24 100644 --- a/scripts/coord/occupancy.ps1 +++ b/scripts/coord/occupancy.ps1 @@ -27,14 +27,27 @@ on Available, print the receipt, and refuse when it is false. Count what you EXAMINED, not what you found. - AN UNPLACEABLE RECORD MAKES THE WHOLE FENCE UNAVAILABLE. Two shapes qualify -- a file that will not - parse, and a record that parses but carries no cwd -- and BOTH used to be dropped on the floor by a - silent `continue`, so they appeared in no count at all. Neither can be attributed to, or cleared + AN UNPLACEABLE RECORD MAKES THE WHOLE FENCE UNAVAILABLE. Three shapes qualify -- a file that will + not parse, a record that parses but carries no cwd, and a record whose cwd is a checkout of THIS + repo that `git worktree list` no longer carries. Each one reached this file as a silent `continue` + and so appeared in no count at all, and the third was still being dropped for as long as it took + anyone to notice that fixing the first two had not fixed it. None can be attributed to, or cleared from, any particular worktree: it could be a session sitting in the very tree the caller is about to delete. A file caught HALF-WRITTEN is exactly this shape, which makes it the signature of a session that launched seconds ago. Refusing the whole run is the only answer that cannot destroy one; the remedy is to look at the named file and re-run. + THE THIRD SHAPE IS THE STATE THE INCIDENT PRODUCED. prune-merged.ps1's header records a run that + deregistered an occupied worktree and then failed to delete the directory, leaving a session whose + every git command failed. From that moment its record's cwd names a checkout git does not list, and + a bare `continue` here made that session INVISIBLE rather than UNPLACEABLE -- so RecordsUnplaceable + could not rise, Available could not go false, and a fence that cannot fail measures nothing. + + IT IS A NARROW SHAPE ON PURPOSE. Most records on this host name OTHER repositories, and faulting + those would leave the fence permanently unavailable -- which disarms every caller as thoroughly as + never refusing at all. Get-UnplaceableCwdReason below holds the whole boundary and says what + evidence each side of it rests on. + RecordsExamined and RecordsUnplaceable deliberately OVERLAP: the first counts what parsed, the second counts what cannot be placed, and a cwd-less record is both. @@ -129,6 +142,90 @@ function Get-RepoWorktrees([string]$RepoHint) { return $out } +# This repo's SHARED git directory, absolute and normalised, or '' when git cannot say. Every worktree +# of one repo reports the same value and a different clone never does, so it is the identity a stray +# checkout is matched against below. +function Get-RepoCommonDir([string]$RepoHint) { + $gitArgs = @() + if ($RepoHint) { $gitArgs = @("-C", $RepoHint) } + $cd = & git @gitArgs rev-parse --path-format=absolute --git-common-dir 2>$null + if ($LASTEXITCODE -ne 0 -or -not $cd) { return '' } + return (ConvertTo-Norm ([string]$cd).Trim()) +} + +# The git directory a path belongs to, found by walking UP from it the way git does, normalised. '' when +# nothing up the chain is a checkout. +# +# PURE FILESYSTEM, AND THAT IS THE POINT. `git -C rev-parse` fails outright once a worktree's +# admin entry under /worktrees/ has been pruned -- which is exactly the state this gets +# asked about -- so git would answer "not a repository" for a checkout whose own .git file still names +# this repo. Reading the pointer ourselves survives deregistration, and it is the same evidence +# prune-merged.ps1 uses to recognise an orphan it left behind. +function Get-OwningGitCommonDir([string]$Path) { + $cur = $Path + # Bounded: a path that never resolves to a drive root must not spin a SessionStart hook. + for ($i = 0; $i -lt 64 -and $cur; $i++) { + $dot = Join-Path $cur '.git' + # The primary checkout carries the common dir itself as a DIRECTORY. + if (Test-Path -LiteralPath $dot -PathType Container) { return (ConvertTo-Norm $dot) } + if (Test-Path -LiteralPath $dot -PathType Leaf) { + $txt = '' + try { $txt = Get-Content -LiteralPath $dot -Raw -EA Stop } catch { return '' } + if (-not ($txt -match 'gitdir:\s*(\S.*)')) { return '' } + $gitdir = ConvertTo-Norm ($Matches[1].Trim()) + # A LINKED worktree's pointer names /worktrees/. Fold it onto the common + # dir so both spellings compare as the one identity. + if ($gitdir -match '^(.+)/worktrees/[^/]+$') { return $Matches[1] } + return $gitdir + } + $parent = Split-Path $cur -Parent + if (-not $parent -or $parent -eq $cur) { break } + $cur = $parent + } + return '' +} + +# Why a record that matched no worktree is a FAULT rather than simply somebody else's session. Returns +# '' when it is not this fence's business. +# +# THE BOUNDARY IS THE WHOLE OF THIS FUNCTION, and it cuts both ways. Under-flagging is the defect this +# was written for: a session in a deregistered checkout vanished, so the fence cleared worktrees it had +# never accounted for. Over-flagging costs exactly as much in the other direction -- most records on +# this host name other repositories, and faulting those leaves the fence permanently unavailable, which +# a caller stops reading. So a fault needs EVIDENCE that the cwd is a checkout of THIS repo, and the two +# ways of getting it differ because the two states differ: +# +# * THE DIRECTORY IS STILL THERE. Read its own .git pointer and compare git directories. That is +# positive proof, it survives deregistration, and a directory that merely shares the `-` +# name prefix -- an unrelated clone, or a plain folder -- fails it and is left alone. presence.ps1 +# is already pinned against that prefix trap for attribution; this must not reintroduce it. +# * THE DIRECTORY IS GONE. Nothing on disk can say whose it was, so the only evidence left is the +# name, and `-` is what scripts/worktree/new.ps1 builds. A cwd under that naming +# which no longer exists is a worktree of this repo that was removed from under a session. The name +# test is admitted ONLY on this branch, never on a path that exists. +# +# A cwd inside a registered worktree never reaches here, so both branches are about checkouts git has +# stopped listing. +function Get-UnplaceableCwdReason { + [CmdletBinding()] + param( + [Parameter(Mandatory)][AllowEmptyString()][string]$Cwd, + [string]$PrimaryNorm, + [string]$RepoCommonNorm + ) + if (-not $Cwd) { return '' } + if (Test-Path -LiteralPath $Cwd) { + if (-not $RepoCommonNorm) { return '' } + if ((Get-OwningGitCommonDir $Cwd) -ne $RepoCommonNorm) { return '' } + return 'cwd is a checkout of this repository that `git worktree list` no longer carries, so it can be attributed to no worktree and clears none' + } + $norm = ConvertTo-Norm $Cwd + if ($PrimaryNorm -and $norm.StartsWith("$PrimaryNorm-")) { + return 'cwd no longer exists and is named as a worktree of this repository, so the session in it can be attributed to no worktree and clears none' + } + return '' +} + <# Map every session record onto the worktree it was launched in, fenced for liveness, with a receipt. @@ -138,7 +235,8 @@ Returns a pscustomobject: Detail [string] why it is unavailable, '' when it is available RootsExamined [int] config roots holding a sessions registry RecordsExamined [int] records that PARSED across those roots - RecordsUnplaceable[int] records that will not parse or carry no cwd -- any at all => Available false + RecordsUnplaceable[int] records that will not parse, carry no cwd, or name a checkout of this repo + that git no longer lists -- any at all => Available false UnplaceableFiles [array] each one's path and why, so the operator can go and look Worktrees [array] every worktree of this .git (Path/Branch/Locked/LockReason/...) PrimaryPath [string] the trunk checkout (git reports it first) @@ -192,12 +290,42 @@ function Get-WorktreeOccupancy { # RecordsUnplaceable counts what cannot be PLACED, and a cwd-less record is both. $faults = @($all | Where-Object { $_.Unreadable } | ForEach-Object { [pscustomobject]@{ File = $_.File; Why = "unparseable: $($_.Error)" } }) - $placeable = @() + + $repoCommonNorm = Get-RepoCommonDir $Repo + + # PLACEMENT HAPPENS HERE, BEFORE THE AVAILABILITY VERDICT, and that ordering is the fix rather than + # a tidy-up. The third fault shape is only visible once you have TRIED to place a record. While the + # verdict was computed first, placement ran after it in a loop of its own and a failed placement had + # nowhere to go -- which is how a silent `continue` there stayed invisible for as long as it did. + $placed = @() foreach ($e in $records) { if (-not $e.Record.cwd) { $faults += [pscustomobject]@{ File = $e.File; Why = 'no cwd in the record, so it cannot be placed in any worktree' } + continue } - else { $placeable += $e } + + # Scope: cwd inside one of this repo's worktrees. Exact match on the worktree root, or a + # descendant of it -- a session cd'd into a subdirectory is still that worktree's session. + # LONGEST match wins, or a nested worktree (.claude/worktrees/x) folds into the primary and + # gets reported as colliding in a checkout it is nowhere near. + $cwdNorm = ConvertTo-Norm $e.Record.cwd + $match = $null + foreach ($k in $wtIndex.Keys) { + if ($cwdNorm -eq $k -or $cwdNorm.StartsWith("$k/")) { + if (-not $match -or $k.Length -gt (ConvertTo-Norm $match.Path).Length) { $match = $wtIndex[$k] } + } + } + if ($match) { + $placed += [pscustomobject]@{ Entry = $e; Match = $match } + continue + } + + # NOT MATCHING IS TWO DIFFERENT ANSWERS, and they used to share one silent `continue`. Another + # repo's session is none of this fence's business and must not cost a refusal. A checkout of + # THIS repo that git has stopped listing is a session the fence cannot see, and every worktree + # it then clears is cleared on an incomplete roster. + $why = Get-UnplaceableCwdReason -Cwd ([string]$e.Record.cwd) -PrimaryNorm $primaryNorm -RepoCommonNorm $repoCommonNorm + if ($why) { $faults += [pscustomobject]@{ File = $e.File; Why = $why } } } $available = $false @@ -216,21 +344,10 @@ function Get-WorktreeOccupancy { else { $available = $true } $sessions = @() - foreach ($entry in $placeable) { + foreach ($p in $placed) { + $entry = $p.Entry $rec = $entry.Record - - # Scope: cwd inside one of this repo's worktrees. Exact match on the worktree root, or a - # descendant of it -- a session cd'd into a subdirectory is still that worktree's session. - # LONGEST match wins, or a nested worktree (.claude/worktrees/x) folds into the primary and - # gets reported as colliding in a checkout it is nowhere near. - $cwdNorm = ConvertTo-Norm $rec.cwd - $match = $null - foreach ($k in $wtIndex.Keys) { - if ($cwdNorm -eq $k -or $cwdNorm.StartsWith("$k/")) { - if (-not $match -or $k.Length -gt (ConvertTo-Norm $match.Path).Length) { $match = $wtIndex[$k] } - } - } - if (-not $match) { continue } + $match = $p.Match # A record we cannot even evaluate (e.g. a non-numeric pid, which throws in the fence) must # VETO, not vanish and not crash the caller. UNREADABLE is in the veto set for that reason. diff --git a/tests/test_coord_occupancy_unplaceable.py b/tests/test_coord_occupancy_unplaceable.py new file mode 100644 index 000000000..ad66eccfa --- /dev/null +++ b/tests/test_coord_occupancy_unplaceable.py @@ -0,0 +1,237 @@ +# SPDX-License-Identifier: AGPL-3.0-or-later +# Copyright (C) 2026 MessageFoundry Foundation, LLC and contributors +"""The occupancy fence must be able to report UNAVAILABLE, or its green light proves nothing. + +``scripts/coord/occupancy.ps1`` returns ``Available`` beside its rows precisely so that "the fence +ran and nobody is here" stops looking like "the fence could not look". Its own header states the +rule: an unplaceable record makes the whole fence unavailable, because such a record could name ANY +worktree and therefore clears none of them. + +**The header promised it and the matcher dropped one shape on the floor.** A record whose cwd matched +no registered worktree left the loop on a bare ``continue`` and reached no counter, so a session +sitting in a checkout git no longer lists -- which is exactly what the incident in +``prune-merged.ps1``'s header produced, a worktree deregistered with somebody still working in it -- +was INVISIBLE rather than UNPLACEABLE. ``RecordsUnplaceable`` could not rise past the two shapes the +matcher did count, and no third shape could ever make ``Available`` false. + +That is a control that cannot fail, so it measured nothing, and the two reapers gated on it inherited +the same hole. + +**Every test here is one half of a pair.** Asserting only that a planted record makes the fence +unavailable would pass against a matcher that refuses unconditionally, which is the safest possible +wrong answer and would quietly disarm every caller. So each direction is asserted: + +* a planted record whose worktree is gone MUST make the fence unavailable; +* the clean case, and a session belonging to some OTHER repository on the same host, MUST leave it + available. Over-flagging costs the fence in the other direction: most records on this machine + belong to other repos, and faulting them would leave the fence permanently refusing. +""" + +from __future__ import annotations + +import json +import os +import shutil +import subprocess +from pathlib import Path +from typing import Any + +import pytest + +from tests._dead_pid import never_live_pid + +ROOT = Path(__file__).resolve().parents[1] +COORD = ROOT / "scripts" / "coord" +LIB = ("occupancy.ps1", "session-registry.ps1") +TIMEOUT = 120 + +pytestmark = pytest.mark.skipif( + shutil.which("pwsh") is None or os.name != "nt", + reason="occupancy.ps1 needs pwsh on Windows (Get-Process / Process.StartTime)", +) + + +def _git(repo: Path, *args: str) -> None: + subprocess.run(["git", "-C", str(repo), *args], check=True, capture_output=True, text=True) + + +class Fixture: + """A throwaway repo, a throwaway config root, and a COPY of the scripts under test. + + Never the live tree: ``test_coord_seat_prompt.py`` records two stray claims that landed in the + real registry because a test ran against it. + """ + + def __init__(self, tmp_path: Path) -> None: + self.tmp = tmp_path + self.box = tmp_path / "scripts" / "coord" + self.box.mkdir(parents=True) + for name in LIB: + shutil.copy2(COORD / name, self.box / name) + + self.repo = tmp_path / "repo" + self.repo.mkdir() + _git(self.repo, "init", "-q", "-b", "main") + _git(self.repo, "config", "user.email", "t@example.invalid") + _git(self.repo, "config", "user.name", "t") + (self.repo / "f.txt").write_text("x", encoding="utf-8") + _git(self.repo, "add", "f.txt") + _git(self.repo, "commit", "-qm", "init") + + self.root = tmp_path / "root" + (self.root / "sessions").mkdir(parents=True) + + def sibling(self, name: str) -> Path: + """The path ``scripts/worktree/new.ps1`` gives a sibling worktree: ``-``.""" + return self.repo.parent / f"{self.repo.name}-{name}" + + def write_session(self, *, cwd: Path | str, session_id: str, pid: int | None = None) -> Path: + """A registry record. The pid is dead by construction: faults are counted before the + liveness fence runs, so no test here needs a live process.""" + procid = never_live_pid() if pid is None else pid + rec = { + "pid": procid, + "sessionId": session_id, + "cwd": str(cwd), + "startedAt": 1767225600000, + "version": "2.1.219", + "peerProtocol": 1, + "kind": "interactive", + "entrypoint": "claude-desktop", + "name": session_id[:8], + "nameSource": "derived", + } + f = self.root / "sessions" / f"{session_id}.json" + f.write_text(json.dumps(rec), encoding="utf-8") + return f + + def occupancy(self) -> dict[str, Any]: + ps = ( + f". '{self.box / 'occupancy.ps1'}'; " + f"Get-WorktreeOccupancy -Repo '{self.repo}' -ConfigRoot '{self.root}' " + "| ConvertTo-Json -Depth 6" + ) + proc = subprocess.run( + ["pwsh", "-NoProfile", "-NonInteractive", "-Command", ps], + capture_output=True, + text=True, + timeout=TIMEOUT, + check=False, + ) + assert proc.returncode == 0, f"rc={proc.returncode}\n{proc.stdout}\n{proc.stderr}" + parsed: dict[str, Any] = json.loads(proc.stdout) + return parsed + + +@pytest.fixture +def fx(tmp_path: Path) -> Fixture: + return Fixture(tmp_path) + + +def test_the_clean_case_reads_available(fx: Fixture) -> None: + """The positive control. Without this, every assertion below passes against a fence that has + simply been wired shut, and a fence that always refuses is as useless as one that never can.""" + fx.write_session(cwd=fx.repo, session_id="aaaaaaaa-1111") + occ = fx.occupancy() + + assert occ["Available"] is True, occ["Detail"] + assert occ["RecordsUnplaceable"] == 0 + assert occ["RecordsExamined"] == 1 + assert occ["Detail"] == "" + + +def test_a_session_whose_worktree_is_gone_is_unplaceable_not_invisible(fx: Fixture) -> None: + """The defect. ``EnterWorktree`` relocates a live session into + ``/.claude/worktrees/``; ``prune-merged.ps1``'s header records a run that + deregistered a worktree with somebody still working in it and failed to delete the directory. + + After that, the session's recorded cwd names a checkout ``git worktree list`` does not carry. The + matcher fell out of the loop on a bare ``continue``, so the record reached no counter at all: the + fence reported one fewer record than existed and still called itself Available. + """ + relocated = fx.sibling("relocated") / ".claude" / "worktrees" / "inner" + planted = fx.write_session(cwd=relocated, session_id="bbbbbbbb-2222") + assert not relocated.exists(), "the point of the test is a cwd that is no longer on disk" + + occ = fx.occupancy() + + assert occ["RecordsUnplaceable"] >= 1, ( + "a record naming a checkout of this repo that git no longer lists reached no counter, so " + f"the receipt under-reports what exists: {occ}" + ) + assert occ["Available"] is False, ( + f"the fence cleared every worktree while a session it could not place existed: {occ}" + ) + assert str(planted) in " ".join(occ["UnplaceableFiles"]), ( + f"the operator is told the fence refused but not which file to go and look at: {occ}" + ) + # It must not be attributed to a worktree either. An unplaceable record clears none of them and + # belongs to none of them; inventing an owner would be worse than the silence it replaces. + assert occ["Sessions"] == [] + + +def test_a_deregistered_checkout_still_on_disk_is_unplaceable(fx: Fixture) -> None: + """The same defect with the directory left behind, which is the state the incident actually + produced: ``git worktree remove --force`` deregistered the tree and then failed to delete it. + + The directory and its ``.git`` pointer still name this repository's worktree admin area, so the + evidence that it belongs to this repo is on disk -- and git does not list it. + """ + orphan = fx.sibling("orphan") + _git(fx.repo, "worktree", "add", "-q", "-b", "orphan-branch", str(orphan)) + admin = fx.repo / ".git" / "worktrees" / orphan.name + assert admin.is_dir(), f"git did not register the worktree where expected: {admin}" + shutil.rmtree(admin) # deregistered; the checkout and its .git file stay put + + listed = subprocess.run( + ["git", "-C", str(fx.repo), "worktree", "list", "--porcelain"], + capture_output=True, + text=True, + check=True, + ).stdout + assert orphan.name not in listed, ( + f"git still lists it, so this is not the state under test:\n{listed}" + ) + + fx.write_session(cwd=orphan, session_id="cccccccc-3333") + occ = fx.occupancy() + + assert occ["RecordsUnplaceable"] >= 1, occ + assert occ["Available"] is False, occ + + +def test_another_repositorys_session_does_not_make_this_fence_unavailable(fx: Fixture) -> None: + """The other direction, and the one that decides whether the fault is worth having. + + Most records on this host belong to other repositories. Faulting them would leave the fence + permanently refusing, which disarms it exactly as thoroughly as never refusing at all -- a caller + that always reads UNAVAILABLE stops reading it. + + Three shapes that are NOT this repo's business, planted together: + + * a session in a different git repository; + * a session in a plain directory that merely shares the ``-`` name prefix. This is the + sibling-prefix trap ``test_coord_presence.py`` already pins for attribution, and the fault must + not reintroduce it by another route; + * a session at a path that does not exist and is nowhere near this repo's naming. + """ + other = fx.tmp / "other-repo" + other.mkdir() + _git(other, "init", "-q", "-b", "main") + + lookalike = fx.sibling("sweep") # a plain directory, not a checkout of anything + lookalike.mkdir() + + fx.write_session(cwd=fx.repo, session_id="dddddddd-4444") + fx.write_session(cwd=other, session_id="eeeeeeee-5555") + fx.write_session(cwd=lookalike, session_id="ffffffff-6666") + fx.write_session(cwd=fx.tmp / "somewhere-else", session_id="99999999-7777") + + occ = fx.occupancy() + + assert occ["RecordsUnplaceable"] == 0, ( + "a record belonging to another repository was counted as this fence's fault, so the fence " + f"now refuses whenever anyone works anywhere else on this host: {occ['UnplaceableFiles']}" + ) + assert occ["Available"] is True, occ["Detail"] + assert occ["RecordsExamined"] == 4 From 345d2f62b118d8da517e67c3624fafb97122ec12 Mon Sep 17 00:00:00 2001 From: Scott Hall Date: Thu, 17 Sep 2026 14:58:42 -0500 Subject: [PATCH 2/3] fix(tests): classify the new occupancy test in the tooling manifest tests/test_coord_occupancy_unplaceable.py imports no engine module, so nothing decided which CI legs run it and test_tooling_partition.py failed the required ubuntu leg. That test's own message requires the entry to land in the same pull request as the file. Classified (a) HARNESS rather than the ambiguous (b): the subject is scripts/coord/occupancy.ps1, and the manifest header names the coordination harness as this tier. Twenty-six sibling test_coord_*.py files already sit here. Placed among those siblings rather than appended at end-of-file. Nothing enforces an order, only test_manifest_has_no_duplicates, and an EOF append is the one position that collides with every other pull request adding a line. Added by the Lander, not the authoring session, because the branch had been quiet for four hours with no claim on it and a third session is blocked until this commit is reachable from main. The head SHA was pinned before the write and the push refuses if it moved. --- tests/tooling_manifest.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/tooling_manifest.txt b/tests/tooling_manifest.txt index 89d5c51f1..5ce70cf49 100644 --- a/tests/tooling_manifest.txt +++ b/tests/tooling_manifest.txt @@ -67,6 +67,7 @@ tests/test_coord_handoff_pointer.py tests/test_coord_handoff_report.py tests/test_coord_lane_statement.py tests/test_coord_lock.py +tests/test_coord_occupancy_unplaceable.py tests/test_coord_overlap_attribution.py tests/test_coord_overlap_cache.py tests/test_coord_overlap_signals.py From 7a3bd46dfcd8ad9eeb4cf9bd7a4990c8a16730e8 Mon Sep 17 00:00:00 2001 From: wshallwshall Date: Thu, 17 Sep 2026 15:11:10 -0500 Subject: [PATCH 3/3] docs(worktrees): the fence's third unplaceable shape reaches WORKTREES.md occupancy.ps1:30 changed from "Two shapes qualify" to "Three" on this branch; docs/WORKTREES.md still enumerated two, so a documented claim stopped matching its code. No test asserts it either way. The enumeration, not just the numeral. The third shape is a record whose cwd is a checkout of this repo that `git worktree list` no longer carries. Taken from Get-UnplaceableCwdReason, not the script header: that function admits two evidence branches -- the owning git common dir when the path exists, the `-` name when it does not -- and the header covers only the first. Hence "still on disk or gone". Agreement words that moved with the count: "and one that parses" -> "a record that parses", "both used to be" -> "Each used to be", "Neither can be placed" -> "None can be placed". The "two independent signals" and "Both are re-read" above count signals, not shapes, and stand. An older copy in prune-merged.ps1:84 names only the unparseable shape. Left untouched -- another session owns that header. --- docs/WORKTREES.md | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/docs/WORKTREES.md b/docs/WORKTREES.md index d05ff5ae4..81f1ab38e 100644 --- a/docs/WORKTREES.md +++ b/docs/WORKTREES.md @@ -225,11 +225,14 @@ can *prove* a session is gone — a `DEAD`/`STALE`/absent verdict is the absence permission. And **if the fence cannot look at all, nothing is pruned**: an empty roster and an unreadable one produce the same empty answer, so availability is asserted explicitly — at least one config root with a registry, at least one readable record, **and no record that cannot be placed**. -That last one matters more than it sounds. Two shapes qualify — a file that will not parse, and one -that parses but carries no `cwd` — and both used to be dropped by a silent `continue`, appearing in no -count at all. Neither can be placed in *or* cleared from any candidate, and a file caught -*half-written* is exactly what a session that launched a second ago looks like. An unavailable fence -turns every candidate into a SKIP and exits **2**. There is deliberately no override flag. +That last one matters more than it sounds. Three shapes qualify — a file that will not parse, a +record that parses but carries no `cwd`, and a record whose `cwd` is a checkout of *this* repo that +`git worktree list` no longer carries, whether that directory is still on disk or gone. Each used to +be dropped by a silent `continue`, appearing in no count at all, and the third went on being dropped +after the fix for the first two. The incident above produced that shape in its still-on-disk form. +None can be placed in *or* cleared from any candidate, and a file caught *half-written* is exactly +what a session that launched a second ago looks like. An unavailable fence turns every candidate +into a SKIP and exits **2**. There is deliberately no override flag. ### The candidate set is siblings only — and "sibling" is not a prefix match