Parallel AI Agents, One Per File: A Measured Fan-Out
TL;DR
parallel()fans N independent agent calls out at once. It’s a barrier: it waits for every thunk, with a concurrency cap ofmin(16, cores-2). Excess calls queue.- Measured on a real 43-file repo sweep: 1195.5s sequential vs 154.5s fanned out. 7.74x faster, on the identical job.
- One node broke a file along the way. Disclosed in full below, not smoothed over.
📊 Result, up front. 43-file real repo sweep, identical job both shapes: 1195.5s sequential vs 154.5s fanned out (7.74x, n=1 per shape). 42 of 43 files reached verified-clean; 1 broke mid-run, disclosed below.
0.0, 0.0, 0.1, 0.1, 0.2, 0.2, 0.2, 0.3, 0.3, 0.3, 0.4, 0.4, 0.5, 0.6, 0.7, 0.7, ← 16 agents, all within 0.7s12.9, 12.9, 13.7, 14.9, 16.4, 18.9, 19.1, 19.3, ... ← 17th waits, then streams inThat’s a slice of the real dispatch offsets, in seconds. I watched a parallel() call fan 43 parallel AI agents out over a real repo, one agent per file. Sixteen agents started inside the same second. The seventeenth waited almost 13 seconds. Nothing about that number was in a doc before this run measured it.
The problem: you’ve read that parallel() turns N sequential agent calls into one fanned-out call. You’ve heard someone say “just parallelize it” about a sweep like this one, with no number attached. This post gives you the number, from a real 43-file repo, not a demo corpus. It includes the part where it broke a file.
Prerequisites:
- Finished Loop Part 2 (the lint-sweeper spec, one agent, one file, per round).
- Finished Graph Part 2 (node contracts: JSON Schema-enforced structured output, the reason fan-out works at all instead of handing you 43 strings to parse).
- Helpful, not required: Graph Part 1, for the series’
Loop → Graphglossary this post’s vocabulary assumes. - No prior exposure to the Workflow tool’s
parallel()or its sibling primitives assumed.
Step 1: Give the node a contract
Part 2 gave the lint-sweeper’s one-step a contract: an input shape and a validated output shape, enforced at the boundary instead of requested in prose. Fan-out reuses that contract unchanged. This post doesn’t re-derive it, it leans on it.
A node that returns a string a human has to skim doesn’t fan out. Run it 43 times and you get 43 strings to read by hand, with no reliable way to collect them. A node whose output is already {file, before, after, exitCode}, validated, does fan out. Here’s the rung, reused verbatim from Loop Anatomy and Graph Part 2’s contract:
{ "type": "object", "properties": { "file": { "type": "string" }, "before": { "type": "integer" }, "after": { "type": "integer" }, "exitCode": { "type": "integer" } }, "required": ["file", "before", "after", "exitCode"], "additionalProperties": false}Verify: run the node once against a single file by hand and confirm the output validates against this schema. Fix a bad contract before you fan it out. Fan-out amplifies whatever the contract lets through, 43 times over, not just the good runs.
Step 2: Swap the loop for parallel()
Loop Part 2’s spec fixed one file per round: one_step: "fix one batch of findings (batch = one file), then re-run the check." N files cost N rounds, run in series. parallel() is the one-line change that removes “in series.” Same node, same contract, dispatched all at once instead of one at a time.
// before: one file per round, run in seriesfor (const file of files) { const result = await agent(fixNode, { file }); results.push(result);}
// after: every file fanned out at onceconst thunks = files.map((file) => agent(fixNode, { file }));const results = (await parallel(thunks)).filter(Boolean);That .filter(Boolean) isn’t decoration. parallel() is a barrier: it waits for every thunk, and a rejected thunk resolves to null that has to be dropped before anything downstream reads the result array. This run’s fix stage dispatched 43 copies of Step 1’s schema-enforced job, each in an isolated scratch directory per file, and waited on all 43 at once. Zero thunks were rejected, 0/43 null in both stages, so .filter(Boolean) did nothing observable here. Keep it anyway: the day it does something is the day a silent null.file crash would otherwise reach you.
Parallel agent execution is a live industry trend right now, not a novelty: DeepLearning.AI’s The Batch covered it as a growing shape in August 2025, and ShipWithAI’s own multi-agent coverage has tracked the same shift from the conceptual side. But “fan it out” is a mechanism, not a magic word. The rest of this post is what that mechanism actually costs and breaks.
Verify: confirm .filter(Boolean) sits between parallel() and anything that reads results, before touching results, not after.
Try It Now. Paste-and-run these three on your own repo, no fix node needed yet:
git ls-files '*.sh' | wc -l, tracked count only, skips stale duplicates. Verify: matches your repo’s real file count.shellcheck -f json1 $(git ls-files '*.sh') | jq '[.comments[].file] | unique | length', how many files have findings. Verify: non-zero means some of your fleet is dirty.- Fan your fix node out with
parallel(), then.filter(Boolean)before reading results. Verify:results.lengthstill equals your tracked-file count.
Step 3: Run parallel AI agents and read the results
43 tracked .sh files in a real plugin repo, run through the shape above. shellcheck reports 35 findings across 12 of them; the other 31 are already clean. Nothing was seeded for this post. Unlike Part 2’s fixture, this corpus was already dirty going in.
The count itself is load-bearing. It comes from git ls-files '*.sh', not a raw find. A raw find would have picked up stale worktree duplicates and an untracked scratch file. That would have inflated the number. Cite the tracked count, move on.
$ git ls-files '*.sh' | wc -l43$ shellcheck -f json1 $(git ls-files '*.sh') | jq '.comments | length'35The 35 findings cluster around a small set of shellcheck codes, captured in the same pre-run sweep that set the 35/12/31 split above:
| Code | Count | Meaning |
|---|---|---|
| SC2064 | 18 | Unquoted trap command, expands now instead of when signaled |
| SC2164 | 7 | cd without || exit, silent failure on a bad path |
| SC2295 | 5 | Unquoted expansion, unwanted globbing/splitting |
| SC2034 | 2 | Variable assigned but never used |
| SC2016 | 2 | Single quotes stop expansions the writer expected |
| SC2181 | 1 | Checking $? indirectly instead of the command directly |
tests/install/test-gen-dist-metadata.sh led with 6 findings. Behind it, three files tied at 5 each: tests/install/test-install.sh, scripts/run-content-series.sh, and scripts/lint-layers.sh. Name all three or none; the tie is real. Individual code reference at e.g. SC2034.
1195.5s sequential vs 154.5s fanned out, on the identical 43-file job. 7.74x faster wall clock. This is one run of each shape, n=1 per arm. A real measurement, not a statistically powered claim.
| Sequential | parallel() fan-out | |
|---|---|---|
| Agent calls | 43 | 86 (43 fix + 43 verify) |
| Wall clock | 1195.5s (19m 55.5s) | 154.5s (2m 34.5s) |
| Speedup | 1x (baseline) | 7.74x |
| Total subagent tokens | 1,717,260 | 3,364,399 |
| Tool calls | 180 | 253 |
| Agents errored / returned null | 0 | 0 |
💰 The trade, in raw numbers. Wall clock: 1195.5s → 154.5s. Save 1,041 seconds (17.4 minutes) on the identical 43-file job. Tokens: 1,717,260 → 3,364,399. Roughly 1.96x more, because fix and verify became separate agent calls (43 → 86). No dollar conversion here. Pricing this trade precisely is Part 11’s job.
Immediately qualify the headline: 31 of 43 nodes found nothing to fix and returned without editing anything. The 7.74x is the real aggregate, but 12 files carried the work. Isolated durations, read off each agent’s own transcript:
| n | mean | min | max | |
|---|---|---|---|---|
| Dirty files (had findings) | 12 | 28.3s | 18.6s | 82.7s |
| Clean files (0 findings) | 31 | 22.2s | 13.6s | 122.5s |
The clean-file max isn’t a typo. hooks/post-write-peer-review.sh was the second file dispatched in the sequential run. It took 122.5s despite finding nothing to fix, six times the eventual steady state. Exclude it and one other early outlier (99.3s) and the clean-file mean drops to 16.1s. This reads as a cold-start effect at the front of the sequential run. It wasn’t isolated as a controlled variable, so treat it as an observation, not a proven cause.
scripts/run-content-series.sh was the hardest file across every run in this trace: 82.7s to fix in the sequential run, third slowest of 43, four distinct findings in a file over 100 lines long. It’s also the file that comes back in Step 5.
For context: the LLMCompiler paper (Kim et al., ICML 2024) reports “up to 3.7x” latency speedup and “up to 6.7x” cost savings for parallel tool-calling versus a sequential ReAct baseline, on benchmark suites. This run’s 7.74x clears that upper bound, on a real repo, not a benchmark. That’s a sign fan-out headroom is real and workload-dependent, not a tighter bound than the paper’s own numbers.
Verify: re-check with an independent shellcheck sweep of the result. Don’t trust each agent’s own self-report. Step 5 is exactly the case where a self-report and reality diverged.
Step 4: Watch the concurrency cap bind
min(16, cores-2) isn’t a formula to take on faith. I read this directly off Snapshot B’s fix-stage dispatch timestamps: 16 agents dispatch within 0.7 seconds, the 17th waits until 12.9 seconds in, and it isn’t fixed batches of 16. It’s a rolling queue: one in as soon as one finishes.
Full offsets, seconds since the first dispatch, read directly off this run’s own transcripts:
0.0, 0.0, 0.1, 0.1, 0.2, 0.2, 0.2, 0.3, 0.3, 0.3, 0.4, 0.4, 0.5, 0.6, 0.7, 0.7,12.9, 12.9, 13.7, 14.9, 16.4, 18.9, 19.1, 19.3, 19.3, 19.6, 19.6, 20.7, 20.9,23.6, 24.0, 25.8, 29.7, 31.6, 32.4, 32.8, 33.0, 33.0, 33.5, 34.4, 34.8, 37.5, 38.1Sixteen land in under a second. The gap before the 17th is the cap binding, not a scheduling artifact: as soon as any of the first 16 finishes, the next queued file starts immediately. That’s why the offsets after the initial burst are irregular instead of stepped. The verify stage showed the identical pattern independently: 16 within 1.0s, 17th at 12.4s.
Which cap, exactly: the Workflow tool’s parallel() primitive caps concurrent agent() calls at min(16, cores-2), what this run measured directly. That’s a different mechanism from the Claude Code Agent tool’s own session-level concurrent-subagent limit, documented at 20 (“Concurrent subagent limit reached,” requiring Claude Code v2.1.217+, per the sub-agents docs). Two caps, two tools, don’t conflate them. This post’s number is the parallel() cap, observed at 16.
Verify: pull the dispatch timestamps from your own run’s transcripts and count how many land in the first second. Fewer than min(16, cores-2), and something else is capping you before parallel() does.
Step 5: Handle what parallel() doesn’t handle for you
parallel() is a barrier, not a safety net. Splitting fix and verify into two separate calls means a fix agent that makes a mistake, like the one that broke scripts/run-content-series.sh in this run, never gets a second look from itself.
I didn’t catch it by reading the output. A separate verify agent did, and only because it ran with no memory of what the fix agent had done.
The added line:
# shellcheck disable=SC2034 # reserved flag, not yet consumed by this stagesat inside a case branch, where a directive has to precede a complete statement. The file stopped parsing. shellcheck on it afterward reported not the original style findings but parse errors (SC1009, SC1073, SC1124, SC1072, SC1085), and bash -n confirmed a real syntax break, not a shellcheck false positive.
The fix-stage agent self-reported before: 4, undercounting the true 5 findings. It made the same undercount on this same file in the sequential run, where the fix was nonetheless complete. The separate verify-stage agent had no visibility into what the fix agent had done. It caught the break anyway: after: 4, exitCode: 1. A full independent sweep of the snapshot, plus bash -n across all 43 files, confirms it: 42 of 43 files reached a genuinely clean state; 1 did not.
What this costs: the sequential run’s shape, one agent doing fix-then-reverify itself, would likely have caught its own incomplete edit before returning. The fanned-out shape splits fix and verify into two calls, and the fix agent gets no chance to self-correct. That gap is real, disclosed, and not resolved here. It’s a later post’s territory.
Was this a one-off or systematic? An independent re-run of the identical two-stage design, on the identical file, succeeded: before: 4, after: 0, exitCode: 0, independently verified clean, taking 122.9s, the longest single node in that run, consistent with this file’s reputation as genuinely harder. One failure in two attempts at the same design isn’t enough to call systematic or a fluke. Report the sample size plainly: two attempts, one failure.
This failure mode is a machine-verification story, not a human-review-capacity one. A different angle on parallel agent work, from the Pragmatic Engineer (Oct 2025): Anthropic’s Sid Bidasaria found running a few agents through his workday improved throughput; Armin Ronacher pushed back that review capacity, not dispatch capacity, is the real limiter. This run’s incident was caught by a second agent, not a human reviewer, sidestepping one limiter and raising another: what a second agent misses.
Verify: 42 of 43 files confirmed independently clean, a full shellcheck -f json1 sweep plus bash -n across the batch, not the fix agent’s own self-report.
FAQ
Q: Does parallel() retry a failed agent automatically?
A: No. A rejected thunk resolves to null. .filter(Boolean) on the result array is the caller’s job, always, whether or not a given run happens to reject anything.
Q: What happens past the concurrency cap?
A: Excess calls queue. This run measured a roughly 12-second wait for the 17th of 43 agents to start, then a steady stream as earlier slots freed up.
Q: Is fan-out free?
A: No. This run’s fanned-out shape used roughly double the tokens of the sequential baseline, 3,364,399 vs 1,717,260, for the 7.74x speedup, because fix and verify are separate agent calls. Pricing that trade precisely is Part 11’s job.
Q: Does fan-out change correctness, not just speed?
A: It changes the failure surface, not just the speed. In this run’s disclosed incident (Step 5), a fix agent’s incomplete edit broke a file’s syntax, and a separate verify agent caught it. The same job run sequentially had one self-correcting agent per file; the fanned-out shape splits fix and verify into two agents that never talk to each other.
Close
You now know what parallel() actually does: a barrier over N independent agent calls, capped at min(16, cores-2), excess calls queued, .filter(Boolean) mandatory on the result. You’ve seen a real number attached to it too: 1195.5s down to 154.5s on a 43-file repo, 7.74x, n=1, not a hypothetical demo.
The pitfall to carry forward: a fanned-out, split fix/verify shape gives a bad edit no chance to self-correct the way a combined sequential node can. This run hit that once, in 43 files, caught by an independent verify step rather than the agent that made the mistake. The cheap mitigation: run bash -n across the whole fanned-out batch before trusting any single agent’s exitCode: 0. It’s exactly the check that caught this run’s one broken file, and it costs almost nothing to add.
Graph Engineering Part 4 picks up where this leaves off: what changes once results have to come back together, not just go out. Ships next in this series.
What to read next
- Give Every Agent Node a Contract: Part 2’s node contract, the JSON Schema this post’s fan-out depends on to be collectable at all.
- Anatomy of a Loop: Five Building Blocks and One Spine: the sequential, one-file-per-round spec this post ran side by side with the fanned-out shape.
- Why Single-Agent AI Coding Hits a Wall, And What Multi-Agent Actually Changes: the concept, one senior dev’s day-to-day experience running agents in parallel. This post is the measurement: one real fan-out, on one real corpus.