Chapter 22. Many Agents, One Machine

Several AI coding agents on one machine break on shared mutable state: a repo checkout, a port registry file, the disk, the CPU, and a browser tab pool, each one a mutable thing with more than one writer. The isolation that works is one git worktree per task, cut from origin/main, plus a check on the shared resources before I trust any measurement taken on this box. I run multiple AI coding agents against one Mac at the same time. That is the operating condition, not an occasional overlap. While drafting this chapter I counted the live sessions: pgrep -x claude | wc -l returned 6, and those are the Claude processes alone; Codex sessions run on the same box. The load line read 18:31 up 3:40, 1 user, load averages: 16.28 11.03 7.92. Under ~/code sit 64 top-level directories once you exclude the worktree siblings; 60 of them are git repos, and the other four (INBOX, backups, handoffs, the-signal-chain.fleet) are holding pens. At the 2026-06-10 “pull all” sweep the count was around 50 repos. The machine gets busier every month, and each agent behaves as if it owns the box. None of them does.

A single agent on this machine is reliable. Two agents touching one mutable thing, a checkout, a port registry, the disk itself, produce the failures that cost me real work. The ones below deleted uncommitted edits, blocked every tool call on the box at once, and came close to filing a false production regression. Each has a date on it, and each changed a rule I now run by default.

A branch switch in a shared checkout deletes uncommitted work

2026-06-10. Another live session switched ~/code/Suede-AI-App from main to its own feature branch, feat/master-registry-consolidation, in the middle of my task, and my uncommitted edits vanished with the switch. I had been editing that same checkout in place. The note I wrote that day: “two edits in, everything was gone.” No prompt, no error on my side. The working tree moved out from under me and took the work with it.

The mechanism is dull. A checkout has one working tree and one HEAD, and I had pointed two sessions at both. The main checkout is shared mutable state, not a workspace, and a branch switch by any session wipes every other session’s uncommitted work without a warning. The second session did nothing wrong. It followed its own task to completion, which is the point: on a shared checkout, two correct agents produce one destroyed edit.

The same session, the same day, also appended its own entry, named “registry-dev”, to ~/.claude/launch.json. The collision surface is wider than git. Any file that more than one session writes is contested ground.

One git worktree per agent task

One worktree per task, cut from origin/main into a sibling directory, is the countermeasure:

git -C ~/code/Suede-AI-App worktree add \
  ~/code/Suede-AI-App.worktrees/<task-name> -b <branch> origin/main

cp ~/code/Suede-AI-App/frontend/.env.local \
  ~/code/Suede-AI-App.worktrees/<task-name>/frontend/.env.local

Three choices are packed into those two commands. First, the branch cuts from origin/main, not local main. This Mac is a stale mirror of the machine that pushes to GitHub; on 2026-06-08 the local Suede-AI-App checkout was found 458 commits behind origin/main, parked on an abandoned feature branch with a paused rebase. Work built on local state inherits that drift. Second, the worktree lands in a sibling directory, ~/code/<repo>.worktrees/<task-name>, so the repo’s own tree stays clean and the task’s identity is readable from the path. Third, frontend/.env.local gets copied by hand, because it is gitignored and dev does not start without it. A fresh worktree cut from origin arrives without any of your untracked local files, and the env file is the one that matters.

Cutting from origin is not a habit a sync can retire. On 2026-06-10 I ran a pull-all across the roughly 50 repos then in ~/code: fetch plus fast-forward-only pull, one pass, and Suede-AI-App came out clean on current main with no paused rebases left in the main checkouts. The rule survived the cleanup anyway, because this box drifts again the moment the other computer pushes. A one-time sync buys a moment of accuracy, not a property of the machine, so the worktree command keeps naming origin/main and I keep re-verifying zero-behind before anything pushes or deploys from here.

Then the dev server gets registered in ~/.claude/launch.json on a unique port. The 2026-07-29 registry contains 41 configurations on 41 unique ports and resolves the old notes’ disagreement: suede-agent-studio is on 3210, not 3211, and 3017 is absent. Read launch.json before picking a port, because the file is what sessions collide on, not anyone’s summary of it.

Here is what the convention looks like on the machine today: 52 worktrees spread across 33 sibling directories.

Sibling directory Worktrees
Suede-AI-App.worktrees 21
suede-promo.worktrees 8
suede-voice.worktrees 4
suede-brand-domains.worktrees 3
six repos at 2 each 12

The remaining four directories hold one worktree each, and 19 of the 33 sit empty: shells left behind by prior cleanups, including suede-agent-studio.worktrees, agentix.worktrees, and suede-empire.worktrees. Empty shells cost a directory entry and nothing else, so I leave them. The 21 under Suede-AI-App.worktrees are the number that tells the story. The monorepo is the busiest surface on the machine, which is why the shared-checkout collision happened there first.

The discipline has a passive half too. As I write this, the main Suede-AI-App checkout carries 10 uncommitted entries, two modified fastlane metadata files plus eight untracked paths, and the suede-agent-studio checkout is parked on a detached HEAD with a modified src/app/landing.css and an untracked fastlane/ directory. That second one is the fragile case: detached HEAD plus uncommitted changes means the work is anchored to no branch, and one checkout by any session could strand it. I touch none of it. Dirty state I did not create is presumed to be another session’s in-flight work, and the rule is to preserve it as found rather than stashing, resetting, or tidying it into a commit it never asked for. The paused rebase found on 2026-06-08 got the same treatment, and the pull-all sweep left four broken-remote snapshots under ~/from-jasons-macbook untouched on the same reasoning. Cleaning up someone else’s working tree on this machine is deletion.

When another session fills the disk: ENOSPC on every command

2026-07-22. Every Bash call failed at once with ENOSPC: no space left on device. Not most calls: every call, including df -h, the command whose whole job is to diagnose the condition. File writes failed in the scratchpad and inside the repo alike. When the tool that measures free space cannot run for lack of free space, the failure reads like a broken harness or a revoked permission. It was neither. Other concurrent sessions’ worktrees, builds, and node_modules trees had eaten the machine to zero.

Then it healed on its own. Free space went from 0 to 4-5GB within 1-2 minutes as another session’s build or install finished. That produced two rules. On suspected disk exhaustion, wait a short interval and retry a plain df -h / before concluding anything is unrecoverable, because the condition is often transient on a box where other agents are mid-build. And never attempt blind cleanup. With Bash down you cannot ls or du to learn what is safe to remove, so any deletion is a guess, and the thing you guess wrong about is another session’s work in progress.

Reading df on this Mac takes care even when it runs. df -h / reports the sealed read-only system volume, /dev/disk1s1s1: 466Gi size, 12Gi used, 11% capacity. That looks like an empty disk. The volume doing the work is /System/Volumes/Data, /dev/disk1s2: 342Gi used, 78% capacity, sharing the same 99Gi available pool. Budget from the Data volume or from the shared available figure, or the 11% will lie to you at the worst moment.

Free-space math has a second trap: peak versus result. The Suede-AI-App/frontend install weighs 3.1GB+ across 1173 top-level entries, most of it a heavy web3/wallet SDK dependency tree. On 2026-07-22 that install failed with ENOSPC while df showed a few GB free, because npm’s tarball-extraction peak exceeds the final installed size. Headroom that covers the result does not cover the build. Budget for the peak.

Cloning node_modules with cp -Rc instead of installing

If another worktree, or the main checkout, has a working node_modules built from an identical package-lock.json, clone it instead of installing. The same day produced that workaround, and it now carries most of the disk pressure here:

diff ~/code/Suede-AI-App/frontend/package-lock.json ./package-lock.json  # must be identical
cp -Rc ~/code/Suede-AI-App/frontend/node_modules ./node_modules

The -c flag asks macOS for APFS clonefile(), copy-on-write cloning. Measured on the 3.1GB, 1173-entry tree: df stayed flat within 0.1GB across the entire clone. Blocks get duplicated on disk when a file is modified later, and until then a multi-gigabyte dependency tree costs metadata. On a machine that has hit ENOSPC from sibling sessions, an install that consumes no space beats an install that races the neighbors for it. The neighbors are also what make the workaround possible: on a box carrying 52 worktrees, the odds are good that some sibling has paid for the install you need, and the densest repo, the one whose 3.1GB tree causes the pressure, is the one most certain to have a donor sitting next door.

The lockfile check is not optional. The clone reproduces the source tree byte for byte, so if the lockfiles diverge you get a node_modules that matches neither manifest, and the failure shows up later as a runtime mystery instead of an install error. Diff first, then clone.

One more calibration from the same day: free bytes do not mean free time. A 1173-entry tree is minutes of metadata work even when no data moves. If a cp -Rc runs past about 180 seconds, move it to the background rather than concluding it hung.

Why a load average of 838 invalidates a benchmark

CPU is the other contended resource, and its failure mode is quieter than the disk’s because nothing errors. The numbers come back wrong instead.

During an estate-wide Lighthouse performance pass, load on this machine hit 838, driven by other concurrent sessions, not by the pages under test. A performance score measured under load 838 describes the contention, not the code. Ship a fix based on that score and you are tuning a site against a traffic jam it will never see again.

The countermeasure costs one command: run uptime before trusting any measurement taken on this box, and read the whole triple, not the first number. The snapshot at the top of this chapter, 16.28 then 11.03 then 7.92, says more than “the machine is busy.” The 1-minute average at double the 15-minute average means concurrent work ramped up inside the last few minutes. A benchmark started in that window lands in the worst of the ramp and reads as a regression that does not exist. The same logic covers flaky tests: a suite that fails at load 16 and passes at load 2 measured the machine, not the code.

Shared browser tabs return another session’s page

A shared verification tool is the subtlest collision on this machine, because a wrong verification result converts a healthy system into a false incident.

2026-07-17, while verifying a live page through a shared browser-automation tab pool: the tool returned stale content from a different origin than the one I had navigated to. Concurrent agents reuse and overwrite tabs in the pool, so a shared tab’s state proves nothing about the page you think you are on. Taken at face value, that stale readout was minutes away from being filed as a production regression. The rule that came out of it: open a dedicated tab with tabs_create and re-verify there before concluding anything. On this machine, a suspicious browser result is a tooling signal first and a finding second.

The sharpest version came earlier, on 2026-06-11, during the settlement-gate incident. While I repaired the x402 settlement default from an isolated worktree, a concurrent session counter-edited the same defaults in the main checkout. Two of the post-mortem rules exist because of that overlap: one orchestrator per repo at a time, and when sessions do overlap, trust commit contents, not commit messages. A message records an intention. On a contested repo, the diff is the testimony.

Shared surfaces, collisions, and countermeasures

Shared surface Collision Countermeasure
Repo checkout Branch switch wiped uncommitted edits (2026-06-10) One worktree per task, cut from origin/main
frontend/.env.local Fresh worktree cannot start dev Hand-copy from the main checkout
~/.claude/launch.json Concurrent entries, contested ports Unique port per session; read the file before writing it
Disk ENOSPC took down every tool call (2026-07-22) Wait and retry df -h /; no blind cleanup; cp -Rc instead of install
CPU Load 838 under a benchmark pass uptime before trusting any measurement
Browser tab pool Stale cross-origin readout (2026-07-17) Dedicated tab per verification

Every row is the same shape: one mutable thing, two writers, and a failure that arrived without an error message. The checkout collision deleted work in silence. The stale tab reported wrong data with full confidence. The load average corrupted a score that looked precise to two decimal places. Shared-state failures on this machine do not announce themselves, which is why the countermeasures run before the evidence of trouble, not after.

The 2026-06-10 note flags that the same caution “likely applies” to other busy repos on the machine, marked as likely rather than confirmed. I run it as confirmed anyway. The collision mechanism has nothing repo-specific in it, and the price of being wrong in the cautious direction is one extra directory.

The numbers say which way this is trending. Around 50 repos at the 2026-06-10 sweep, 60 now, plus 52 worktrees and 6 concurrent sessions on the evening I checked. The fleet grows and the machine does not. What holds it together is that each session’s blast radius stays inside a directory with the task’s name on it, and everything the sessions cannot avoid sharing, the disk, the CPU, the port file, the browser, gets measured before it gets trusted.