Skip to main content

Too many agents, one Mac

· 5 min read

Coding agents made parallel work cheap. My Mac got the bill.

I often have several agents working at once, each in its own Git worktree. They write code, run the tests, and commit it. In isolation, each one behaves sensibly. Together, they can start three builds, four test suites, and a browser run on the same machine without any of them knowing the others exist.

The worst offenders were Git hooks. An agent would finish its work, run the tests, then commit or push. The pre-commit or pre-push hook would run much of the same validation again. Multiply that by a few agents and worktrees, and a quiet afternoon of delegation turned into fans, swap, and a machine too sluggish to use.

Worktrees only isolate the code

Git worktrees are a good fit for agents. Each gets its own checkout, so they can work on separate branches without trampling one another.

That isolation has a hard edge. The source trees are separate, but the CPU and memory aren't. Build output is often separate too, so each worktree may compile the same dependencies and hold its own caches. The hooks attached to the repository don't know that another worktree is already running the same check.

Each agent sees a simple local decision: the instructions say to run the tests, so run the tests. None can see that the machine is already at its limit.

CPU contention made every job slower. Memory pressure was worse. Once macOS started compressing memory and growing swap, jobs that should have finished quickly stayed alive for longer. That increased their overlap, which increased the pressure again. More parallel work produced less completed work.

Agent-level limits weren't enough

I could tell each agent to be cautious, but that relies on every agent obeying the same instruction and somehow knowing what every other agent is doing.

I could lower the parallelism of every build. That wastes the machine when only one job is running and still doesn't coordinate several memory-hungry jobs.

A lock file in one repository helps a little, but the problem isn't one repository. It's every agent, worktree, terminal, and hook competing for one computer.

The scheduler had to live at the same level as the constraint: the machine.

Putting a turnstile under the shell

I built Turnstile as a machine-wide, memory-aware gate for builds and tests on macOS.

It puts lightweight shims for tools such as swift, npm, vitest, and playwright at the front of PATH. Normal commands pass straight through. Heavy builds and tests ask a small daemon for permission to start.

That means the coordination happens beneath the agents. Claude Code, Codex, a Git hook, and a command I type myself all pass through the same gate. I don't need to keep teaching each caller how busy the machine is.

Turnstile gives compile, test, and browser jobs their own concurrency limits. It also learns how much memory each command tends to use in each project, then starts a job only when there is room for its expected peak. Commands I type go ahead of background agent work.

If two agents request the same command against identical working-tree contents, Turnstile merges the runs. Both callers see the output and exit code from one execution. Different worktrees with different changes still run separately, as they should.

The Git hook case falls out naturally. A pre-push hook that calls npm test or swift test doesn't need special integration. It reaches the same shim as the agent did, then waits, starts, or joins an identical run according to the state of the whole machine.

Memory is a moving target

Admission control handles jobs whose cost is predictable. Real builds aren't that polite.

Turnstile watches macOS memory pressure and swap growth while jobs run. As memory gets tight, it reduces the parallelism passed to supported tools. If the machine starts swapping, it stops admitting new work and can pause the newest agent job until memory recovers. At least one job keeps running, so the queue still drains.

This matters because free memory alone is misleading on macOS. Compression and paging can make the headline number look tolerable while the machine is busy writing itself into treacle. Swap growth is the useful signal.

There is still an escape hatch. Turnstile fails open if its daemon breaks, and I can disable it globally when I want the old behavior. It's there to schedule commands, not become a new reason they fail.

What changed

Turnstile doesn't make a build cheaper, and it doesn't remove the disk cost of worktrees. It stops expensive work from arriving in one uncoordinated wave.

My agents may wait before a build now. That can look slower if I watch one terminal, but the queue is honest: it says what is running, why a job is waiting, and roughly when it can start. Across the machine, more jobs finish, the foreground stays usable, and pre-commit and pre-push hooks no longer turn a routine commit into an accidental load test.

This feels like a broader lesson from running several agents. Parallelism isn't the same as throughput. Once software can create work faster than the machine can absorb it, delegation needs traffic control.

Turnstile is open source, and installs through Homebrew. The defaults are designed to work without per-project setup.

Maybe I'll send an email once in a while

Monthly digest. No spam.