Orchestrator-Workers
By the Syncfusion DevTutorials team · Last updated
When the work list is only found while running, an orchestrator starts workers for it. Cap width, depth and spend, record what it really started, and keep a kill switch.
Watch an Orchestrator Start Too Many Workers
The project is moving to a new API client. Every old call must change. Nobody knows how many there are until the code is searched. It could be 5. It could be 50.
An orchestrator node searches the code, finds the call sites, and starts one worker for each. Run it.
An orchestrator with no caps
It found 18 call sites and started a worker for each. Some workers found big files and started their own workers. 33 workers called the API at once. The API allows 8. The other 25 got 429 Too Many Requests, retried at once, and hit the limit again, and again.
Nothing told the orchestrator how far it may go. In this lesson you give it limits.
TL;DR
- Orchestrator-workers is for work you can only list while running: the orchestrator finds the work and starts workers for it.
- It fails when it is unbounded: too many workers at once, workers starting workers, or no spend limit.
- Give it caps:
width(how many at once),depth(may workers start workers?) andspend(tokens for the whole subtree). - Set
widthat or below the limit of what the workers call, such as a rate-limited API. - Record the realized topology: what was actually started. Add a kill switch that stops every worker at once.
A Real-World Example
Omar asks an agent to "update every test file to the new test helper". The agent finds 140 test files and starts a subagent for each. His laptop fans spin up. His API bill for the afternoon is larger than for the rest of the month. Half the subagents fail on rate limits, and some of them start subagents of their own to "split the work".
He stops everything by closing the terminal. Then he cannot tell which files were done.
The second time, the plan has limits: at most 6 workers at once, workers may not start workers, and the whole job may use at most 200,000 tokens. Each finished file is recorded in the state file. A file called GRAPH_PAUSED stops everything cleanly. The job takes a bit longer, and finishes.
What You Will Learn
- When the work list is only known at run time
- How to cap an orchestrator's width, depth and spend
- Why a rate-limited API needs a width cap
- How to record what the orchestrator really started
- How to stop every worker at once
Work Found at Run Time
Most patterns so far had a fixed shape: you knew the nodes before the run. Orchestrator-workers is different. The orchestrator discovers the work, and the number of workers depends on what it finds.
The orchestrator finds the work
const files = {
"src/api/tickets.ts": ["fetch(", "fetch("],
"src/api/users.ts": ["fetch("],
"src/lib/export.ts": [],
"src/App.tsx": ["fetch(", "fetch(", "fetch("],
};
const sites = [];
for (const [file, calls] of Object.entries(files)) calls.forEach((_, i) => sites.push(file + "#" + (i + 1)));
console.log("found " + sites.length + " call sites in " + Object.keys(files).length + " files");
console.log("first three: " + sites.slice(0, 3).join(", "));
Expected output:
found 6 call sites in 4 files
first three: src/api/tickets.ts#1, src/api/tickets.ts#2, src/api/users.ts#1
This is the right pattern when the list really is unknown ahead of time and the items are independent. If you can list the work before you start, a plain fan-out (Lesson 5) is simpler.
Caps: How Wide, How Deep, How Much
An orchestrator without limits does what a busy manager with an unlimited budget does: it hires everyone at once. Three caps stop that.
Width: how many workers run at once. Depth: whether workers may start workers. Spend: the token budget for the whole subtree.
In the graph file, caps go on the orchestrator node:
kill_switch: GRAPH_PAUSED
nodes:
- { id: plan, kind: orchestrator, record_topology: true, caps: { width: 8, depth: 1, spend: 120000 }, reads: [task], writes: [call_sites] }
Width
width is how many workers run at the same time. Extra work waits in a queue. Set it at or below the limit of the thing every worker calls.
Without a width cap, 33 workers hit an API that allows 8. With width 8, they take turns and nothing fails.
Width vs a rate limit of 8
const LIMIT = 8;
function run(workers, width) {
const atOnce = Math.min(workers, width);
const rejected = Math.max(0, atOnce - LIMIT);
return atOnce + " at once, " + rejected + " rejected with 429";
}
console.log("no cap: " + run(33, Infinity));
console.log("width 20: " + run(33, 20));
console.log("width 8: " + run(33, 8));
Expected output:
no cap: 33 at once, 25 rejected with 429
width 20: 20 at once, 12 rejected with 429
width 8: 8 at once, 0 rejected with 429
The rejected workers are the start of the trouble. If they all retry at once, with no wait, they hit the limit again. That is a 429 storm: lots of tokens and time spent being told "no".
Depth
depth says how many levels of workers may exist. depth: 1 means: the orchestrator starts workers, and workers may not start their own workers.
Width capped, depth not
Without a depth cap, a worker that finds a big file may decide to "split the work" and start sub-workers, which may start more. Each level multiplies the count.
How depth multiplies workers
const sites = 18;
const bigFiles = 3; // workers that find a big file
const perSplit = 3;
// Depth 2: each big file is split into 3 more workers.
// Depth 3: those split again, adding 2 per big file.
function workers(depth) {
let total = sites;
if (depth >= 2) total += bigFiles * perSplit;
if (depth >= 3) total += bigFiles * 2;
return total;
}
for (const d of [1, 2, 3]) console.log("depth " + d + ": " + workers(d) + " workers");
Expected output:
depth 1: 18 workers
depth 2: 27 workers
depth 3: 33 workers
Spend
spend is a token budget for the orchestrator and everything under it. When it runs out, the run stops on that bound, and the state file lists what is left. You choose how much the job may cost before you start, not after.
A spend cap that is too low is also a bug. It stops every run before the work is done. Size it from the biggest job you expect, with some room, and treat "stopped on the spend cap" as a signal to look, not as a normal finish.
Record What Really Ran
The graph file says what may run. The realized topology is what did run: how many workers, at what depth, how many retries. Without it, you cannot tell a normal run from a runaway one.
A realized topology record
const record = [
{ node: "plan", copies: 1, depth: 0 },
{ node: "worker", copies: 18, depth: 1 },
];
const total = record.reduce((s, r) => s + r.copies, 0);
console.log(record.map((r) => r.node + " x" + r.copies + " (depth " + r.depth + ")").join(" -> "));
console.log("total nodes run: " + total + ", deepest level: " + Math.max(...record.map((r) => r.depth)));
Expected output:
plan x1 (depth 0) -> worker x18 (depth 1)
total nodes run: 19, deepest level: 1
In the lab, record_topology: true on the orchestrator saves this record with every run.
Spend for the Whole Subtree
The spend cap covers the orchestrator and every worker under it. Here is how a cap of 60,000 tokens plays out on the three versions:
Where a spend cap stops the run
const PER_SITE = 1500;
const CAP = 60000;
for (const sites of [18, 34, 52]) {
const need = sites * PER_SITE;
const done = need <= CAP ? sites : Math.floor(CAP / PER_SITE);
console.log(sites + " sites: needs " + need + ", " + (done === sites ? "all done" : "stopped on the cap after " + done));
}
Expected output:
18 sites: needs 27000, all done
34 sites: needs 51000, all done
52 sites: needs 78000, stopped on the cap after 40
Check the Plan at the Join
Workers can fail quietly. A worker times out, writes nothing, and the join sees results only from the workers that finished. If the join just counts what came back, it reports "all done" for work that never happened.
The fix is a join that compares the plan with the results: the orchestrator's list of call sites against the diffs that came back. Anything missing goes back to the queue.
- { id: join, kind: join, timeout: 15m, policy: all, check_plan: true, reads: [call_sites, diffs], writes: [summary] }
Compare the plan with the results
const plan = ["api/tickets#1", "api/tickets#2", "api/users#1", "App#1", "App#2"];
const results = ["api/tickets#1", "api/users#1", "App#1", "App#2"];
const missing = plan.filter((site) => !results.includes(site));
console.log("planned " + plan.length + ", came back " + results.length);
console.log(missing.length ? "missing: " + missing.join(", ") + " -> back to the queue" : "all done");
Expected output:
planned 5, came back 4
missing: api/tickets#2 -> back to the queue
A Kill Switch
Sometimes you need to stop everything now: the bill climbs, or the workers are breaking things. Closing a terminal is not a plan. It leaves half-finished work and no record.
A kill switch is a simple, agreed signal that every worker checks before each step, such as a file named GRAPH_PAUSED in the repo. Create the file, and every worker stops after its current step and saves its state. Delete it, and the graph can resume from the checkpoint.
In the real tools, the orchestrator is usually the main agent following graph.md, and workers are subagents. Claude Code runs up to 20 subagents at once by default; Codex has a setting for how many agent threads may run at once. Neither knows your API's rate limit. Write the width cap in graph.md and in each worker's instructions.
Lab Exercise: Cap the Orchestrator
Cap the orchestrator
Open the graph file. Change it so that:
- The orchestrator has
caps:withwidthof 8 or less,depth: 1, and aspendbudget big enough for the largest version. - The orchestrator has
record_topology: true. - The join has
check_plan: trueand readscall_sitesas well asdiffs. - The graph has
kill_switch: GRAPH_PAUSEDat the top. (The linter asks for it; the check does not.)
Then press Check my graph. The 3 versions find 18, 34 and 52 call sites. In every version, two workers time out quietly. Every version must migrate all its call sites, with 0 rate-limit errors, the realized topology recorded, and no false "all done".
For the spend cap: each call site costs about 1,500 tokens, and the biggest version has 52. Leave some room above that.
Common mistakes
| Mistake | Wrong idea | Correct idea | What goes wrong |
|---|---|---|---|
| No width cap | "More workers finish sooner." | Width at or below the API's limit. | A 429 storm: retries fail again and again. |
| Width above the limit | "The API limit is just a guideline." | The limit is a hard ceiling. | Every extra worker is rejected. |
| No depth cap | "Workers know when to split." | depth: 1 by default. | Workers multiply level by level. |
| Spend cap too low | "A tiny budget is safest." | Size it for the biggest job, with room. | Every big run stops halfway. |
| No record of what ran | "The graph file says what runs." | The realized topology says what did run. | A runaway looks like a normal run. |
Exercises
Each exercise ends with a value on the last line. Press Check to compare it with the expected answer.
What to Remember
- Orchestrator-workers is for work you can only list while running.
- Cap width at or below the limit of what workers call. Uncapped width causes 429 storms.
depth: 1stops workers from starting workers.- A spend cap bounds the whole subtree. Size it for the biggest job.
- Record the realized topology, and keep a kill switch that stops everything cleanly.
Further Reading
- How to Implement Graph Engineering, by QA Wolf: orchestrator-workers, unbounded orchestrators, and the API-migration worked example.
- Building Effective Agents, by Anthropic: orchestrator-workers for tasks where the subtasks are not known in advance.
- Claude Code subagents: how many subagents run at once, and how to change it.
Next: Evaluator-Optimizer. A fixer goes back and forth between two broken versions, and you write the exits that stop it, then export your first graph.