From 13bef777d5523fdd805c82379cf26f1cd7650ca2 Mon Sep 17 00:00:00 2001 From: wshallwshall Date: Wed, 29 Jul 2026 10:16:34 -0500 Subject: [PATCH 1/2] feat(coord): cross-surface session presence, incl. VS Code sessions The SessionStart banner listed WORKTREES, not live sessions -- so it could not tell you that someone is editing the shared primary right now, which is the collision that actually matters. And no existing tool could see a VS Code session at all. Why the Desktop app's own session tooling cannot do this: its list_sessions enumerates an in-memory map of sessions THE APP ITSELF SPAWNED. A VS Code session is never entered into it -- not filtered out, never registered -- so it is invisible and unaddressable. Verified 2026-07-29 against a live VS Code session sharing the DEFAULT config root, so this is not a per-login split. /sessions/.json is the only registry carrying every surface; presence.ps1 reads that, discovering config roots dynamically because several logins can coexist. Liveness is a fence, not a pid check. Pids get reused and these records outlive their process, so presence.ps1 computes process start time itself and requires it to be consistent with the recorded session start. Claude Code ships a procStart field for exactly this, but on this host it serialises as absent and its guard passes unconditionally -- so a bare pid check would report a recycled pid as a live session. Read-only throughout: a roster, not a channel. It never writes a registry file and never contacts another session. Tests drive the real scripts as subprocesses against throwaway repos and fixture config roots, never a Python copy of the rules. The fence test was verified by mutation: with the start-time check disabled it fails while the dead-pid test still passes, so it is pinning the fence rather than the script merely running. The session-context tests pin the fail-safe contract -- a missing, throwing, or junk-emitting presence.ps1 costs the live-session list and nothing else, because whatever that hook prints IS the chat's starting context. --- scripts/coord/presence.ps1 | 264 +++++++++++++++++++++++++ scripts/worktree/session-context.ps1 | 34 ++++ tests/test_coord_presence.py | 229 +++++++++++++++++++++ tests/test_session_context_presence.py | 165 ++++++++++++++++ 4 files changed, 692 insertions(+) create mode 100644 scripts/coord/presence.ps1 create mode 100644 tests/test_coord_presence.py create mode 100644 tests/test_session_context_presence.py diff --git a/scripts/coord/presence.ps1 b/scripts/coord/presence.ps1 new file mode 100644 index 00000000..c6cb20c3 --- /dev/null +++ b/scripts/coord/presence.ps1 @@ -0,0 +1,264 @@ +<# +.SYNOPSIS + Who is ACTUALLY live in this repo right now -- across every Claude Code surface, including VS Code. + +.DESCRIPTION + `claim.ps1` answers "what is being built"; this answers "who is here". They are different failures: + a claim tells you work is taken, presence tells you whether the session that took it still exists. + + WHY THIS EXISTS RATHER THAN THE OBVIOUS ALTERNATIVES + ---------------------------------------------------- + The Claude Desktop app's session tooling (its `list_sessions` MCP tool) enumerates an in-memory map + of sessions THE DESKTOP APP ITSELF SPAWNED. A session launched by the VS Code extension is never + entered into it -- not filtered out, never registered -- so it is invisible to that tool and cannot + be addressed by it. Verified 2026-07-29: a live VS Code session sharing the DEFAULT config root was + absent from `list_sessions` while its sibling desktop sessions were listed. It is not a login split. + + `/sessions/.json` is the only registry that contains every surface. This script + reads that, so a VS Code session in the primary checkout shows up next to desktop sessions in + worktrees. Config roots are discovered dynamically (~\.claude plus any ~\.claude-account-N), because + several logins can be in play on one machine and a session is only visible to the login that owns it. + + LIVENESS IS A FENCE, NOT A PID CHECK + ------------------------------------ + A bare `is pid alive` check is wrong: PIDs are reused, and these files outlive their process (a + session that dies uncleanly leaves its file behind -- measured, 2 of 3 IDE lock files on this host + named dead processes, the oldest by 6.5 days). Claude Code's own registry carries a `procStart` field + for exactly this fence, but on this host it serialises as absent, so the shipped guard passes + unconditionally. We therefore compute process start time OURSELVES and require it to be consistent + with the recorded session start. A reused PID hosts a process that started AFTER the session + registered, and that is what STALE means below. + + A session is reported LIVE only when its pid resolves AND that process's start time is consistent. + Anything else is STALE (pid reused or file orphaned) or DEAD (no such pid). + + READ-ONLY. This script never writes, never deletes a registry file, and never contacts another + session. It is a roster, not a channel. + +.EXAMPLE + pwsh -NoProfile -File scripts\coord\presence.ps1 + pwsh -NoProfile -File scripts\coord\presence.ps1 -All # include stale/dead entries + pwsh -NoProfile -File scripts\coord\presence.ps1 -Json # machine-readable +#> +[CmdletBinding()] +param( + # Config roots to scan. Defaults to every ~\.claude* directory (Desktop + each CLI/VS Code login). + # Tests point this at a fixture directory, so the real discovery logic is what gets exercised. + [string[]]$ConfigRoot, + # Show STALE and DEAD entries too. Default lists only sessions that are actually live. + [switch]$All, + # Emit JSON instead of a table. + [switch]$Json, + # Repo to scope to. Defaults to the current worktree's repo family (all worktrees sharing one .git). + [string]$Repo, + # Treat this pid as "me" so the roster can mark the calling session. Defaults to auto-detection by + # ancestry (see Get-SelfPids) -- 0 means "work it out", which is what every real invocation wants. + [int]$SelfPid = 0, + # How far a process may have started BEFORE its session registered and still be the same run. + # Generous: registration follows process start, but a cold start on a loaded box can lag. + [int]$StartSkewMinutes = 15 +) + +$ErrorActionPreference = "Stop" + +# --- Which worktrees count as "this repo" ------------------------------------------------------- +# A session is in-scope when its cwd is inside ANY worktree sharing this .git. Keyed on the worktree +# set rather than a single path, because the whole point is seeing siblings, not just yourself. +function Get-RepoWorktrees([string]$RepoHint) { + $gitArgs = @() + if ($RepoHint) { $gitArgs = @("-C", $RepoHint) } + $porcelain = & git @gitArgs worktree list --porcelain 2>$null + if ($LASTEXITCODE -ne 0 -or -not $porcelain) { return @() } + $out = @() + $cur = $null + foreach ($line in $porcelain) { + if ($line -like "worktree *") { + $cur = [pscustomobject]@{ Path = $line.Substring(9).Trim(); Branch = "" } + $out += $cur + } + elseif ($line -like "branch *" -and $cur) { + $cur.Branch = ($line.Substring(7).Trim() -replace '^refs/heads/', '') + } + elseif ($line -like "detached*" -and $cur) { + $cur.Branch = "(detached)" + } + } + return $out +} + +function ConvertTo-Norm([string]$p) { + if (-not $p) { return "" } + return ($p -replace '\\', '/').TrimEnd('/').ToLowerInvariant() +} + +# --- Config roots ------------------------------------------------------------------------------- +# Discovered, not hardcoded: several logins can coexist (~\.claude for Desktop, ~\.claude-account-N for +# CLI/VS Code subscriptions) and a session is only ever visible to the login that owns it. +function Get-ConfigRoots { + if ($ConfigRoot) { return @($ConfigRoot | Where-Object { Test-Path $_ }) } + return @( + Get-ChildItem -Path $env:USERPROFILE -Directory -Filter ".claude*" -Force -EA SilentlyContinue | + Where-Object { Test-Path (Join-Path $_.FullName "sessions") } | + ForEach-Object { $_.FullName } + ) +} + +function Get-LoginLabel([string]$RootPath) { + $leaf = Split-Path $RootPath -Leaf + if ($leaf -ieq ".claude") { return "default" } + return ($leaf -replace '^\.claude-account-', 'acct-') -replace '^\.claude-?', '' +} + +# The surface a session was launched from. This is the field that makes VS Code sessions visible at all, +# so it is reported verbatim when it is something we do not recognise rather than folded into "other". +function Get-SurfaceLabel([string]$Entrypoint) { + switch -Regex ($Entrypoint) { + '^claude-desktop$' { return "desktop" } + '^claude-vscode$' { return "vscode" } + '^$' { return "?" } + default { return $Entrypoint -replace '^claude-', '' } + } +} + +# --- The fence ---------------------------------------------------------------------------------- +# LIVE requires the pid to resolve AND the process start time to be consistent with the recorded +# session start. Without the second half this is just a pid check, and a reused pid reads as alive. +function Get-Liveness([int]$ProcId, [object]$StartedAtMs, [int]$SkewMinutes) { + if (-not $ProcId) { return @{ State = "DEAD"; Detail = "no pid in record" } } + $proc = Get-Process -Id $ProcId -EA SilentlyContinue + if (-not $proc) { return @{ State = "DEAD"; Detail = "pid $ProcId not running" } } + + $procStart = $null + try { $procStart = $proc.StartTime } catch { } + if (-not $procStart) { + # Access can be denied for a process owned by another context. Report the uncertainty rather + # than upgrading it to LIVE -- an unverifiable fence is not a passed fence. + return @{ State = "UNVERIFIED"; Detail = "pid $ProcId alive; start time unreadable" } + } + if ($null -eq $StartedAtMs) { + return @{ State = "UNVERIFIED"; Detail = "pid $ProcId alive; record has no startedAt" } + } + + $registered = [DateTimeOffset]::FromUnixTimeMilliseconds([int64]$StartedAtMs).LocalDateTime + # A process cannot have started after the session it hosts registered (allow a small forward slop + # for clock jitter). Started much later => this pid was recycled onto a different process. + $delta = ($procStart - $registered).TotalMinutes + if ($delta -gt 1) { + return @{ State = "STALE"; Detail = "pid $ProcId reused (process started $([int]$delta)m after the session)" } + } + if ($delta -lt (-1 * $SkewMinutes)) { + return @{ State = "STALE"; Detail = "pid $ProcId start precedes the session by $([int](-$delta))m" } + } + return @{ State = "LIVE"; Detail = "" } +} + +# --- Which of these sessions is the caller ------------------------------------------------------ +# NOT $PID: this script runs as a pwsh child (often a grandchild, via a hook), so its own pid never +# appears in the registry. The session that invoked us is an ANCESTOR, so walk the parent chain and +# treat every pid on it as "self". Getting this wrong is not cosmetic -- a roster that cannot tell you +# from a sibling is one you will act on as though someone else were in your own worktree. +function Get-SelfPids([int]$Override) { + if ($Override -gt 0) { return @($Override) } + # ONE CIM query for the whole process table, not one per ancestor: this runs on every SessionStart, + # and a dozen round-trips is the difference between a hook you notice and one you don't. + $ppid = @{} + try { + Get-CimInstance Win32_Process -Property ProcessId, ParentProcessId -EA Stop | + ForEach-Object { $ppid[[int]$_.ProcessId] = [int]$_.ParentProcessId } + } catch { return @($PID) } + + $chain = @() + $cur = $PID + # Bounded: a runaway or cyclic parent chain must not hang a SessionStart hook. + for ($i = 0; $i -lt 12 -and $cur -gt 0; $i++) { + $chain += $cur + $parent = $ppid[$cur] + if (-not $parent -or $parent -le 0 -or $chain -contains $parent) { break } + $cur = $parent + } + return $chain +} + +# --- Collect ------------------------------------------------------------------------------------ +$worktrees = Get-RepoWorktrees $Repo +if (-not $worktrees -or $worktrees.Count -eq 0) { + if ($Json) { "[]" | Write-Output } else { Write-Host "Not inside a git repository -- nothing to scope presence to." } + exit 0 +} +$wtIndex = @{} +foreach ($w in $worktrees) { $wtIndex[(ConvertTo-Norm $w.Path)] = $w } +# The primary (trunk) checkout is the first entry git reports; naming it matters because a session +# sitting there is the one most likely to collide with everyone else. +$primaryNorm = ConvertTo-Norm $worktrees[0].Path + +$selfPids = Get-SelfPids $SelfPid + +$rows = @() +foreach ($root in (Get-ConfigRoots)) { + $sessDir = Join-Path $root "sessions" + foreach ($f in @(Get-ChildItem $sessDir -Filter *.json -EA SilentlyContinue)) { + try { $rec = Get-Content $f.FullName -Raw -EA Stop | ConvertFrom-Json -EA Stop } catch { continue } + if (-not $rec.cwd) { continue } + + # 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. + $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 } + + $live = Get-Liveness ([int]$rec.pid) $rec.startedAt $StartSkewMinutes + $matchNorm = ConvertTo-Norm $match.Path + $rows += [pscustomobject]@{ + State = $live.State + Detail = $live.Detail + Surface = Get-SurfaceLabel $rec.entrypoint + Login = Get-LoginLabel $root + SessionId = [string]$rec.sessionId + Short = if ($rec.sessionId) { ([string]$rec.sessionId).Substring(0, [Math]::Min(8, ([string]$rec.sessionId).Length)) } else { "?" } + Pid = [int]$rec.pid + Cwd = [string]$rec.cwd + Worktree = if ($matchNorm -eq $primaryNorm) { "primary" } else { Split-Path $match.Path -Leaf } + IsPrimary = ($matchNorm -eq $primaryNorm) + Branch = $match.Branch + Kind = [string]$rec.kind + IsSelf = ($selfPids -contains [int]$rec.pid) + StartedAt = if ($null -ne $rec.startedAt) { [DateTimeOffset]::FromUnixTimeMilliseconds([int64]$rec.startedAt).LocalDateTime.ToString("o") } else { "" } + } + } +} + +$order = @{ "LIVE" = 0; "UNVERIFIED" = 1; "STALE" = 2; "DEAD" = 3 } +$rows = @($rows | Sort-Object @{ E = { $order[$_.State] } }, @{ E = { $_.Worktree } }) +if (-not $All) { $rows = @($rows | Where-Object { $_.State -eq "LIVE" -or $_.State -eq "UNVERIFIED" }) } + +if ($Json) { + # -Depth so nested pscustomobjects survive; -AsArray so a single row is still a JSON list and a + # caller can index it without special-casing. + ($rows | ConvertTo-Json -Depth 4 -AsArray) | Write-Output + exit 0 +} + +if ($rows.Count -eq 0) { + Write-Host "No live sessions found for this repo." -ForegroundColor DarkGray + Write-Host "(Add -All to include stale/dead registry entries.)" -ForegroundColor DarkGray + exit 0 +} + +Write-Host "" +Write-Host "Live Claude sessions in this repo ($($rows.Count)):" +foreach ($r in $rows) { + $me = if ($r.IsSelf) { " <-- THIS session" } else { "" } + $warn = if ($r.IsPrimary -and -not $r.IsSelf) { " [in the SHARED PRIMARY]" } else { "" } + $state = if ($r.State -eq "LIVE") { "" } else { " [$($r.State): $($r.Detail)]" } + Write-Host (" {0,-8} {1,-7} {2,-34} {3}" -f $r.Short, $r.Surface, $r.Worktree, $r.Branch) + if ($me -or $warn -or $state) { Write-Host (" {0}{1}{2}" -f $me.TrimStart(), $warn, $state) } +} +Write-Host "" +Write-Host " See what they are building: pwsh -NoProfile -File scripts\coord\claim.ps1 -List" +Write-Host "" +exit 0 diff --git a/scripts/worktree/session-context.ps1 b/scripts/worktree/session-context.ps1 index edd7b58c..4c3ef988 100644 --- a/scripts/worktree/session-context.ps1 +++ b/scripts/worktree/session-context.ps1 @@ -56,6 +56,40 @@ if ($root) { $lines += "All worktrees sharing this .git/history/remote:" $wt | ForEach-Object { $lines += " $_" } + # WHO IS ACTUALLY HERE. The worktree list above is the set of checkouts, not the set of live + # sessions -- most worktrees usually have nobody in them, and the one collision that matters + # (someone editing the shared primary right now) is invisible from it. presence.ps1 is the only + # roster that spans surfaces: the Desktop app's own session tooling never registers a session it + # did not spawn, so a VS Code session working in this repo does not appear in it at all. + $presence = Join-Path $PSScriptRoot "..\coord\presence.ps1" + if (Test-Path $presence) { + $peers = @() + # A SessionStart hook must never fail loudly: whatever this prints IS the chat's starting + # context, so a throw here would replace the coordination banner with a stack trace. + try { $peers = @(& $presence -Json | ConvertFrom-Json) } catch { $peers = @() } + + $others = @($peers | Where-Object { -not $_.IsSelf }) + if ($others.Count -gt 0) { + $lines += "" + $lines += "LIVE sessions in this repo right now ($($others.Count) besides you):" + foreach ($p in $others) { + $where = if ($p.IsPrimary) { "the SHARED PRIMARY" } else { $p.Worktree } + $flag = if ($p.State -ne "LIVE") { " [$($p.State)]" } else { "" } + $lines += " $($p.Short) $($p.Surface) in $where [$($p.Branch)]$flag" + } + # The surfaces differ in what can reach them, and that changes how you coordinate. + if (@($others | Where-Object { $_.Surface -ne "desktop" }).Count -gt 0) { + $lines += " NOTE: a non-desktop (e.g. VS Code) session is live. It cannot be reached by" + $lines += " session messaging -- coordinate through a claim or the PR, not a message." + } + if (@($others | Where-Object { $_.IsPrimary }).Count -gt 0) { + $lines += " WARNING: a session is working in the SHARED PRIMARY checkout. Anything you do" + $lines += " there can collide with it -- stay in this worktree." + } + $lines += " Full roster: pwsh -NoProfile -File scripts\coord\presence.ps1 -All" + } + } + # Nudge cleanup: count the - siblings new.ps1 creates, so finished ones don't pile up. $rootFwd = ($root -replace '\\', '/') $siblingCount = @($wt | Where-Object { ($_ -replace '\\', '/') -like "$rootFwd-*" }).Count diff --git a/tests/test_coord_presence.py b/tests/test_coord_presence.py new file mode 100644 index 00000000..746d39b0 --- /dev/null +++ b/tests/test_coord_presence.py @@ -0,0 +1,229 @@ +# SPDX-License-Identifier: AGPL-3.0-or-later +# Copyright (C) 2026 MessageFoundry Organization and contributors +"""Tests for the cross-surface session roster (``scripts/coord/presence.ps1``). + +Every test drives the REAL script as a subprocess against a throwaway git repo and a fixture config +root, so what is under test is the logic the SessionStart hook actually runs -- not a Python +re-implementation of its rules that could drift from it silently. + +Two properties carry the weight: + +* **VS Code sessions are included.** That is the entire reason this exists. The Desktop app's own + ``list_sessions`` never registers a session it did not spawn, so a VS Code session is invisible to + it; ``/sessions/.json`` is the only registry that has every surface. +* **Liveness is fenced, not a bare pid check.** A pid alone is not identity: pids get reused and these + records outlive their process. ``test_pid_reuse_is_not_reported_live`` is the one that matters -- + it uses a REAL running pid whose process started long after the recorded session, which is exactly + the shape of a recycled pid, and asserts the roster refuses to call it LIVE. +""" + +from __future__ import annotations + +import json +import os +import shutil +import subprocess +import time +from pathlib import Path +from typing import Any + +import pytest + +PRESENCE = Path(__file__).resolve().parents[1] / "scripts" / "coord" / "presence.ps1" + +pytestmark = pytest.mark.skipif( + shutil.which("pwsh") is None or os.name != "nt", + reason="presence.ps1 needs pwsh on Windows (Get-CimInstance / Process.StartTime)", +) + +NOW_MS = int(time.time() * 1000) + + +def _git(repo: Path, *args: str) -> None: + subprocess.run(["git", "-C", str(repo), *args], check=True, capture_output=True, text=True) + + +@pytest.fixture +def repo(tmp_path: Path) -> Path: + """A real git repo -- the script scopes presence to worktrees sharing one .git.""" + r = tmp_path / "repo" + r.mkdir() + _git(r, "init", "-q") + _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", "f.txt") + _git(r, "commit", "-qm", "init") + return r + + +@pytest.fixture +def config_root(tmp_path: Path) -> Path: + root = tmp_path / ".claude" + (root / "sessions").mkdir(parents=True) + return root + + +def write_session( + config_root: Path, + *, + pid: int, + cwd: Path | str, + session_id: str, + entrypoint: str = "claude-desktop", + started_at: int | None = None, +) -> None: + rec = { + "pid": pid, + "sessionId": session_id, + "cwd": str(cwd), + "startedAt": NOW_MS if started_at is None else started_at, + "version": "2.1.219", + "peerProtocol": 1, + "kind": "interactive", + "entrypoint": entrypoint, + "name": session_id[:8], + "nameSource": "derived", + } + (config_root / "sessions" / f"{pid}.json").write_text(json.dumps(rec), encoding="utf-8") + + +def run_presence(repo: Path, config_root: Path, *extra: str) -> list[dict[str, Any]]: + """Invoke the real script and return its JSON rows.""" + proc = subprocess.run( + [ + "pwsh", + "-NoProfile", + "-NonInteractive", + "-File", + str(PRESENCE), + "-Repo", + str(repo), + "-ConfigRoot", + str(config_root), + "-Json", + *extra, + ], + capture_output=True, + text=True, + timeout=120, + check=False, # asserted below with the stderr attached, which check=True would hide + ) + assert proc.returncode == 0, f"presence.ps1 failed: {proc.stderr}" + out = proc.stdout.strip() + return json.loads(out) if out else [] + + +def test_live_session_in_repo_is_listed(repo: Path, config_root: Path) -> None: + write_session(config_root, pid=os.getpid(), cwd=repo, session_id="aaaaaaaa-1111") + rows = run_presence(repo, config_root) + assert [r["Short"] for r in rows] == ["aaaaaaaa"] + assert rows[0]["State"] == "LIVE" + + +def test_vscode_session_is_included_and_labelled(repo: Path, config_root: Path) -> None: + """The reason this script exists: the Desktop app's session tooling cannot see these.""" + write_session( + config_root, + pid=os.getpid(), + cwd=repo, + session_id="bbbbbbbb-2222", + entrypoint="claude-vscode", + ) + rows = run_presence(repo, config_root) + assert len(rows) == 1 + assert rows[0]["Surface"] == "vscode" + assert rows[0]["State"] == "LIVE" + + +def test_pid_reuse_is_not_reported_live(repo: Path, config_root: Path) -> None: + """A real, running pid recorded against a much older session start = a recycled pid. + + This is the failure a bare `is the pid alive` check waves through, and the one Claude Code's own + registry cannot catch on this host (its `procStart` field serialises as absent, so its guard + passes unconditionally). The roster computes process start time itself and must reject this. + """ + two_days_ago = NOW_MS - (48 * 60 * 60 * 1000) + write_session( + config_root, + pid=os.getpid(), + cwd=repo, + session_id="cccccccc-3333", + started_at=two_days_ago, + ) + assert run_presence(repo, config_root) == [] # not live, so absent by default + + rows = run_presence(repo, config_root, "-All") + assert [r["State"] for r in rows] == ["STALE"] + assert "reused" in rows[0]["Detail"] + + +def test_dead_pid_is_excluded_by_default_and_shown_with_all(repo: Path, config_root: Path) -> None: + dead = _find_free_pid() + write_session(config_root, pid=dead, cwd=repo, session_id="dddddddd-4444") + assert run_presence(repo, config_root) == [] + + rows = run_presence(repo, config_root, "-All") + assert [r["State"] for r in rows] == ["DEAD"] + + +def test_session_outside_the_repo_is_ignored(repo: Path, config_root: Path, tmp_path: Path) -> None: + elsewhere = tmp_path / "not-the-repo" + elsewhere.mkdir() + write_session(config_root, pid=os.getpid(), cwd=elsewhere, session_id="eeeeeeee-5555") + assert run_presence(repo, config_root) == [] + + +def test_sibling_prefix_directory_is_not_treated_as_inside_the_repo( + repo: Path, config_root: Path +) -> None: + """`-sweep` shares a string prefix with `` but is a different tree. + + A naive StartsWith would fold every `MessageFoundry-*` sibling worktree into the primary and + report sessions as colliding in a checkout they are nowhere near. + """ + sibling = repo.parent / f"{repo.name}-sweep" + sibling.mkdir() + write_session(config_root, pid=os.getpid(), cwd=sibling, session_id="ffffffff-6666") + assert run_presence(repo, config_root) == [] + + +def test_subdirectory_cwd_still_maps_to_its_worktree(repo: Path, config_root: Path) -> None: + sub = repo / "messagefoundry" / "config" + sub.mkdir(parents=True) + write_session(config_root, pid=os.getpid(), cwd=sub, session_id="99999999-7777") + rows = run_presence(repo, config_root) + assert len(rows) == 1 + assert rows[0]["IsPrimary"] is True + + +def test_linked_worktree_session_is_scoped_to_its_own_branch( + repo: Path, config_root: Path, tmp_path: Path +) -> None: + """A sibling worktree's session must report ITS branch, not the primary's.""" + wt = tmp_path / "wt-feature" + _git(repo, "worktree", "add", "-b", "feature-x", str(wt)) + write_session(config_root, pid=os.getpid(), cwd=wt, session_id="77777777-8888") + rows = run_presence(repo, config_root) + assert len(rows) == 1 + assert rows[0]["Branch"] == "feature-x" + assert rows[0]["IsPrimary"] is False + assert rows[0]["Worktree"] == "wt-feature" + + +def test_malformed_record_does_not_break_the_roster(repo: Path, config_root: Path) -> None: + """A hook that throws on one bad file would take the whole SessionStart banner down.""" + (config_root / "sessions" / "99999.json").write_text("{not json", encoding="utf-8") + write_session(config_root, pid=os.getpid(), cwd=repo, session_id="12121212-9999") + rows = run_presence(repo, config_root) + assert [r["Short"] for r in rows] == ["12121212"] + + +def _find_free_pid() -> int: + """A pid that is not currently running -- start a process, note its pid, wait for it to exit.""" + proc = subprocess.Popen( + ["cmd", "/c", "exit"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL + ) + proc.wait(timeout=30) + time.sleep(0.3) # let the OS reap it before we claim the pid is gone + return proc.pid diff --git a/tests/test_session_context_presence.py b/tests/test_session_context_presence.py new file mode 100644 index 00000000..d05b2fc1 --- /dev/null +++ b/tests/test_session_context_presence.py @@ -0,0 +1,165 @@ +# SPDX-License-Identifier: AGPL-3.0-or-later +# Copyright (C) 2026 MessageFoundry Organization and contributors +"""The SessionStart banner (``scripts/worktree/session-context.ps1``) must degrade, never throw. + +Whatever this hook prints to stdout IS the chat's starting context. If it raises, the session opens +with a stack trace instead of its coordination rules -- and the failure is silent, because nobody +reads a hook's exit code. These tests pin the fail-safe contract around the presence roster it now +calls into: a missing, broken, or slow ``presence.ps1`` must cost you the live-session list and +nothing else. + +The scripts are copied into a throwaway tree rather than edited in place, so a test run never mutates +the repo -- these same scripts are live in every sibling session while the suite runs. +""" + +from __future__ import annotations + +import json +import os +import shutil +import subprocess +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[1] +CONTEXT = ROOT / "scripts" / "worktree" / "session-context.ps1" +PRESENCE = ROOT / "scripts" / "coord" / "presence.ps1" + +pytestmark = pytest.mark.skipif( + shutil.which("pwsh") is None or os.name != "nt", + reason="session-context.ps1 needs pwsh on Windows", +) + +# Printed unconditionally, before any worktree or presence logic runs. Its presence in stdout is the +# signal that the hook produced real context rather than dying early. +ALWAYS_PRINTED = "[MessageFoundry] This project prefers Ultracode" + + +def _git(cwd: Path, *args: str) -> None: + subprocess.run(["git", "-C", str(cwd), *args], check=True, capture_output=True, text=True) + + +@pytest.fixture +def staged(tmp_path: Path) -> Path: + """A git repo with 2+ worktrees (so the parallel-session block runs) holding script copies.""" + repo = tmp_path / "repo" + repo.mkdir() + _git(repo, "init", "-q") + _git(repo, "config", "user.email", "t@example.invalid") + _git(repo, "config", "user.name", "t") + (repo / "f.txt").write_text("x", encoding="utf-8") + _git(repo, "add", "f.txt") + _git(repo, "commit", "-qm", "init") + _git(repo, "worktree", "add", "-q", "-b", "second", str(tmp_path / "wt2")) + + (repo / "scripts" / "worktree").mkdir(parents=True) + (repo / "scripts" / "coord").mkdir(parents=True) + shutil.copy(CONTEXT, repo / "scripts" / "worktree" / "session-context.ps1") + return repo + + +def run_context(repo: Path) -> subprocess.CompletedProcess[str]: + return subprocess.run( + [ + "pwsh", + "-NoProfile", + "-NonInteractive", + "-File", + str(repo / "scripts" / "worktree" / "session-context.ps1"), + ], + cwd=str(repo), + capture_output=True, + text=True, + timeout=180, + check=False, # the whole point is that it exits 0 even when presence.ps1 fails + ) + + +def fake_presence(repo: Path, rows: list[dict[str, object]]) -> None: + """Stand in for presence.ps1 -Json, emitting exactly the shape the real script emits.""" + payload = json.dumps(rows).replace("'", "''") # '' escapes a quote in a PS single-quoted string + (repo / "scripts" / "coord" / "presence.ps1").write_text( + f"Write-Output '{payload}'\n", encoding="utf-8" + ) + + +def test_banner_survives_a_missing_presence_script(staged: Path) -> None: + """presence.ps1 absent (an older checkout, a partial clone) must not cost the banner.""" + assert not (staged / "scripts" / "coord" / "presence.ps1").exists() + proc = run_context(staged) + assert proc.returncode == 0 + assert ALWAYS_PRINTED in proc.stdout + assert "PARALLEL SESSION" in proc.stdout + assert "LIVE sessions" not in proc.stdout # degraded, not crashed + + +def test_banner_survives_a_presence_script_that_throws(staged: Path) -> None: + (staged / "scripts" / "coord" / "presence.ps1").write_text( + "throw 'presence exploded'\n", encoding="utf-8" + ) + proc = run_context(staged) + assert proc.returncode == 0 + assert ALWAYS_PRINTED in proc.stdout + assert "PARALLEL SESSION" in proc.stdout + assert "LIVE sessions" not in proc.stdout + + +def test_banner_survives_presence_emitting_junk(staged: Path) -> None: + """Malformed JSON on stdout must not take the hook down in ConvertFrom-Json.""" + (staged / "scripts" / "coord" / "presence.ps1").write_text( + "Write-Output 'not json at all'\n", encoding="utf-8" + ) + proc = run_context(staged) + assert proc.returncode == 0 + assert ALWAYS_PRINTED in proc.stdout + + +def test_live_peers_are_rendered_when_presence_reports_them(staged: Path) -> None: + """The positive arm: a peer in the shared primary on another surface must be called out. + + Without this the suite would pass with the presence block silently emitting nothing at all -- + every other test here asserts an ABSENCE, which a no-op integration also satisfies. + """ + # One foreign vscode session sitting in the shared primary -- the worst case the banner exists for. + fake_presence( + staged, + [ + { + "Short": "deadbeef", + "Surface": "vscode", + "Worktree": "primary", + "Branch": "main", + "IsSelf": False, + "IsPrimary": True, + "State": "LIVE", + } + ], + ) + proc = run_context(staged) + assert proc.returncode == 0 + assert "LIVE sessions in this repo right now (1 besides you)" in proc.stdout + assert "deadbeef" in proc.stdout + assert "vscode" in proc.stdout + assert "SHARED PRIMARY" in proc.stdout + assert "cannot be reached by" in proc.stdout # the non-desktop coordination note + + +def test_self_is_excluded_from_the_peer_count(staged: Path) -> None: + fake_presence( + staged, + [ + { + "Short": "aaaaaaaa", + "Surface": "desktop", + "Worktree": "w", + "Branch": "b", + "IsSelf": True, + "IsPrimary": False, + "State": "LIVE", + } + ], + ) + proc = run_context(staged) + assert proc.returncode == 0 + assert "LIVE sessions" not in proc.stdout # you alone is not a coordination problem From f125184021d666318009a5252664ed5868c1cbb8 Mon Sep 17 00:00:00 2001 From: wshallwshall Date: Wed, 29 Jul 2026 10:27:05 -0500 Subject: [PATCH 2/2] fix(worktree): serialize `git worktree add`; document the coordination layer `git worktree add -b ` writes .git/config (the branch's upstream), so two sessions creating worktrees at once race .git/config.lock. On Windows that surfaces as "could not lock config file .git/config: File exists" / "unable to write upstream branch configuration", leaving ORPHANED branches behind and callers that never run. Several worktrees already share this .git, so it is a live hazard rather than a theoretical one, and nothing serialized it. scripts/coord/lock.ps1 is the primitive: the same atomic exclusive-create as claim.ps1 and alloc.ps1, because a read-modify-write was measured silently losing 4 of 8 concurrent PowerShell writes. It is deliberately NOT claim.ps1 -- a claim is a long-lived advisory note about WORK, this is a short mutex around one OPERATION, and that is why this retries where claims do not. It RETRIES and NEVER STEALS. There is no reliable liveness signal on this host (the session registry has no heartbeat, and its shipped pid+procStart guard fails OPEN toward "still alive"), so breaking a lock we cannot prove abandoned would re-open the race the lock exists to close. On timeout it fails loudly, naming the holder and the manual override. A wedged lock you can see beats a silent double-write you cannot. Tests launch eight real concurrent processes and assert exactly one wins, and that the losers FAIL rather than silently proceed -- verified by mutation: swapping CreateNew for Create turns both concurrency tests red while the others stay green, so they pin the exclusion rather than the script merely running. One test takes the lock from the primary and contends from a worktree, since a lock only helps if every worktree resolves to the same file. docs/WORKTREES.md now covers presence.ps1 and this lock, including why a DEAD verdict must never by itself authorise a destructive action. --- docs/WORKTREES.md | 35 +++++++++ scripts/coord/lock.ps1 | 94 +++++++++++++++++++++++ scripts/worktree/new.ps1 | 12 +++ tests/test_coord_lock.py | 162 +++++++++++++++++++++++++++++++++++++++ 4 files changed, 303 insertions(+) create mode 100644 scripts/coord/lock.ps1 create mode 100644 tests/test_coord_lock.py diff --git a/docs/WORKTREES.md b/docs/WORKTREES.md index 2b58ecb5..32d62553 100644 --- a/docs/WORKTREES.md +++ b/docs/WORKTREES.md @@ -78,6 +78,41 @@ share this `.git`** it appends the parallel-session block: which worktree/branch full worktree list, and the shared-memory write rule above. With a single worktree it prints only the working-default line. +**Who is actually live — `presence.ps1`.** The worktree list above is the set of *checkouts*, not the +set of *sessions*: most worktrees usually have nobody in them, and the collision that matters — someone +editing the shared primary right now — is invisible from it. The banner therefore also lists **live +sessions**, from [../scripts/coord/presence.ps1](../scripts/coord/presence.ps1). Run it directly any time: + +```powershell +pwsh -NoProfile -File scripts\coord\presence.ps1 # live sessions in this repo +pwsh -NoProfile -File scripts\coord\presence.ps1 -All # include stale/dead registry entries +``` + +Two things make it worth having over the Desktop app's own session list: + +- **It sees VS Code sessions.** The Desktop app's `list_sessions` enumerates an in-memory map of + sessions *the app itself spawned*; a session launched by the VS Code extension is never entered into + it — not filtered out, never registered — so it is invisible there and cannot be messaged. Verified + against a live VS Code session sharing the **default** config root, so this is not a per-login split. + `/sessions/.json` is the only registry carrying every surface, and that is what + `presence.ps1` reads (discovering config roots dynamically, since several logins can coexist). +- **Liveness is fenced, not a pid check.** PIDs get reused and those records outlive their process, so + it compares each process's real start time against the recorded session start. Claude Code ships a + `procStart` field for exactly this, but here it serialises as absent and its guard passes + unconditionally — a bare pid check reports a recycled pid as a live session. + +It is **read-only**: a roster, not a channel. It never writes a registry file and never contacts another +session. Note the corollary of having no heartbeat anywhere on this host: a `DEAD`/`STALE` verdict is a +hint for a human, and must never by itself authorise a destructive action such as reclaiming a claim. + +**Creating a worktree is serialised.** `git worktree add -b ` writes `.git/config`, so two +sessions creating worktrees at once race `.git/config.lock` — on Windows that surfaces as `could not +lock config file .git/config: File exists`, leaving orphaned branches behind. `new.ps1` wraps that call +in a cross-session mutex ([../scripts/coord/lock.ps1](../scripts/coord/lock.ps1)), which uses the same +atomic exclusive-create as `claim.ps1`. It **retries and never steals**: on timeout it fails loudly and +names the holder, because breaking a lock you cannot prove is abandoned re-opens the very race it exists +to close — and on this host there is no reliable liveness signal to prove it with. + **Cross-session staging guard.** A `PreToolUse` hook (same `settings.json`, [../scripts/hooks/block-blanket-git-stage.ps1](../scripts/hooks/block-blanket-git-stage.ps1)) refuses blanket `git add -A`/`.`/`-u`/`--all` and `git commit -a`/`-am`/`--all` in **every** session, so even two diff --git a/scripts/coord/lock.ps1 b/scripts/coord/lock.ps1 new file mode 100644 index 00000000..0050782a --- /dev/null +++ b/scripts/coord/lock.ps1 @@ -0,0 +1,94 @@ +<# +.SYNOPSIS + A short-lived, cross-session mutex for operations that are NOT safe to run concurrently. + +.DESCRIPTION + Dot-source this and wrap the critical section: + + . "$PSScriptRoot\..\coord\lock.ps1" + $lock = Enter-CoordLock -Name "worktree-add" + try { ...the operation... } finally { Exit-CoordLock $lock } + + Same atomic test-and-set as claim.ps1 and alloc.ps1: it claims by EXCLUSIVELY CREATING a file in + /mefor-coord/locks/, and the failed create IS the mutual exclusion. A + read-modify-write on a shared list is not an option here -- PowerShell was measured silently + losing 4 of 8 concurrent writes. + + DIFFERENT FROM claim.ps1, deliberately. A claim is a long-lived note about WORK ("I am building + #105"), held for a session, advisory, and released by hand. This is a short-lived mutex around a + single OPERATION measured in seconds. That difference is why this one retries and claims do not. + + WE RETRY; WE NEVER STEAL. Breaking a lock we cannot prove is abandoned re-opens the exact race + the lock exists to close, and on this host there is no reliable liveness signal to prove it with: + the session registry has no heartbeat, and its shipped pid+procStart guard fails OPEN toward + "still alive". So on timeout this FAILS LOUDLY with the holder's identity and the manual override, + rather than quietly deciding the holder is dead. A wedged lock you can see beats a silent + double-write you cannot. + + Do not use this for anything held longer than seconds. git's own posture works because a + .lock is held for microseconds around one write, so a crash rarely lands inside it; the longer + the hold, the more likely a crash leaves a lock nobody can safely break. +#> + +# Returns the lock's path, to be passed back to Exit-CoordLock. +function Enter-CoordLock { + [CmdletBinding()] + param( + # Lock identity. One name = one mutex; unrelated operations should use different names. + [Parameter(Mandatory)][string]$Name, + # How long to wait for a sibling to finish before giving up. Sized for the operation. + [int]$TimeoutSeconds = 90, + # Repo to anchor the lock directory to. Defaults to the current repo's shared git dir, so + # every worktree AND the primary checkout resolve to the same lock. + [string]$Repo + ) + $gitArgs = @() + if ($Repo) { $gitArgs = @("-C", $Repo) } + $common = (& git @gitArgs rev-parse --path-format=absolute --git-common-dir 2>$null) + if ($LASTEXITCODE -ne 0 -or -not $common) { throw "Enter-CoordLock: not inside a git repository." } + + $dir = Join-Path $common.Trim() "mefor-coord/locks" + New-Item -ItemType Directory -Force -Path $dir | Out-Null + $safe = ($Name.Trim().ToLowerInvariant() -replace '[^a-z0-9._-]+', '-').Trim('-') + if (-not $safe) { throw "Enter-CoordLock: name '$Name' reduces to nothing usable." } + $lock = Join-Path $dir "$safe.lock" + + $deadline = (Get-Date).AddSeconds($TimeoutSeconds) + while ($true) { + try { + $fs = [System.IO.File]::Open( + $lock, + [System.IO.FileMode]::CreateNew, + [System.IO.FileAccess]::Write, + [System.IO.FileShare]::None) + try { + # Recorded so a wedged lock names its holder instead of being an anonymous mystery. + $who = [System.Text.Encoding]::UTF8.GetBytes( + "pid=$PID host=$env:COMPUTERNAME at=$((Get-Date).ToString('o'))") + $fs.Write($who, 0, $who.Length) + } finally { $fs.Dispose() } + return $lock + } catch [System.IO.IOException] { + if ((Get-Date) -gt $deadline) { + $held = "(unreadable)" + try { $held = (Get-Content -LiteralPath $lock -Raw -EA Stop).Trim() } catch { } + throw ( + "Timed out after ${TimeoutSeconds}s waiting for the '$safe' lock.`n" + + " held by: $held`n" + + " NOT stealing it -- there is no reliable way to prove that session is gone, and`n" + + " breaking the lock re-opens the race it exists to prevent.`n" + + " If you are certain that session is dead, delete it by hand:`n" + + " Remove-Item -LiteralPath '$lock'") + } + Start-Sleep -Milliseconds 200 + } + } +} + +function Exit-CoordLock { + [CmdletBinding()] + param([Parameter(Mandatory)][string]$LockPath) + # Best-effort: a failure to release must never mask the real error from the critical section, + # which is usually why we are unwinding in the first place. + Remove-Item -LiteralPath $LockPath -Force -ErrorAction SilentlyContinue +} diff --git a/scripts/worktree/new.ps1 b/scripts/worktree/new.ps1 index 748e9475..5e06d62f 100644 --- a/scripts/worktree/new.ps1 +++ b/scripts/worktree/new.ps1 @@ -57,6 +57,17 @@ if ($LASTEXITCODE -ne 0) { # Reuse an existing branch if it's there, else create it from -Base. $branchExists = & git -C $RepoRoot branch --list $Name Write-Host "Creating worktree '$WorktreePath' on branch '$Name'..." + +# SERIALIZED ACROSS SESSIONS. `git worktree add -b ` writes .git/config (the new +# branch's upstream), and concurrent adds race .git/config.lock. Reported and reproduced on Windows: +# parallel adds against one common .git fail with "could not lock config file .git/config: File +# exists" / "unable to write upstream branch configuration", leaving ORPHANED branches behind and +# callers that never run. Several worktrees already share this .git, so this is a live hazard, not a +# theoretical one. 90s is generous for an operation that takes seconds -- if we wait that long, +# something is genuinely wrong and the throw is the right outcome. +. "$PSScriptRoot\..\coord\lock.ps1" +$addLock = Enter-CoordLock -Name "worktree-add" -TimeoutSeconds 90 -Repo $RepoRoot +try { if ($branchExists) { & git -C $RepoRoot worktree add $WorktreePath $Name } else { @@ -74,6 +85,7 @@ if ($branchExists) { } & git -C $RepoRoot worktree add $WorktreePath -b $Name $Base } +} finally { Exit-CoordLock $addLock } if ($LASTEXITCODE -ne 0) { throw "git worktree add failed (exit $LASTEXITCODE)" } # Record this worktree's HOME branch in its PRIVATE git dir (/.git/worktrees//), so the diff --git a/tests/test_coord_lock.py b/tests/test_coord_lock.py new file mode 100644 index 00000000..76f21a54 --- /dev/null +++ b/tests/test_coord_lock.py @@ -0,0 +1,162 @@ +# SPDX-License-Identifier: AGPL-3.0-or-later +# Copyright (C) 2026 MessageFoundry Organization and contributors +"""Tests for the cross-session operation mutex (``scripts/coord/lock.ps1``). + +``git worktree add -b `` writes ``.git/config``, so two sessions creating worktrees at +once race ``.git/config.lock`` -- reproduced on Windows as "could not lock config file", leaving +orphaned branches behind. ``new.ps1`` now serializes that call through this lock. + +The load-bearing test is ``test_only_one_of_eight_concurrent_claimants_wins``: it launches eight real +processes at once and asserts exactly one acquires. Anything less than genuine concurrency would pass +against a lock that does not lock at all -- which is the failure mode this file exists to exclude. +The number is not arbitrary: a read-modify-write in this same codebase was measured silently losing 4 +of 8 concurrent PowerShell writes, so eight is the shape already known to break the naive approach. +""" + +from __future__ import annotations + +import os +import shutil +import subprocess +from concurrent.futures import ThreadPoolExecutor +from pathlib import Path + +import pytest + +LOCK = Path(__file__).resolve().parents[1] / "scripts" / "coord" / "lock.ps1" + +pytestmark = pytest.mark.skipif( + shutil.which("pwsh") is None or os.name != "nt", + reason="lock.ps1 needs pwsh on Windows", +) + + +def _git(cwd: Path, *args: str) -> None: + subprocess.run(["git", "-C", str(cwd), *args], check=True, capture_output=True, text=True) + + +@pytest.fixture +def repo(tmp_path: Path) -> Path: + r = tmp_path / "repo" + r.mkdir() + _git(r, "init", "-q") + _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", "f.txt") + _git(r, "commit", "-qm", "init") + return r + + +def acquire( + repo: Path, *, name: str = "t", timeout: int = 2, hold_ms: int = 0 +) -> subprocess.CompletedProcess[str]: + """Take the lock, optionally hold it, release. Prints ACQUIRED on success.""" + script = ( + f". '{LOCK}'; " + f"$l = Enter-CoordLock -Name '{name}' -TimeoutSeconds {timeout} -Repo '{repo}'; " + f"Write-Output 'ACQUIRED'; " + f"Start-Sleep -Milliseconds {hold_ms}; " + f"Exit-CoordLock $l" + ) + return subprocess.run( + ["pwsh", "-NoProfile", "-NonInteractive", "-Command", script], + capture_output=True, + text=True, + timeout=180, + check=False, + ) + + +def test_lock_can_be_taken_and_released(repo: Path) -> None: + first = acquire(repo) + assert "ACQUIRED" in first.stdout, first.stderr + # Released, so an immediate second attempt must succeed -- a lock that never frees is a wedge. + second = acquire(repo) + assert "ACQUIRED" in second.stdout, second.stderr + + +def test_only_one_of_eight_concurrent_claimants_wins(repo: Path) -> None: + """Eight real processes, one lock, a hold long enough that the overlap is genuine.""" + with ThreadPoolExecutor(max_workers=8) as pool: + results = list(pool.map(lambda _: acquire(repo, timeout=1, hold_ms=1500), range(8))) + + winners = [r for r in results if "ACQUIRED" in r.stdout] + assert len(winners) == 1, f"expected exactly 1 winner, got {len(winners)}" + # Everyone else must FAIL, not silently proceed. Silent success is the bug. + assert all("Timed out" in r.stderr for r in results if "ACQUIRED" not in r.stdout) + + +def test_timeout_refuses_to_steal_and_names_the_holder(repo: Path) -> None: + """On timeout it must fail loudly with the holder's identity -- never break the lock.""" + lock_dir = ( + Path( + subprocess.run( + ["git", "-C", str(repo), "rev-parse", "--path-format=absolute", "--git-common-dir"], + capture_output=True, + text=True, + check=True, + ).stdout.strip() + ) + / "mefor-coord" + / "locks" + ) + lock_dir.mkdir(parents=True, exist_ok=True) + held = lock_dir / "t.lock" + held.write_text("pid=999999 host=OTHERBOX at=2026-07-29T00:00:00.0000000Z", encoding="utf-8") + + proc = acquire(repo, timeout=1) + assert "ACQUIRED" not in proc.stdout + assert "Timed out" in proc.stderr + assert "pid=999999" in proc.stderr # names the holder + assert "NOT stealing" in proc.stderr + assert held.exists(), "the lock must survive a timeout -- stealing re-opens the race" + + +def test_distinct_names_do_not_block_each_other(repo: Path) -> None: + """One mutex per operation: an unrelated lock must not serialize against this one.""" + lock_dir = ( + Path( + subprocess.run( + ["git", "-C", str(repo), "rev-parse", "--path-format=absolute", "--git-common-dir"], + capture_output=True, + text=True, + check=True, + ).stdout.strip() + ) + / "mefor-coord" + / "locks" + ) + lock_dir.mkdir(parents=True, exist_ok=True) + (lock_dir / "other.lock").write_text("pid=1", encoding="utf-8") + + proc = acquire(repo, name="t", timeout=2) + assert "ACQUIRED" in proc.stdout, proc.stderr + + +def test_lock_is_shared_between_the_primary_and_its_worktrees(repo: Path, tmp_path: Path) -> None: + """A lock only helps if every worktree resolves to the SAME file as the primary checkout. + + This is what makes it usable for `git worktree add`, whose damage is to the one shared .git. + """ + wt = tmp_path / "wt" + _git(repo, "worktree", "add", "-q", "-b", "wt-branch", str(wt)) + + hold_script = ( + f". '{LOCK}'; $l = Enter-CoordLock -Name 'shared' -TimeoutSeconds 5 -Repo '{repo}'; " + f"Write-Output 'ACQUIRED'; Start-Sleep -Milliseconds 2500; Exit-CoordLock $l" + ) + holder = subprocess.Popen( # take it from the primary and hold + ["pwsh", "-NoProfile", "-NonInteractive", "-Command", hold_script], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + ) + try: + assert holder.stdout is not None + assert "ACQUIRED" in holder.stdout.readline() # only proceed once it truly holds the lock + contender = acquire(wt, name="shared", timeout=1) # from the WORKTREE + assert "ACQUIRED" not in contender.stdout, "worktree resolved to a different lock file" + assert "Timed out" in contender.stderr + finally: + holder.wait(timeout=60)