We ran 10 Claude Code agents at once and froze the PC. Here is how we schedule them now
Table of Contents
When you run several Claude Code agents at once, the first thing to give out is usually not the CPU. It is the npm ci, the builds and the headless Chrome each agent runs, all fighting over the same disk and the same memory. We launched 10 at once and the desktop could no longer even start cmd.exe. Since then: at most 6 at a time, starts staggered by 2 minutes, a pause flag checked before every launch, a guard script that sets that flag on its own, and every worker deletes its own node_modules and worktree when it finishes.
What happened that night?
At 5 a.m. on November 3 we launched 10 Claude Code workers, all headless claude -p: 4 in a product repo and 6 in our operations repo, doing health checks, filling feature gaps and preparing open source PRs. Each worker ran npm ci and a build in its own git worktree, then opened headless Chrome to walk through the pages.
The workers kept producing, and the first 7 reports still came in a little after 7, but the machine was already in trouble: from 5:49 on, git commits stopped going through. Then the symptoms piled up:
- In the coordinating Claude session, the Bash and PowerShell tools took 30 minutes to return even for
echoorGet-Date. The commands did run; the shell just never exited. - Scheduled tasks were killed one by one with result code 267014 (0x41306, task terminated).
- Our two-hourly patrol script left no record at all for its 05:05, 07:05 and 09:05 runs.
- The loop's heartbeat file last updated at 08:06.
Only around 10:40 did we work it out: the whole machine could no longer start new processes. A reboot was the only fix, and it did not happen until about 18:50. After the reboot, git complained index file bad signature, and the index had to be rebuilt before anything could be committed.
The machine is strong. Why did it freeze?
This is a desktop with 96 GB of RAM and an RTX 3080, with the C and D drives on the same NVMe. Early the next morning, with 4 workers running, we measured CPU at 10%, 68 GB of free memory and a disk queue of 0. By those numbers, 10 workers should have fit.
The agents themselves are not heavy. A Claude Code process mostly waits for the model. What is heavy is the commands it runs for you. Our best explanation:
- Disk: 10 workers writing
node_modules(tens of thousands of small files each) and build output in the same window, plus a Chrome instance each, all on one NVMe. - Commit charge: the page file is fixed at 8.7 GB, so the commit limit is roughly RAM plus page file, about 104 GB. Node builds and Chrome reserve a lot of commit up front. Even with physical memory left, once commit runs out, new processes cannot start.
Why only a best explanation? Because once the machine froze, we could not measure anything. There is also one thing we have not explained: the patrol script had already logged a spawnSync cmd.exe ETIMEDOUT at 01:35, before any of the 10 were launched. So we are sure of the trigger (10 at once, every one of them doing heavy I/O), but we will not claim it is the only cause.
What are the rules now?
Five, each one mapped to a symptom from that night:
- At most 6 workers at a time, raised to 8 after a stable stretch. The cap depends on what the workers run, not on core count.
- Stagger starts by 2 minutes. The first few minutes of a worker are usually
npm ci, so staggering starts staggers installs, and 10 copies ofnode_modulesnever get written at once. - Check a pause flag before each launch. If the flag file exists, nothing new starts; running workers keep going.
- A guard script sets the flag on its own. Free memory under 8 GB, disk queue over 8 for 3 checks in a row, or more than 8 claude.exe processes: any one of these writes the flag and sends an alert. Once everything is back under, it removes the flag it set.
- Workers clean up after themselves. The last section of every dispatch prompt says: delete your own
node_modules, thengit worktree removeyour own worktree.
Where does the number 6 come from?
It was not carefully calculated. After the reboot we dropped to a cautious 4. Early the next morning we measured CPU at 10%, 68 GB free and a disk queue of 0, saw the machine sitting mostly idle, raised the cap to 6, and left the guard script as the safety net.
While the cap was still 4 or 5, we also had a rule that at most 3 workers could be doing heavy work (npm ci, builds, browsers) at once. At 6, staggered starts are what keep the heavy steps apart. Workers that read code, write reports or draft PR descriptions are light, and a few extra do not matter.
The condition for going to 8 is written down in advance: the guard has not raised the flag for a while, and no scheduled task has ended with 267014 again.
To be honest, the same night we wrote this, we pushed our own worker count to 9, and the guard flagged it on its very first test run (9 claude.exe processes against a limit of 8). Writing a rule down and actually keeping to it are different things, which is why raising the flag is the script's job and not left to self-discipline.
What does the dispatch loop look like?
Here is a minimal dispatch loop for Git Bash. It launches prompts in queue order, with the concurrency cap and stagger as parameters:
#!/usr/bin/env bash
# dispatch.sh: launch in order, at most MAX at a time, STAGGER seconds apart
MAX=6
STAGGER=120
QUEUE=/c/work/queue # one .md per prompt
LOGS=/c/work/logs
PAUSE_FLAG=/c/ops/DISPATCH_PAUSE
count_claude() { tasklist //FI "IMAGENAME eq claude.exe" //NH | grep -ci 'claude.exe'; }
BASE=$(count_claude) # the coordinator and any open sessions are not workers
for prompt in "$QUEUE"/*.md; do
tag=$(basename "$prompt" .md)
while [ -f "$PAUSE_FLAG" ] || [ $(( $(count_claude) - BASE )) -ge "$MAX" ]; do
sleep 60
done
( cd /c/work/repo && claude -p --model opus < "$prompt" > "$LOGS/$tag.log" 2>&1 ) &
echo "$(date +%H:%M) dispatched $tag"
sleep "$STAGGER"
done
wait
A few details. In our setup, one claude -p worker is one claude.exe process; count it yourself on your own machine first. In Git Bash, tasklist needs //FI and //NH; a single slash gets rewritten as a path. And every subshell does a cd to an absolute path, because a background subshell's working directory is not guaranteed to be the one you think. We learned that from builds that ran in the wrong repo.
What does the guard script look like?
Our own version is written in Node, uses PowerShell's Get-Counter for the measurements, runs every 5 minutes from Task Scheduler, and only went live in the early hours of November 4, so it has not raised a real flag yet. Below is a trimmed-down PowerShell equivalent with the same limits and behavior. Test it yourself before relying on it:
# resource-guard.ps1: measure three numbers, write the pause flag if any is over
$flag = 'C:\ops\DISPATCH_PAUSE'
$state = 'C:\ops\guard-diskq-hits.txt'
$os = Get-CimInstance Win32_OperatingSystem
$freeGB = $os.FreePhysicalMemory / 1MB # value is in KB; divide by 1MB to get GB
$queue = (Get-Counter '\PhysicalDisk(_Total)\Current Disk Queue Length').CounterSamples[0].CookedValue
$claude = @(Get-Process claude -ErrorAction SilentlyContinue).Count
# the disk queue must be over the limit 3 checks in a row; ignore single spikes
$hits = 0
if (Test-Path $state) { $hits = [int](Get-Content $state) }
if ($queue -gt 8) { $hits++ } else { $hits = 0 }
Set-Content $state $hits
$why = @()
if ($freeGB -lt 8) { $why += ('free memory {0:N1} GB' -f $freeGB) }
if ($hits -ge 3) { $why += "disk queue > 8 for $hits checks" }
if ($claude -gt 8) { $why += "claude.exe x $claude" }
if ($why.Count -gt 0) {
if (-not (Test-Path $flag)) {
Set-Content $flag ('resource-guard: ' + ($why -join '; '))
# plug in your own alert here: a Telegram bot, a Discord webhook, email
}
} elseif ((Test-Path $flag) -and (Get-Content $flag -Raw).StartsWith('resource-guard')) {
Remove-Item $flag # all clear: remove only our own flag, never one a human placed
}
Register it:
schtasks /Create /TN ResourceGuard /SC MINUTE /MO 5 /TR "powershell -NoProfile -ExecutionPolicy Bypass -File C:\ops\resource-guard.ps1"
There are two kinds of flag. One the guard raised comes down by itself once the numbers recover. One a human placed (say, for maintenance) is never touched by the script and has to be deleted by a person. Our version also records when it last alerted, so one pause sends one alert instead of ringing all night. Two small traps: registered this way, a console window flashes every 5 minutes, so we wrap the call in a VBS script to hide it; and if the script contains non-ASCII strings, save it as UTF-8 with a BOM, or Windows PowerShell 5.1 will read them as garbage.
How do workers clean up after themselves?
Ten worktrees each carry a node_modules of a few hundred MB to a couple of GB. Leave them and the C drive fills up in days. Cleanup is written into the last section of the dispatch prompt, and the worker does it:
WT=/c/work/wt-$tag
git -C /c/work/repo worktree add -b "$tag" "$WT" origin/main
# ... the worker works inside $WT ...
rm -rf "$WT/node_modules"
git -C /c/work/repo worktree remove "$WT"
If git worktree remove fails because of untracked files, look at git status first. Do not just add --force to get past it.
One exception will really hurt: if the worktree's node_modules is a junction (mklink /J) pointing at the main checkout, both rm -rf and git worktree remove --force follow it and delete the main copy too. We hit it at least four times in two months. To share packages, call the main checkout's tools by absolute path instead of creating a junction.
How do you recover when it freezes?
Learn the symptoms first; the earlier you spot them, the easier it is:
- Tool calls or the shell take a very long time to return, even though the command did run.
- Scheduled tasks end with result code 267014.
- Logs show
spawnSync cmd.exe ETIMEDOUT. - git commit hangs.
If the shell still responds, stop the workers and keep the coordinator. List claude.exe processes with Get-CimInstance Win32_Process, pick out the workers by creation time, and kill them by PID with Stop-Process -Id. Do not match on a tag in the command line and kill everything that matches: the shell running that command has the same tag in its own command line and will kill itself.
If not even new processes start, reboot. If git then reports bad signature, rebuild the index:
mv .git/index .git/index.bak && git reset
git reset rebuilds the index from HEAD and leaves your working files alone.
These rules follow the same idea as the render lock that lets several Claude sessions share one GPU: when the machine is the limit, make the agents take turns instead of letting them fight.
We write up our multi-agent work on this blog, including the parts we got wrong. If you want to see how many agents in a "team" are actually doing anything, and what they cost, read how to check the real size and cost of a multi-agent setup next.
FAQ
How many Claude Code agents can I run in parallel?
Size it by what the agents run, not by the agents. A Claude Code process spends most of its time waiting on the model and is light; the heavy part is the npm ci, builds and headless Chrome it runs for you. On a 96 GB desktop with a single NVMe drive, 10 agents doing heavy I/O at once froze the machine. We now cap it at 6, stagger starts by 2 minutes, and plan to raise it to 8 after a stable stretch.
How do I stop dispatching agents before the machine locks up?
Use a pause flag file. The dispatch loop checks for the flag before launching each agent. A separate guard script, run every 5 minutes by Task Scheduler, measures free memory, disk queue length and the number of claude.exe processes, writes the flag and sends an alert when any of them crosses its limit, and removes its own flag once everything is back under. The disk queue has to be over the limit 3 checks in a row, so a single spike does not pause everything.
After a forced reboot, git says index file bad signature. How do I fix it?
The index file was cut off mid-write. Move .git/index out of the way and run git reset. Git rebuilds the index from HEAD and leaves your working files alone; anything that was staged simply becomes unstaged. In a worktree, the index lives at .git/worktrees/<name>/index.