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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions docs/WORKTREES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
`<config-root>/sessions/<pid>.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 <name> <base>` 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
Expand Down
94 changes: 94 additions & 0 deletions scripts/coord/lock.ps1
Original file line number Diff line number Diff line change
@@ -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
<git-common-dir>/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
}
Loading
Loading