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/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/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/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_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) 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