Ramiz RajaBook a call
← Writing
Working With AI Agents

Twelve Cores, Seven Devices, One Disk

Git worktrees keep parallel agents out of each other's files. They do nothing about the CPU, the test phones, the disk or the run that hung seven hours ago. Those are the parts that actually broke.

Ramiz RajaOctober 21, 20268 min read
Twelve Cores, Seven Devices, One Disk

A release test run on one of my Android apps once sat for 7 hours and 22 minutes. In that time it used 258 milliseconds of CPU. It wasn't slow, it was stuck, inside the test tool's own driver install, before a single test step ran. Nobody checked on it. Several Claude Code sessions were working on the same machine, and each had its own work to watch.

That is what running multiple Claude Code agents at once actually looks like after a few months. The problems are not in the code. If you only want the checklist, it's the table at the end; the sections in between are how each row was earned.

Git worktrees solve the files, not the machine

If you search for how to run several Claude Code agents in parallel, the answer is consistent: give each session its own git worktree and branch, so two agents never edit the same file. That's right, and I do it. It's also where the guides ranking for that search stop, from Towards Data Science to kanbots.

Here's what changed for me. One agent at a time is a tool. Four agents at a time, each with its own worker processes, builds and device runs, is a small team sharing one desk. A worktree gives each of them a separate folder. They still share:

  • The CPU. A Gradle build, an emulator and a test run each want most of the machine.
  • The test devices. There's one Pixel. Two agents can't both install onto it at once.
  • The disk. Every build adds cache, and nothing removes it.
  • Time. A hung run looks exactly like a slow one unless something checks.

The hard part is that every session is behaving reasonably on its own. No single agent does anything wrong. The failure only exists when you add them up, and no session can see the others. So the guards have to live outside the sessions, at the machine level.

My setup, for scale: an Apple M3 Pro MacBook Pro (12 cores, 36 GB), a Windows PC with an Intel i9-11900H (8 cores, 32 GB) that takes Android builds, a 2014 MacBook Pro kept as a spare, seven registered test devices, and a real-device cloud for what I don't own. The agents are Claude Code sessions that plan and review, plus cheaper coding agents that do the bulk of the work. That split is covered in how I review AI-generated mobile code.

Claude Code high CPU usage: when several sessions fan out

The first thing that broke was speed, and it took a while to see why.

A session would launch background shell commands to generate files: about twelve of them, each burning 8 to 10 percent CPU. On its own, fine. But three sessions doing something similar at the same time oversubscribed the M3 Pro, and every build, test and device run on the box slowed to a crawl. Each session saw only its own twelve workers. None of them knew the machine was full.

The fix is a Claude Code PreToolUse hook. It's registered once in ~/.claude/settings.json, so it runs before every Bash call in every session:

"hooks": {
  "PreToolUse": [
    { "matcher": "Bash",
      "hooks": [ { "type": "command",
                   "command": "$HOME/.claude-shared/scripts/resource-budget-guard.sh" } ] }
  ]
}

The hook reads the tool call as JSON on stdin. If it isn't a run_in_background call, it exits at once. Otherwise it takes one ps snapshot and counts busy processes across all Claude sessions on the machine, not just its own:

# condensed from resource-budget-guard.sh
MAX=${CLAUDE_MAX_BG_WORKERS:-$(( NCPU / 2 ))}   # 6 on a 12-core Mac
FLOOR=${CLAUDE_BG_CPU_FLOOR:-3}                 # %CPU that counts as "working"
# for each process: walk up its parents until one is a `claude` process.
#   skip the hook itself, and skip MCP servers (long-lived, not fan-out)
#   if its %CPU >= FLOOR, count it against that session
[ "$COUNT" -lt "$MAX" ] && exit 0
printf '{"decision":"block","reason":%s}\n' "$(jq -Rs . <<<"$REASON")"

The block reason names which session owns the busy workers and tells the agent what to do instead: run the command in the foreground, batch the work into one loop, or wait. Foreground calls aren't checked at all, so a blocked agent can still do the work, one step at a time. For a run that genuinely needs more, a leading CLAUDE_MAX_BG_WORKERS=8 on that one command raises the limit for that call only. The hook reads the assignment from the command text itself, because a variable set in the command never reaches the hook's own environment.

The second half is cleanup. A Stop hook warns when a session still owns more than four busy processes, and a SessionEnd hook kills the busy ones it can trace back to that session (MCP servers excluded; on the Windows box it only logs them unless reaping is switched on). That matters more than it sounds. Claude Code's own issue tracker has reports like this one, of processes that pile up and outlive the work they were started for. A worker that never gets reaped costs the machine for hours.

Sharing test phones between agents: lease, don't grab

Two agents on one phone is worse than two agents on one file, because a phone has no merge step. One installs a build while the other is mid-test, and the second agent reports a failure the app doesn't have.

The worst version of this took a long misdiagnosis to find. Two Maestro runs, on two different Android phones connected to the same machine, kept dying with:

Connection refused: localhost:7001

That error looks exactly like an app hang. I blamed the number of MCP server processes first, then a concurrent iOS session. Neither was it. The real cause was an upstream Maestro bug: before version 2.6.0 it tracked sessions per platform, not per device, so the second run's driver install was silently skipped (Maestro issue #3065). The rule now is simple: Maestro 2.6.1 or later, everywhere.

The general fix is a small device farm. Every command that touches a phone goes through a wrapper that takes a lease first:

farm-with-device --alias plannedping-pixel --holder me:gate --ttl 7800 -- \
  run-with-watchdog.sh --log gate.log -- ./scripts/run-local-release-gate.sh

The phones hang off the Windows PC, and the farm there is the only lease authority. Single commands (farm-adb, farm-maestro, screenshots) forward from the Mac over SSH; a whole gate script like the one above runs on the PC itself. If the PC can't be reached, device commands fail rather than guess.

The lease is an atomic mkdir lock with a time limit, set longer than the watchdog's own cap so it can't expire mid-run. A hook blocks raw adb and maestro calls, so an agent can't skip the queue by accident. Devices carry a policy, and the policy decides who can pick them:

DeviceAvailabilityHow an agent reaches it
Pixel 4a 5Galwaysby role (default pick)
Sonyalwaysby role (default pick)
TCL tablet, Android 16alwaysby name only
POCO (MIUI)alwaysby name only, for OEM-specific bugs
Samsung S26 Ultraon-demandby name plus an extra flag
iPhone 15, iOS 26alwaysby name only, via Appium
iPhone 11, iOS 18on-demandby name only

Picking by role fails closed when more than one device matches, so an agent never lands on a phone by luck.

For iPhones the agent drives the device without touching the Mac's mouse at all, which is its own story: an agent driving a real iPhone.

There was a second device problem I didn't see coming. To keep a long run from being interrupted, agents set the screen to stay on while charging, then finished and walked away. Phones sat fully awake around the clock, which drains and heats a battery even on a charging cable. Now releasing a lease arms a marker. The next Stop hook that finds a marker older than five minutes turns stay-awake off on that Android device, sets the screen timeout back to 60 seconds and switches the display off. A device that's leased again in the meantime is skipped. When a session ends, it does the same for its devices straight away.

Claude Code disk space: build caches only grow

Disk is the slowest problem and the easiest to ignore. Here's one night from the cleanup log:

00:45  disk-hygiene summary: free 56G -> 56G
03:52  disk-hygiene summary: free 24G -> 24G
05:52  disk-hygiene summary: free 12G -> 12G
07:54  disk-hygiene summary: free 10G -> 7G
08:55  disk-hygiene summary: free 3G -> 3G
10:52  disk-hygiene start:   free 1G

Below 100 GB free the cleaner starts on its own, so that night it ran almost every hour, and free space kept falling anyway. A cleanup schedule doesn't stop a disk filling: the cleaner may only delete what a rebuild restores, and the warning threshold is what tells a human to look.

That restriction is deliberate, and it's what makes the job safe to run unattended. It deletes merged worktrees, merged branches and old build/, .gradle/ and DerivedData/ folders. It never touches source, .git, .env files or keystores. A worktree with commits that no remote has is never deleted; it's logged for a human to decide.

The tricky case was dirty worktrees. A coding agent that dies mid-task leaves one behind, and I'd found six of them holding 3 to 8 GB of build output each. Keeping them forever fills the disk. Deleting them loses work. So a dirty worktree idle for 72 hours is first archived, as a binary patch plus a tarball of every untracked and ignored file except regenerable build folders, both size-checked, and only then removed. The ignored files matter: a .env or a keystore is untracked by design, and a cleaner that ignores them deletes the only copy.

Normally it runs every six hours at low priority. On a good pass it frees a lot: the largest single sweep cleared 44.5 GB across 92 paths.

The honest part: for a while it freed nothing at all, while looking like it worked. The cleanup script loaded a helper that had its own variable named APPLY, set to off. So --apply silently became a dry run. The log said "DRY-RUN", deleted nothing, and the job looked healthy. The fix was renaming the variables. The lesson was to check what a cleanup job freed, not whether it ran.

Long runs: is it hung or just slow?

Back to the opening. Nothing polled that run, and when a session finally did report on it, it read the log and said "no Maestro process alive". The process was still resident. An earlier stop signal had killed the wrapper shell and left the Java process underneath untouched. And it wasn't the port 7001 bug from the last section: that machine was already on Maestro 2.6.1.

Every run expected to take more than five minutes now goes through a watchdog:

Exit codeMeaning
0finished
125stalled: the log stopped growing for 600 seconds
124hit the hard cap (7,200 seconds)
143killed by a signal

124 and 125 mean no result was produced. They are never reported as a failing test.

A ten-minute stall check plus one thread dump named that cause immediately. Before it kills a stalled run, the watchdog takes a thread dump of the run's Java processes (jstack) and a sample of the rest of its process tree, then kills the whole tree, not just the wrapper. The dump settles the argument you'd otherwise have: if the blocking frame is inside the test tool, it's the tool; if it's inside the app's code, it's a possible product bug worth a closer look. No dump, no verdict either way.

Two rules came straight out of that run. Answer "is it still running?" from the process table, never from a log file. And kill the tree, not the wrapper.

Moving Android builds to a second machine

Once the guards were in, the Mac was still the bottleneck, because it does everything. So Android builds moved to the Windows PC. A win-build <project> <tasks> command streams the tracked files over SSH, runs Gradle on a warm cache there, and pulls the APKs back. All 13 Android repos build there.

iOS stays on the Mac, since it has to. The 2014 MacBook Pro is deliberately not a build worker. It's several times slower than the M3, and putting anything on it that someone waits for just moves the bottleneck somewhere worse.

Two machines bring a new problem: the hooks and scripts above have to be identical on both. A sync job runs every five minutes on each host through a private git repo. Before pushing, it scans local edits for secret patterns. Incoming changes are checked for shell syntax, JSON and file size before they go live, then swapped in one file at a time with an atomic rename. If an incoming check fails, the live files stay as they were and every new session on that host sees a drift warning until it's fixed. The same thinking applies to the code itself, in keeping agent-written code clean: a rule only holds if something fails when it breaks.

What to set up first

If you're running more than two Claude Code sessions on one machine, this is the order I'd do it in, by how much each one cost me before it existed:

ProblemWhat breaksGuard
Runs that hanghours lost, false "still running"watchdog: stall at 600 s log silence, thread dump, kill the tree
CPU fan-outevery build slows, nobody knows whyblock new background launches once half the cores' worth of workers are busy
Shared phonesfalse failures, a phone taken mid-testleases with a time limit; raw adb blocked
Leaked processesslow machine hours laterreap busy processes at session end
Diskbuilds fail at 100%scheduled cleanup of regenerable files only; archive before delete
Two machinesa fix on one host, not the othervalidated 5-minute sync with a drift warning

None of this is about the model. It's the same work a team does when several people share one build server, done for agents that can't see each other. If you've inherited an app whose build and test setup already looks like this, that is the kind of thing I sort out in an app rescue.