Little documentation
Guides and APIInternals

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.
  • failureMode is fail_fast or all_settled.
  • fanIn.order is input or itemKey; fanIn.output is array.
  • output.schema.items declares the envelope shape (itemKey, status, output | outputRef, error, artifacts).
  • parallel cannot nest inside another parallel.

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:

100%

A worker exits early when:

  • the run signal is aborted (cancellation / timeout),
  • failureMode: "fail_fast" and another branch already set failFastCause,
  • a fatalCause (terminal runtime error such as capability_drift) has been observed.

Per-Branch Events

For each branch the runtime appends, in order:

  1. ParallelBranchScheduled — branchPath, itemKey, branchIndex.
  2. The branch executes its step.steps[] serially as if it were a mini-workflow. Each child step emits the normal StepScheduled → StepAttemptStarted → … → StepCompleted sequence with paths like review-candidates[cand_123].score-candidate.
  3. Either ParallelBranchCompleted (with outputRef and artifactRefs) or ParallelBranchFailed (with error and partial artifactRefs).

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";
100%
  • 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 emits ParallelGroupCompleted with an envelope containing both status: "completed" and status: "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:

  1. previousOrResumableBranchResultFor() looks at the persisted events.
  2. Branches whose envelope artifact was already written get reused without re-running.
  3. Branches with ParallelBranchScheduled but no terminal event are reissued. The worker pool re-enters them through branchResultFromStateForResume(), which short-circuits if the branch happens to have committed since (e.g. another process flushed an event).
  4. 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:

  1. If the parallel group declares fanIn.outputStep: "<stepId>", that step's lastOutput is used.
  2. Otherwise, the most recently committed non-decision step in the branch contributes its lastOutput. 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 fields is 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:

ScenarioMechanismUnit of concurrencyDurability boundary
Within-workflow: fan out over an items arrayparallel LWIR stepBranch within a stepParallelBranchCompleted event
Pipeline-level: fan out across multiple sub-workflow runsOrchestrator parallel tool calls (one model turn)Sub-workflow runSub-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 <= 100 and maxConcurrency <= maxBranches are alpha caps; sharded fan-out and tree reducers are post-alpha.
  • Not cross-process. Concurrency is Promise.allSettled inside 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.generate or code.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.

On this page