The gate
The repository's pass/fail command, and how a seat is held to it when it stops with uncommitted work.
repo.gate is the repository's pass/fail command. A role with gate_on_stop: true gets a Stop hook in
its generated settings (lib/stop_gate.sh): whenever the seat ends a turn with uncommitted changes in
its working tree, the hook runs the gate there, and if it fails the seat is sent back to work with the
command, the last 60 lines of its output and the path of the full log (.state/<team>/gate/<role>.log).
Roles without the switch never run it. The example gives it to the engineers and qa.
How it behaves
- Only uncommitted work is gated at a stop: a clean tree, including committed but unpushed work, ends
the turn normally. Committed work is gated again when the reviewer runs the gate on the PR. A
git statusthat fails (a corrupt index, say) counts as uncommitted work: the gate runs, and a send-back says status failed. The hook's git calls do not run a repository's fsmonitor program; clean filters stay on, because status needs them for a correct answer (#104). - Only the seat's configured working tree is checked. Work left in another tree, such as a worktree
Claude Code made for a subagent or for
EnterWorktreeunder<repo>/.claude/worktrees/, is not gated. - The gate catches mistakes, not a seat that edits the gate. It runs the scripts in the seat's own tree (a Makefile, test scripts), at the stop and outside Claude Code's permission rules: no prompt, no deny or ask rule, no auto-mode classifier. A seat that changes those files decides whether its gate passes and what runs at its next stop, so for a seat that is not in bypass mode, permission to edit its tree becomes commands that run unprompted at the stop. The reviewer's own run of the gate on the PR stays the real check.
- A seat also stops on purpose: to wait for an answer or a review, or with a test red before the fix.
After
repo.gate_max_blocks(default 3) failed gates in a row in one turn, the next stop in that turn goes through without running the gate again, with a note that the gate is still failing. Every send-back tells the seat that, if it is stopping on purpose, it should say so in its report, and gives the limit (this moved here from the shared prompt, #117; the let-through's note is shown to the user, not to the seat). Each new turn starts from zero, so a seat that lives for days is never gated off for good. The count is in the state folder, which seats cannot edit through Claude Code's tools; a passing gate or a clean tree resets it. A script can still write it, or run the hook with made-up input, like every other "a script can do it" limit under Safety. Run by hand with no hook input,lib/stop_gate.shruns the gate and reports but leaves the count alone. Claude Code also ends such a loop itself after 8 continuations with no tool call in between (raise it withCLAUDE_CODE_STOP_HOOK_BLOCK_CAP). - The hook never writes or reads its state through a link (#194). A symlink, a
hard link, or anything other than a folder or a regular file, at
.state/<team>/gate/or at the role's log or count in it, is refused: no gate runs and nothing is written. The seat is sent back, told to report it; in a turn the hook has already sent back, the stop goes through with a note naming it. Only a seat could put one there; do not delete the file (it may be the real one, with a second name made elsewhere): remove the other name, or move this file aside. - The gate runs with a time limit (
repo.gate_timeout, default 600 s) in a process group of its own, which is killed whole when the time is up. A program that detaches from that group (a daemon, a tmux server) is not killed. - The hook inherits the seat's environment, which runs inside tmux: a gate that must not touch the
team's tmux server unsets
TMUXitself, as this repository's does. - The gate runs at every such stop, so a slow gate (this repository's e2e takes over a minute) makes those stops slow.
- A gated seat's settings set
disableAllHooks: false, so a.claude/settings.jsonin the project cannot switch the gate off. That also turns the user's own hooks back on for that seat if~/.claude/settings.jsonhad switched them all off. - The stop gate does not take the run-exclusive lock yet
(#236). A wait for it would count against
repo.gate_timeoutand come back as a failing gate, sending the seat back for a failure that is not real. The same holds for a gate command that takes the lock itself, as this repository'stest/e2e.shdoes (#309): while another heavy run holds the lock, such a gate waits inside its time limit.
One heavy run at a time
Every seat running a full gate or test run at once loads a machine until timing tests fail for no
other reason (#214). fabrikito run-exclusive -- <command> [args...] runs one such
command at a time across every seat of every team on the host, and the owner's shell:
./fabrikito run-exclusive -- make check
This repository's own test/e2e.sh needs no run-exclusive in front: it takes the lock itself
(Testing). With one in front, that outer run-exclusive waits its turn like any other run, and the step inside then
does not wait a second time.
- It waits, and never fails for a busy lock. It prints who holds the lock (role, team file, pid, folder), since when and the command, then a reminder every 5 minutes, and "got the lock" when its turn comes. Ctrl-C while waiting exits 130 without running anything.
- The command is an argument list, not a shell string (write a pipeline as
bash -c '...'). It runs in the caller's process group, so a pager or a prompt works at a terminal and Ctrl-C reaches it. The exit status is the command's, or 128 plus the signal that ended it. It needs no team file, so it works from any checkout. - First come, first served (#283). Waiting runs get the lock in the order they
arrived, so a run that starts later never passes one that is already waiting (a merge gate is not
starved by later runs). Each waiting run takes a ticket, a file named by its arrival time in a
folder next to the lock (
run-exclusive.lock.queue/), and keeps it locked while it waits; only the earliest live ticket tries the lock, every 2 seconds. A run that ends while waiting (Ctrl-C, TERM) gives up its place at once. One killed with SIGKILL loses it as soon as the next poll finds its ticket unlocked (within 2 seconds): the system drops the lock with the process, so a killed run leaves nothing that blocks the ones behind it or a later run. The waiting message says how many runs are ahead. The folder is removed when the last waiting run leaves. A run started before this was added does not take a ticket and can still pass the queue, until it ends. - The lock is one file per user,
~/Library/Application Support/fabrikito/run-exclusive.lock, outside every team's state and checkout. Teams on one machine share the user, so they share the lock.FABRIKITO_RUN_LOCKnames another file, by an absolute path (the e2e uses its own). The folder is made owner-only when it is created, and a lock file or folder that is a symlink, or a lock file that is a hard link, is refused. Never remove the lock file while a run holds it: a run that starts after that does not wait for the one holding it, and the holder can only say so when it ends. If the holder record next to the lock (run-exclusive.lock.holder) cannot be written (a full disk), nothing runs: the run exits 1 and says why, since a nested run could not otherwise see that its parent holds the lock. - The lock ends with the command. It is released when the command ends or when its run is killed:
- A kill of the caller's process group (the stop gate's time limit, or closing the terminal) reaches the run and the command alike.
- TERM, INT or HUP sent to the run alone is passed on to the command, and continues it if it had stopped. A signal sent to the whole group therefore reaches the command twice: a command that cleans up on a signal should not let the second cut that short.
- A run killed with SIGKILL has its lock dropped by the system. If the SIGKILL went to the run alone, its command is not stopped and can overlap the next run.
- A stopped run keeps the lock: one suspended with Ctrl-Z, or a background job (
... &) whose command touched the terminal. Usefgorkill %1in its shell, or close that terminal. From another shell, a stopped run acts on TERM only once continued:kill -TERM <pid>; kill -CONT <pid>, with the pid the waiting message shows. A stopped run that is waiting does not hold up the line: a waiting run touches its ticket every 2 seconds, and one untouched for 10 seconds is dropped from the queue. When it is continued it takes a new place at the end. - The lock is never handed to the command, so a daemon it leaves running (a tmux server) does not hold it.
- No allow rule for it.
fabrikitoadds none, and a team file should not: Claude Code's allow rules match the start of a command, so a rule forrun-exclusivewould also allow whatever command it wraps. - Nested runs (a gate script that calls
run-exclusiveagain) run at once instead of waiting for themselves, but only while the run that holds the lock is alive. A program left running from an earlier run keeps its variable, and that alone does not skip the lock. - The old names (#273). Before the rename to fabrikito the lock folder was
~/Library/Application Support/software-factory/and the variables wereFACTORY_RUN_LOCKandFACTORY_RUN_EXCLUSIVE_HELD. For one release both still work, so that a checkout that has not been updated and one that has still exclude each other and nest: - The old variables are read when the new ones are not set (the new one wins when both are). The old
FACTORY_RUN_EXCLUSIVE_HELDof a parent run is accepted as the new one is. - On the default lock, a run also takes the old file (
software-factory/run-exclusive.lock) once it has the new one, and writes the old holder record and sets the old variable for its command. A run of an older checkout, which knows only the old file, then waits for it, and it waits for that run. The old file has the older checkout's queue beside it, and the run takes a place in it as a run of that checkout does: nobody is passed in the old queue. Its place there dates from the moment it got the new lock, not from its arrival, so runs of an older checkout that came in meanwhile go first. While it waits there, its messages name the old-name lock and who holds it. - While it waits there it already holds the new lock, and its holder record is written, so a run of this version that arrives is told who it waits for. A run stopped at that point (Ctrl-Z) therefore keeps the new lock, and runs of this version wait for it, where a stopped run that only waits in a queue holds nobody up.
- A lock named by a variable is one file for both versions only when the old variable names it, or
both do. An older checkout does not read
FABRIKITO_RUN_LOCK: with only that one set it stays on the old default lock, and the two versions no longer exclude each other. While checkouts of both ages are in use, set both variables to the same path. - Migration: nothing to do. The first run creates
fabrikito/beside the old folder. The old folder~/Library/Application Support/software-factory/is made again by every run of this version on the default lock for as long as the fallback exists, so deleting it has to wait for the release after this one, which also removes the old variables, the old file and this paragraph.