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

@ -30,7 +30,7 @@ import {
ciRunsUnconditionally,
runsCommand,
} from "./verify-skills.mjs";
import { scriptChainRuns } from "./lib/npm-scripts.mjs";
import { GATE_LEASE_WRAPPER, scriptChainRuns } from "./lib/npm-scripts.mjs";
const SCRIPT = path.join(
path.dirname(fileURLToPath(import.meta.url)),
@ -491,6 +491,43 @@ test("scriptChainRuns follows the chain to a real invocation", () => {
);
});
test("scriptChainRuns looks through the local:gate lease wrapper, and only it", () => {
// `local:gate` runs its stages under `scripts/gate-lease.mjs` (#2339). The
// wrapper runs exactly the `npm run …` it is handed, so a vouch sees past
// it; a wrapper carrying flags, or any other wrapper, still hides the chain.
const T = "verify:skills:cli";
const stages = `npm run validate && npm run ${T}`;
assert.equal(
scriptChainRuns(
{
"local:gate": `${GATE_LEASE_WRAPPER}npm run local:gate:stages`,
"local:gate:stages": stages,
},
"local:gate",
T,
),
true,
);
assert.equal(
scriptChainRuns(
{ "local:gate": `${GATE_LEASE_WRAPPER}npm run ${T}` },
"local:gate",
T,
),
true,
);
for (const wrapped of [
`node scripts/gate-lease.mjs --quiet npm run ${T}`,
`node scripts/other-wrapper.mjs npm run ${T}`,
]) {
assert.equal(
scriptChainRuns({ "local:gate": wrapped }, "local:gate", T),
false,
wrapped,
);
}
});
test("scriptChainRuns is not satisfied by a mention or a longer name", () => {
// `reachableScripts` extracts any `npm run …` substring, so both of these
// passed the old check while the validator never ran.