Guides
Parallel execution
Independent branches genuinely run at the same time — not interleaved, actually concurrent. This page defines the exact scheduling rules so you can predict, wave by wave, what any graph does.
Fan-out and fan-in
Fan-outis just edges: a node with two outgoing edges starts two branches. There is no special "parallel" node — concurrency is a property of the graph shape. Fan-in is the merge node: a barrier that waits until everyincoming branch has completed before it runs. A merge is ready only when all of its predecessor nodes are in the run's completed set.
┌──▶ research ──┐
qualify ─┤ ├──▶ join (merge) ──▶ handoff
└──▶ outreach ──┘
"edges": [
{"id": "e1", "source": "qualify", "target": "research", "condition_value": "true"},
{"id": "e2", "source": "qualify", "target": "outreach", "condition_value": "true"},
{"id": "e3", "source": "research", "target": "join"},
{"id": "e4", "source": "outreach", "target": "join"},
{"id": "e5", "source": "join", "target": "handoff"}
]
Here research and outreach execute concurrently; the merge holds handoff back until both have finished, so handoff sees both results in state.
The wave model
Scheduling is a loop over waves, and it is fully deterministic:
- the engine collects every currently-runnable node — all queued nodes except human gates, and except merges whose branches are not yet all complete — into one wave
- the wave launches concurrently; as each node finishes, a checkpoint is written and the node's successors are appended to the queue for a later wave
- a merge becomes runnable the moment its last incoming branch completes; until then it is held back (deferred) and re-checked at the top of every wave
- the loop ends when no node is runnable — either the run is complete, or it has parked at a gate
Two independent 600 ms branches therefore cost about 600 ms, not 1200 ms — a timing test in the suite asserts exactly this.
The concurrency ceiling
Concurrency within a run is bounded by an asyncio.Semaphore of MAX_PARALLEL_NODES = 4. A wave may contain more than four runnable nodes — the engine launches them all as tasks, but the semaphore lets only four execute at a time; the rest wait their turn and start as slots free up. This protects downstream systems and provider rate limits from a wide fan-out while keeping the graph simple.
| Field | Type | Default | Description |
|---|---|---|---|
| MAX_PARALLEL_NODES | 4 | per run | Fixed engine constant. Bounds concurrently-executing nodes within a single run via an asyncio semaphore. |
| wave size | unbounded | — | All runnable nodes are gathered into a wave; the semaphore, not the wave, caps actual concurrency. |
A checkpoint after every node
Each node in a wave is checkpointed individually as it completes, not once per wave. The checkpoint records the finished node in completed and keeps every still-running sibling in the queue. So if the process dies with two of four branches done, resume keeps those two and re-runs only the two that were mid-flight — the wave does not restart from zero. This is the same checkpoint machinery documented in Reliability & checkpoints.
Failure inside a wave is isolated the same way. If one node fails, its siblings finish and commit their results; only then does the run settle as failed, with the failing node at the head of the checkpoint queue. Resume re-runs only that node — the siblings' work is already durable.
Branch selection on condition and loop edges
Fan-out edges out of a condition or loop node are labelled with a condition_value. The engine follows only the edges whose value matches the node's result — the true edges when the expression is truthy, the false edges otherwise. Edges out of any other node type carry no condition and always fire. A single condition can fan out to several nodes on the same branch — two edges both labelled true start two parallel branches.
Shared state across branches
All branches read and write one shared state dictionary. Writes are per-key, so branches that write distinct output_keys compose cleanly — that is why the merge downstream can see both research_brief and outreach_email.
Key collisions are last-write-wins
If two parallel nodes write the sameoutput_key, whichever finishes last wins — and finish order is not guaranteed. Give parallel branches distinct output keys; treat a shared key as a bug, not a feature.The RevOps template, traced wave by wave
The seeded RevOps Lead Qualification template is the canonical parallel graph: score a lead, branch on whether it qualifies, then run research and outreach concurrently before merging and handing off to sales. Disqualified leads take a separate, single-node path.
┌──▶ research ──┐
score ──▶ qualified ──(true)──┤ ├──▶ join ──▶ handoff
│ └──▶ outreach ──┘ (merge)
└──(false)──▶ archive
"nodes": [ score (agent), qualified (condition), research (agent),
outreach (agent), join (merge), handoff (tool), archive (tool) ]
"edges": [
{"source": "score", "target": "qualified"},
{"source": "qualified", "target": "research", "condition_value": "true"},
{"source": "qualified", "target": "outreach", "condition_value": "true"},
{"source": "qualified", "target": "archive", "condition_value": "false"},
{"source": "research", "target": "join"},
{"source": "outreach", "target": "join"},
{"source": "join", "target": "handoff"}
]
For a lead that qualifies, the executor produces this exact wave sequence:
wave 1 [score] -> writes score; queue: [qualified]
wave 2 [qualified] -> condition true; follows the two 'true' edges
queue: [research, outreach]
wave 3 [research, outreach] -> run CONCURRENTLY (2 <= 4); each checkpoints
on completion and queues 'join'
join is a merge, not ready until BOTH done -> deferred
wave 4 [join] -> both predecessors complete; barrier clears
merge runs; queue: [handoff]
wave 5 [handoff] -> notify sales; queue empty -> run success
A lead that does not qualify skips all of that: from qualified the engine follows only the false edge to archive, which runs alone and completes the run. research, outreach, and join are never queued.
Run it and watch the two branches land together:
$ curl -s -X POST http://localhost:8000/api/workflows/{revops_id}/runs \
-H 'content-type: application/json' \
-d '{"input": {"lead": "Acme Corp — 400 employees, requested a demo"}}'
# in the finished run, state carries each branch's output_key (merge/condition
# nodes don't write state — only agent and tool nodes do):
# "score": "...", "research_brief": "...", "outreach_email": "...",
# "handoff_result": {"sent": false, "simulated": true}
A merge waiting on a gated branch survives the pause
Merges and human gates compose. If one branch of a fan-out reaches a gate while a sibling branch has already completed a merge's other input, the run pauses — but the deferred merge is preserved in the gate checkpoint's queue, so it is not lost. After approval the gated branch runs, completes the merge's last input, and the barrier clears — correctly, even across a backend restart while paused. The full sequence is walked through in Human-in-the-loop.
Limits worth knowing
- Concurrency ceiling is four nodes at once per run — a fixed engine constant today, not per-node configurable
- A merge waits for all its predecessors. Route it only from branches that actually run: a merge fed by a branch that can never complete (for example the unused side of a condition) waits forever by design
- Finish order within a wave is not deterministic even though the wave set is — never depend on which sibling completes first; depend only on the merge that joins them
output_key and let a merge (or a downstream node that reads several keys) combine them. See the node reference for the full config of merge, condition, and loop nodes.