fgate
The fair gate: a build gate for coding agents that share one machine.
Run five or ten coding agents (Claude Code, or anything else with a shell) on one machine and sooner or later
they all start cargo build at once. Memory fills up, the machine freezes, and builds die with "out of memory".
fgate makes them take turns. Each heavy command waits in a fair line for a free slot, then runs unchanged at low
priority, within a memory and CPU ceiling. fgate watch shows the whole thing live. It runs on Windows, Linux and
macOS.

fgate cargo test -p my_crate waits for a build slot, then runs the command unchanged
fgate --gpu my_game.exe --shots waits for the GPU slot (games, renderers, screenshot scripts)
fgate --now cargo publish skips the line: small commands, within a small memory ceiling
fgate --solo my_game.exe --bench alone on the machine: a performance measurement, while nothing else runs
fgate status who holds the slots, who is waiting
fgate watch the same, live: memory, CPU, each job's usage and output, recent runs
fgate peek [name] a running job's output as it comes, or how a finished run ended
fgate hook [options] an agent hook (Claude Code, Copilot) that refuses cargo and C++ builds without fgate
Install
fgate runs on Windows 10/11, Linux and macOS, and needs Rust 1.87 or newer.
That puts fgate in ~/.cargo/bin. One install serves every project and every agent on the machine.
Using it
Put fgate in front of any command that compiles or runs something heavy:
CARGO_TARGET_DIR=/shared/target
A waiting command prints its place in line and who is running, and tells you again whenever that changes:
fgate: waiting for a build slot: #2 in line; running: smelter-2: cargo build --release (1m40s)
fgate: build slot 0 after 2m05s in line — cargo test --workspace
...
fgate: done in 1m12s, peak memory 6.9 GB
Small commands: --now
Waiting ten minutes behind three builds to run cargo fmt, a publish or a quick tool is silly. fgate --now
runs a small command at once, without waiting in line, but it can't be used to slip a build past the queue:
- It runs within a small memory ceiling,
now_mem_gb(2 GB). A command that grows past it fails with "out of memory", and fgate says it is too big to skip the line and should run again without--now. - It still waits while free memory is below
min_free_gband something else is running, like any second command. - At most
now_slots(4) run at once.statusandwatchlist them undernowwhile they run.
The command's own output and exit code pass through untouched. A gated command that itself runs fgate …
(a script calling cargo, say) doesn't queue a second time: it already holds the slot. fgate --version prints the
version, and an option fgate doesn't know is refused at once rather than run as a program.
Agents name their place by their folder, or by their worktree for checkouts under .claude/worktrees/<name>,
so the status shows spaceport-sp1: cargo build rather than a process id.
Measuring performance: --solo
Average fps over a minute means nothing while three other agents build beside the game. fgate --solo gives one run
the machine to itself:
fgate: waiting for the machine to be quiet for a solo run, nothing new starts meanwhile; running: build: smelter-2: cargo build --release (1m40s)
fgate: solo slot 0 after 1m52s in line — my_game.exe --bench 60
fgate: alone on the machine: no other gated job starts until this ends or 10 minutes pass (solo_minutes); normal priority, no CPU ceiling (other programs, such as agents themselves, still run)
- From the moment it asks, nothing new starts: builds, GPU runs and
--nowcommands wait, so a busy line can't keep it waiting for ever. The jobs that are running finish first; fgate doesn't pause or end them. - It runs at normal priority, without the CPU ceiling, which would spoil what it measures. The memory ceiling stays.
- After
solo_minutes(10) it hands the machine back: it goes on, but fgate says that others may start again, so what it measures from then on is no longer alone. A game that hangs can't hold up every agent.solo_minutes = 0never hands it back. - Only gated commands wait. The agents themselves, an editor, or a build started without fgate keep running.
Keep solo runs short, and never use them for builds: every other agent waits while one runs.
Seeing what a job is doing: fgate peek
An agent's command writes its output into the agent's shell, where nobody else sees it. When fgate's own output goes
to a pipe or a file like that, fgate passes the command's output on unchanged and keeps its last 200 lines in its
folder. fgate watch shows each running job's newest line under it, and fgate peek follows one:
$ fgate peek geothermal
── geothermal · build slot 0 · cargo test --workspace -j 4 · running 1m02s
Compiling terrain v0.3.0 (E:\code\game\.claude\worktrees\geothermal\crates\terrain)
Finished `test` profile [unoptimized + debuginfo] target(s) in 58.40s
Running unittests src\lib.rs (target\debug\deps\terrain-6c0f3d5e8a1b2c47.exe)
test erosion::keeps_rivers ... FAILED
...
── ended after 1m08s · exit 101
The name is the checkout's name, as watch shows it; fgate peek gpu 1 picks a slot, and fgate peek alone the only
running job. After a run ends, its last 40 lines stay with the last 50 runs, so fgate peek geothermal still shows how
it ended.
Output that goes to a terminal is left alone, with its colours and progress bars: whoever sits there sees it already.
Lines come from both streams in the order they came, without colour codes. A Python script's output shows as it is
printed, because fgate sets PYTHONUNBUFFERED=1 unless the command sets its own. capture = 0 turns it off.
Checkouts sharing one target directory
Agents often work in git worktrees, one checkout each, and build into one shared CARGO_TARGET_DIR so the
dependencies are compiled once. That is safe for the dependencies, but not for the workspace's own crates: cargo
names their build output by their path relative to the workspace, so every checkout writes to the same files, and it
judges freshness by file times. When another checkout built more recently, your sources look older than its build
and cargo reuses it: your program runs another checkout's code, or fails to compile against its API.
So before a compiling cargo command (build, check, test, run, clippy, doc, bench, rustc) into a
target directory set by CARGO_TARGET_DIR or --target-dir, fgate looks at .fgate-checkouts in that directory:
who built this profile last (debug, release, a custom profile, per target triple). When it was another checkout,
or nobody is recorded yet, fgate first cleans this workspace's own packages for that profile (cargo clean -p
<member> …; the dependencies stay) and records this checkout:
fgate: release in E:\code\game\target was last built by another checkout (E:\code\game\.claude\worktrees\a): cleaning
this workspace's 8 package(s) there first, so cargo doesn't reuse them (switch_clean)
That costs a rebuild of the workspace's crates whenever checkouts take turns, never a rebuild of the dependencies.
A build into the workspace's own target/ is left alone. switch_clean = 0 turns it off.
That clean also deletes the executables a checkout has just built, as soon as another checkout's build gets the slot, often before the first agent has run or copied them. So after a build succeeds, while it still holds the slot, fgate copies the workspace's executables (its bin targets) into the checkout itself, where no other checkout's build touches them:
fgate: kept game.exe, game_tools.exe (unchanged) in E:\code\game\.claude\worktrees\a\target\fgate-bin\debug: run it
from there, the shared target's copy goes with the next checkout's build (keep_bins)
It copies only when the record says this checkout built that profile last, so a kept executable is always the
checkout's own code, and only what changed since the last copy. A kept executable that is running is renamed out of
the way, which Windows allows, rather than overwritten. keep_bins = 0 turns it off.
The clean, the build and the keep go together: with two build slots, another checkout's clean could otherwise run
between this build and its keep, or between its own clean and build. So a build into a shared target holds a lock
from its clean until its keep, one per profile folder: .fgate-lock-debug for build, check, clippy, test and
doc, .fgate-lock-release for --release and bench, one per custom profile. These are the folders cargo itself
locks while it builds, so a dev build and a release build into one target run side by side, and two dev builds never
could. A cargo install or a verifying cargo publish into a shared target takes the lock of the folder it builds in
too, and so does every build into a shared target when switch_clean and keep_bins are off.
A build takes its lock together with its slot, never one without the other. While another build holds its folder's
lock, it keeps its place in line without a slot, and the jobs behind it that need another folder, or none (a release
build, a script, a C++ build, a cargo install into its own target), go past it. Once the folder is free, it goes
first again. A second dev build no longer sits in a slot, blocked in cargo, while the line waits behind it:
fgate: waiting for another build into E:\code\game\target\debug to finish: spaceport: cargo test --workspace (2m10s); #1 in line, the jobs behind that build elsewhere go ahead meanwhile
fgate status marks such a waiter: build line #1: … — waits for another build into E:\code\game\target\debug, later
jobs go past it. A script that runs cargo itself (below) can't say beforehand which folder it builds in, so it takes
its slot first and its cargo waits for the folder's lock in the slot.
Cargo inside a script
A script that builds (fgate sh build.sh, fgate python shots.py) is gated as a whole, but fgate sees only the
script, not the cargo build in it. So a gated command that is not cargo itself gets fgate's own cargo first on its
PATH: a copy of fgate, shim/cargo in fgate's folder. When the script runs cargo …, the copy finds the real cargo
further along the PATH and runs it as a nested gated command: no second wait in line, since the script holds the slot
(only for the folder's lock, while another build holds it), but the same clean, record and kept executables as
fgate cargo …, even when the script sets CARGO_TARGET_DIR itself. A cargo nested in a gated cargo command doesn't
wait for the lock its parent holds.
Without it, the script's build left the record unchanged, and the next checkout's build took the script's crates for
its own. A script that calls cargo by its full path, or clears the PATH, goes around it.
After cargo publish
cargo publish and cargo package check the package by building a copy of it in target/package/<name>-<version>/,
into the same target directory and under the same names as the workspace's own build. Afterwards cargo's records point
at the copy's sources, which never change, so a later cargo build takes the published version for fresh and stops
rebuilding the binary, however the real sources change. So after a gated publish or package that succeeds (and
verified), fgate cleans the workspace's own packages, and the next build compiles the real sources:
fgate: cleaned this workspace's 1 package(s) after the verify build, so the next cargo build compiles from the real
sources, not the packaged copy
How it works
- Kinds of slot. Build slots for
cargoand test runs, a GPU slot for games, renderers and screenshot scripts, so a screenshot run doesn't wait behind a build,nowslots for small commands that skip the line, and onesoloslot for a run that needs the machine to itself. - A fair line. Waiters are served in the order they arrived. A build whose profile folder another build is using keeps its place, and those behind it go past until the folder is free (see above). Places in line, slots and those folder locks are file locks, which the system drops when a process ends however it ends. A crashed or killed agent never leaves a slot taken.
- Waiting for memory. Memory is usually what runs out first, and a build's memory grows as it goes. A second
command waits while less than
min_free_gbis free. At night, every command waits for it, even alone. - One job per command. Once in, the command and everything it starts run as one job: at low priority, within one memory ceiling and a CPU ceiling for the whole process tree, and ended together, so no compiler is left running on its own. If the job hits its memory ceiling, fgate says so, so an "out of memory" from the gate isn't mistaken for a bug in your code. How each system does it is under Platforms.
- Compiler jobs. A gated command gets
CARGO_BUILD_JOBSandCMAKE_BUILD_PARALLEL_LEVELfrom the settings, unless it sets its own. CMake's applies tocmake --build;ninjaormakerun directly take only their own-j, and Ninja uses every thread without it. (conan buildpasses its own-jfromtools.build:jobs; set that in Conan'sglobal.confto match.) - Night limits. Between
night_fromandnight_until(local time) stricter limits apply, for agents that keep working while nobody watches. - History. Finished commands are logged to
history.login fgate's folder (last 300).fgate watchshows them with run time, peak memory, time waited and exit code.
Settings
The settings live in config.txt in fgate's folder, one file per machine, so every agent counts the same slots.
fgate writes the file with defaults on first use. Edit it any time: each command reads it when it starts, and a
waiting command reads it again when it changes. fgate status shows where the folder is:
| System | fgate's folder |
|---|---|
| Windows | %LOCALAPPDATA%\fgate |
| Linux | $XDG_STATE_HOME/fgate, or ~/.local/state/fgate |
| macOS | ~/Library/Application Support/fgate |
| Setting | Default | Meaning |
|---|---|---|
build_slots |
1 | builds and test runs at once |
gpu_slots |
1 | GPU runs at once |
now_slots |
4 | small --now commands at once |
now_mem_gb |
2 | each --now command's memory ceiling |
jobs |
6 | CARGO_BUILD_JOBS and CMAKE_BUILD_PARALLEL_LEVEL for a gated command |
cpu_percent |
40 | each command's hard CPU ceiling, percent of all threads (0 = none) |
mem_gb |
40% of RAM, min 8 | each command's memory ceiling, its whole process tree (0 = none) |
min_free_gb |
6 | a second command waits while less than this is free |
night_from |
23 | night limits start at this hour (night_from = night_until: never) |
night_until |
7 | and end at this hour |
night_build_slots |
1 | build slots at night |
night_mem_gb |
10 | memory ceiling at night |
night_min_free_gb |
8 | at night any command waits while less than this is free |
switch_clean |
1 | clean a shared target's workspace crates when another checkout built them (0 = off) |
keep_bins |
1 | copy a shared build's executables into the checkout's target/fgate-bin/ (0 = off) |
capture |
1 | keep a job's last output lines for peek and watch, unless it goes to a terminal (0 = off) |
solo_minutes |
10 | a --solo run hands the machine back after this many minutes (0 = never) |
Any setting can be overridden for one command with an environment variable such as FGATE_MEM_GB=16.
FGATE_DIR points fgate at another folder, for trying it out without touching the real line.
A starting point. On a 16-thread, 32 GB machine running about ten agents, memory ran out long before the CPU
did. What worked: build_slots = 1, jobs = 16, cpu_percent = 0, mem_gb = 12, min_free_gb = 6. Guard
memory first. If a build hits its memory ceiling, lower jobs before adding slots.
The agent hook
Agents forget the prefix. fgate hook is a PreToolUse hook
for Claude Code and GitHub Copilot (see Setting it up for every project) that
refuses a heavy cargo command (build, check, test, clippy, run, doc, install, nextest, …) or C++
build (cmake --build, ctest, ninja, make, msbuild, conan build/create/test, conan install --build,
also behind uv run) started without fgate, and tells the agent the command to use instead. Configuring
(cmake -S . -B build) and --version/clean runs go through. The agent retries with the prefix. Quoted
text and heredocs don't count, so git commit -m "cargo test passes" goes through.
Add it to the project's .claude/settings.json (or to ~/.claude/settings.json for every project;
examples/settings.json has a complete file):
A project can add its own rules as options:
| Option | Refuses |
|---|---|
--gpu name,name |
these programs (exe names) when started without fgate --gpu |
--protect-branch <name> |
git push to this branch, such as main (repeatable) |
--protect-label <name> |
gh commands that name this label, such as an approval label (repeatable) |
--no-admin-merge |
gh pr merge --admin, which skips required checks |
--note <text> |
nothing: this text is added to every refusal, to point at the project's rules |
"command": "fgate hook --gpu my_game,my_viewer --protect-branch main --no-admin-merge --note \"See AGENTS.md.\""
FGATE_OFF=1 turns the hook off.
Telling the agents
Agents use fgate when their instructions say so. Copy the section in examples/AGENTS.md
into your project's AGENTS.md or CLAUDE.md, and the hook settings in
examples/settings.json into .claude/settings.json. The instructions mean agents get it
right the first time, and the hook catches the ones that forget.
Setting it up for every project
fgate counts slots per machine, so the natural place for the hook and the instructions is your user profile, not each repository: every project on the machine is covered, and no project's files change. Each agent needs two things, the hook and a few lines of instructions (examples/user-instructions.md has a section for cargo and C++ builds).
| Agent | Hook | Instructions |
|---|---|---|
| Claude Code (also VS Code's Claude target) | ~/.claude/settings.json, as above |
~/.claude/CLAUDE.md |
| GitHub Copilot: VS Code, CLI and the app | ~/.copilot/hooks/fgate.json, from examples/copilot-hooks.json |
~/.copilot/copilot-instructions.md |
On Windows ~ is %USERPROFILE%. The Copilot surfaces share one runtime and one hook folder, so the one file covers
them all. It sends Copilot's own preToolUse format (toolName, toolArgs); fgate reads it and answers in it. The
user ~/.claude/settings.json is not read by Copilot, so an agent of each kind needs its own hook file.
Copilot cloud agent runs in GitHub's sandbox, not on your machine, so it has no use for fgate.
Check the hook by handing it a tool call:
| |
Both print a deny with the reason; a command fgate lets through prints nothing.
Updating fgate while agents use it
On Linux and macOS, cargo install fgate --locked replaces it in place; running commands keep the old version
until they end.
On Windows, cargo install can't overwrite a running fgate.exe, and with the hook in place one runs almost all
the time. Build the new one beside it (through the gate, while the old one keeps working), then swap them by
renaming, which Windows allows while a program runs. Delete the old one once nothing runs it:
A fgate watch that is open keeps showing the old version: restart it.
Platforms
| Windows | Linux | macOS | |
|---|---|---|---|
Fair line, slots, --now, memory waiting, night limits, watch, hook |
yes | yes | yes |
Low priority (not --solo) |
below normal | nice 10 | nice 10 |
| Everything a command started ends with it | Windows job | process group | process group |
| Hard memory and CPU ceiling | Windows job | systemd scope (cgroups), with a systemd user session | no |
| Memory and CPU use per command | job accounting | process group (/proc), scope peak |
process group |
- Linux. The ceilings need a systemd user session (
systemd-run --user), which desktop and most server installs have. Where there isn't one (containers, WSL without systemd), fgate runs commands without ceilings and says so; the line and the wait for free memory still keep the machine safe. - macOS has no way to hold a process tree to a memory or CPU ceiling, so commands run without one, and the
wait for free memory carries the load.
--nowcan't stop a command that grows too big here. Tested in CI on GitHub's macOS machines; reports from daily use are welcome. - Commands run in their own process group. When fgate runs in a terminal, the command gets the terminal, so Ctrl+C and typed input reach it as usual.
License
Used in my projects at Vanir Forge.