Serializing Concurrent Gates Behind a Per-Repo Lock
One gate at a time per repository, so overlapping runs stop failing each other
Gantry proves a run's work by running the project's test suite itself — the gate — rather than trusting an agent's word. But every worktree of a repository shares one underlying checkout, so when two gantry runs of the same repository had their gates overlap in time, one gate could observe the other's half-finished tree and report a failure that was not real. A run would die for a collision that existed only because two gates happened to run at once.
This job made gates take turns. It added a lock scoped to the repository — shared across all of that repository's worktrees and every concurrent run of it, while runs of unrelated repositories never wait on each other — so only one gate subprocess runs at a time. The lock is held by the operating system on a file, which means a process that is killed, panics, or loses power mid-gate frees it instantly instead of wedging every run that follows. A run parked behind another's gate shows a plain "waiting" state rather than appearing to hang. Alongside the lock, the job fixed a related defect from the same incident: when the merge-resolve gate failed, its log was being saved empty, so the failure could not be read back from gantry's own records.
Build
The split that made this agent-sized was building the lock as a self-contained primitive with no gantry types, then wiring it into real gates second — and a review between the two rewrote the wiring sprint's contract.
The work divided into a primitive and its integration. The first piece built the lock alone: per-repo key derivation, an advisory file lock, a non-blocking try-acquire, an interruptible blocking acquire, and a guard that releases on every exit path. Its brief was explicit that no call sites would be rewired yet. That boundary is what let the hard guarantees be proven in isolation — mutual exclusion demonstrated across real operating-system processes, and crash auto-release shown by killing a holder and re-acquiring — before any of it touched the build loop. The primitive stayed free of gantry's event types by taking generic hooks: an abort predicate and a one-shot on-wait callback the caller would later supply.
The stress landed exactly at that boundary. After the primitive passed its gate and its review, the run re-planned the integration sprint — a commit recorded as refining that sprint's spec from the first sprint's review. The primitive as built did not expose the shape the original integration brief had assumed: it signalled the start of a wait through a single on-wait hook that fires only when the lock is genuinely contended, and it signalled acquisition simply by returning the guard, with no separate on-acquire callback. The review caught the mismatch and rewrote the integration brief to bind to the interface that actually existed — telling it to emit the "waiting" event from the on-wait hook and the clear event from the call site once the guard returns, keyed on whether the wait ever happened. That re-plan is the whole story of this cut: the difficulty was never in making code pass a test — no gate ever went red — it was in the contract between the two pieces, and the review is where that contract was corrected before the second agent ran against a wrong assumption.
The integration sprint then routed every gate call site through one chokepoint so the lock could not be bypassed by construction, held it for the gate subprocess only, and wired in gantry's real abort latch and event stream. The third sprint — the empty-log fix — was independent by design and could land in any order; it stayed green and did not disturb the locking wiring.
Feature
Gantry's gate execution now flows through a single locked chokepoint keyed on the repository's shared git directory, so concurrent runs queue their gates instead of corrupting each other's results — and the lock shape has since been reused for other locks.
Before this job, nothing stopped two runs of one repository from executing their gates at the same moment against a shared checkout, and a gate that read a tree mid-write would fail for a conflict that was not there. A merge-resolve gate that failed compounded the problem by leaving a zero-byte log, which forced an operator to reproduce the failure by hand.
The answer that stands today is a lock keyed on the repository's shared git common directory — the one location every worktree links back to — resolved with `git rev-parse --git-common-dir` and held as an `flock` on a file in that stable root, never inside a per-run directory. Because it is a kernel advisory lock, a dead holder releases it immediately. A guard owns the open file and drops the lock on every return path, and the critical section is deliberately just the gate subprocess: the code takes the lock immediately before spawning the gate and clears any waiting state the moment it holds it, so the lock is never held across an agent stage. A `GateLockWaiting` run event carries the blocked state to both the terminal interface and the headless log, cleared once the gate starts.
Standing in the tree at today's HEAD, the primitive is intact and heavily documented in its own module, and the chokepoint call lives in the gate-execution path where every gate — baseline, per-sprint, and merge-resolve — passes through it. The waiting event is present across the domain model, the history log, the terminal state, and the headless front-end. Two kinds of drift are worth naming plainly. The original call site in an engine build module is gone: that file no longer exists, and the locking call was carried into the gate module as the engine was reorganized, along with the third sprint's log-capture change, which had landed in that same removed file. And the primitive's shape outlived its first use — later locks in the tree are documented as proving themselves in isolation the same way this per-repo gate lock did, so the pattern this job established was adopted rather than left as a one-off.