Parallel Execution
How bounded parallel branches are scheduled, executed concurrently, persisted, and resumed.
Little Workflow: published on npm as little-workflow@0.2.0-alpha.1 (2026-10-06). Sections labeled UNRELEASED describe source-only changes. Release status
parallel is the alpha SDK's single coordination primitive. It fans a step group out across a known array, runs branches concurrently inside one process, fans results back in deterministic order, and treats every branch boundary as durable.
The implementation lives in executeParallelStep() in runtime.ts. This page maps the source onto the events you'll see in the log.
Config Surface
{
"id": "review-candidates",
"uses": "parallel",
"needs": ["load-candidates"],
"with": {
"items": "{{ input.candidates }}",
"itemKey": "{{ item.candidateId }}",
"maxBranches": 25,
"maxConcurrency": 5,
"failureMode": "all_settled",
"fanIn": { "order": "input", "output": "array" }
},
"steps": [/* per-branch step list */],
"output": {
"mode": "array",
"schema": { /* envelope items schema */ }
}
}Validation rules (enforced by LWIR validation):
maxBranches <= 100(alpha cap),maxConcurrency <= maxBranches.failureModeisfail_fastorall_settled.fanIn.orderisinputoritemKey;fanIn.outputisarray.output.schema.itemsdeclares the envelope shape (itemKey,status,output | outputRef,error,artifacts).parallelcannot nest inside anotherparallel.
Branch Identity
Branches are built deterministically from with.items:
type ParallelBranch = {
index: number; // position in the resolved items array
item: unknown; // value of items[index]
itemKey: string; // resolved {{ item.candidateId }}
branchPath: string; // e.g. review-candidates[cand_123]
};itemKey must resolve to a unique non-empty string within the group. branchPath is the parent stepPath suffixed with [<itemKey>]. Per-branch steps then run with stepPath review-candidates[cand_123].score-candidate.
Worker Pool
executeParallelStep() spins up min(maxConcurrency, branchCount) async workers and feeds them from a shared cursor:
A worker exits early when:
- the run signal is aborted (cancellation / timeout),
failureMode: "fail_fast"and another branch already setfailFastCause,- a
fatalCause(terminal runtime error such ascapability_drift) has been observed.
Per-Branch Events
For each branch the runtime appends, in order:
ParallelBranchScheduled—branchPath,itemKey,branchIndex.- The branch executes its
step.steps[]serially as if it were a mini-workflow. Each child step emits the normalStepScheduled→StepAttemptStarted→ … →StepCompletedsequence with paths likereview-candidates[cand_123].score-candidate. - Either
ParallelBranchCompleted(withoutputRefandartifactRefs) orParallelBranchFailed(witherrorand partialartifactRefs).
The branch's output artifact carries the per-branch envelope:
{
"itemKey": "cand_123",
"status": "completed",
"output": { "candidateId": "cand_123", "score": 0.92, "reason": "..." },
"outputRef": "artifact://art_branch_output",
"artifacts": ["artifact://art_prompt", "artifact://art_response"]
}Failure Modes
type FailureMode = "fail_fast" | "all_settled";fail_fast: the first failed branch records its cause as the group failure cause. In-flight branches are still allowed to finish (so their result envelopes are committed), but no new branches start.all_settled: every branch runs to completion or rejection; the group always emitsParallelGroupCompletedwith an envelope containing bothstatus: "completed"andstatus: "failed"entries.
Either way, the parent step's output is the deterministic fan-in array of branch envelopes:
type ParallelResult = Array<{
itemKey: string;
status: "completed" | "failed";
output?: unknown;
outputRef?: ArtifactRef;
error?: SerializedRunError;
artifacts: readonly ArtifactRef[];
}>;fanIn.order: "input" preserves the order in the resolved items[] array; fanIn.order: "itemKey" sorts envelopes by itemKey.
Branch Resume
Parallel is the only step type where a non-terminal attempt is resumable. The serial scheduler refuses to retry a step with a running attempt because it cannot tell whether the previous worker actually completed the side effect. For parallel, that question is answered per branch: each branch is its own commit boundary.
When the runtime starts (or resumes) a parallel step that was already running:
previousOrResumableBranchResultFor()looks at the persisted events.- Branches whose envelope artifact was already written get reused without re-running.
- Branches with
ParallelBranchScheduledbut no terminal event are reissued. The worker pool re-enters them throughbranchResultFromStateForResume(), which short-circuits if the branch happens to have committed since (e.g. another process flushed an event). - Missing terminal events for resurrected branches are backfilled after the branch settles.
This is the invariant the kill-and-resume demo exercises: kill the parent process after ParallelBranchCompleted for one branch, resume, watch the remaining branches finish, and confirm no completed branch was re-executed.
Branch Final Output
When a branch terminates, its output contributed to fan-in is determined as follows:
- If the parallel group declares
fanIn.outputStep: "<stepId>", that step'slastOutputis used. - Otherwise, the most recently committed non-
decisionstep in the branch contributes itslastOutput. Decision steps never contribute fan-in output.
Rule 2 preserves today's behavior for single-path DAG branches (the topologically-last step's output, which is also its lastOutput). It defines the otherwise-ambiguous case where a branch has multiple terminal candidates because a decision step routed through different paths.
A decision step inside a parallel branch routes within that branch only; to may not target a step outside the enclosing parallel scope (decision.target_not_in_scope).
Branch Output Validation
Before ParallelBranchCompleted is committed, the branch's terminal output is validated against the parent step's output.schema.items plus the per-branch step's own contract. Two things are checked:
- The branch envelope shape (
itemKey,status,output | outputRef,artifacts). - The runtime invariant
parallel.invalid_fan_in: Parallel output items must include itemKey, status, and runtime result fieldsis enforced when the schema rejects an envelope — usually because the LWIR didn't declare the required fields.
Terminal Group Events
After the workers drain, the runtime appends ParallelGroupCompleted (if there were no failures, or failureMode: "all_settled") or ParallelGroupFailed (fail_fast triggered, or fatal cause). Either way, the parent step still goes through the normal executeStep() finalization (ArtifactCreated, StepOutputValidated, StepCompleted) with the assembled array as its output. So at the top level a parallel step is indistinguishable from any other step's commit; downstream readers consume steps.review-candidates.output[0].output exactly the same way they would consume any structured result.
Within-Workflow vs Pipeline-Level Fan-Out
The parallel step described above is a within-workflow construct. It fans out over a known array of items, runs each branch's step list in the same process, and fans results back into a deterministic array output — all within one LWIR execution.
Pipeline-level fan-out is a different mechanism. When an Orchestrator session (see Harness) needs to run N independent sub-workflow instances in parallel, it does so by emitting parallel tool calls in a single agent loop turn — not by using the parallel LWIR step. The model emits multiple run_workflow (or start_workflow) tool-call blocks in one turn; the harness dispatches them concurrently, each as its own sub-run with its own WorkflowVersion, event log, and per-step durability.
Summary of the distinction:
| Scenario | Mechanism | Unit of concurrency | Durability boundary |
|---|---|---|---|
| Within-workflow: fan out over an items array | parallel LWIR step | Branch within a step | ParallelBranchCompleted event |
| Pipeline-level: fan out across multiple sub-workflow runs | Orchestrator parallel tool calls (one model turn) | Sub-workflow run | Sub-run's own per-step event log |
The maxBranches, maxConcurrency, and fanIn fields documented above apply only to the within-workflow parallel step. Pipeline-level concurrency is controlled separately via maxConcurrentSubRuns on runWorkflow.
What Alpha Parallel Is Not
- Not a generic foreach. The branch set is fixed at scheduling time from a resolved expression.
- Not high-volume.
maxBranches <= 100andmaxConcurrency <= maxBranchesare alpha caps; sharded fan-out and tree reducers are post-alpha. - Not cross-process. Concurrency is
Promise.allSettledinside one process. Distributed branch workers are a beta+ concern. - Not a reducer. Fan-in is the deterministic array; downstream steps reduce as a separate step (often
ai.generateorcode.run). - Not the pipeline fan-out mechanism. Use the Orchestrator's parallel tool calls for running N sub-workflow instances concurrently (see above).
That is enough to ship the alpha demo and most real workloads that fan out to AI workers (candidate review, ticket batches, document scoring). It is intentionally not enough to be confused with a queue.