feat: queue concurrent local:gate runs on a machine-wide lease (#2339)

`npm run local:gate` now runs its stages under `scripts/gate-lease.mjs`, a
`proper-lockfile` lease under `os.tmpdir()` (or `$XDG_RUNTIME_DIR`), so a gate
started in a second worktree waits for the first instead of overlapping it.

Measured on v2/main @ aa56551b: a quiet gate is 257s green; two started
together had one fail at 279s (`smoke:web:chromium` found port 6298 held by
the other gate's smoke) and the survivor take 338s. Under the lease, three
gates started within seconds ran back to back (259s, then 262s, then the
third), each green with zero timeouts.

The lease grants rather than polls a threshold, so two waiters cannot
deadlock on each other; a holder that dies stops refreshing and is taken over
after 30s; a signal to the wrapper stops the whole process group, releases,
and exits 128+signal; the wait names the holder's pid and worktree and gives
up after 45 minutes. `INSPECTOR_SKIP_GATE_LEASE=1` bypasses it. A lock that
cannot be created runs the gate unleased with a warning.

`scriptChainRuns` looks through the wrapper (and only it) so the existing
vouches about what `local:gate` runs keep reading the real chain.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PsmUAZ6aLUAFqenjJKSSRp
Signed-off-by: cliffhall <cliff@futurescale.com>
This commit is contained in:
cliffhall 2026-09-12 22:00:33 -04:00
commit f99402918d

View file

@ -148,9 +148,37 @@ Vite's `fs.allow`. Do a real `npm install` in the worktree.
### Everything times out at once
⚠️ Two concurrent `npm run local:gate` runs starve each other — ~326 tests time
out at 5s. Run one at a time. (A `pgrep -f "npm run local:gate"` wait loop
matches _itself_ and never exits.)
⚠️ Two `npm run local:gate` runs on one machine starve each other — under four
worktrees ~326 tests timed out at 5s (#2323), and even two collide
deterministically on the web smokes' fixed ports. Since #2339 the gate takes a
**machine-wide lease**, so a second run queues rather than overlapping; if
everything is still timing out at once, look for what is _not_ the gate: a
bypassed lease (`INSPECTOR_SKIP_GATE_LEASE` set in that shell), a bare
`npm run coverage` or `test:storybook` in another session, or Spotlight
indexing a fresh `node_modules` (a `mdworker` storm after `npm install` in a
new worktree pushed the load average to 20 for ten minutes). Do not write a
"wait until the machine is clear" loop — two of them deadlock on each other,
and a `pgrep -f "npm run local:gate"` loop matches _itself_ and never exits.
### Waiting on the lease
A gate that starts with
```
gate-lease: pid 12345 in /Users/you/Projects/mcp-inspector-wt-1, running for 2m10s holds the gate lease; waiting …
```
is queued behind another worktree's gate, and will start the moment it
releases (it re-checks every 2s and prints `still waiting` once a minute). The
holder's pid and worktree are in the line, so you can decide whether to wait
or to stop that gate. A holder that was **killed** — a closed terminal, an
OOM'd session — stops refreshing its lock and is taken over after 30s; nothing
needs cleaning up by hand. The wait gives up after 45 minutes, naming the
holder, which only ever happens against a live gate that has hung.
`INSPECTOR_SKIP_GATE_LEASE=1 npm run local:gate` runs without the lease. It is
for a measurement that needs contention; it does not get a result sooner,
because the queued run finishes before an overlapped one would.
## Local-only steps