Fan-Out / Join (v1.1)
Fan-Out / Join is the sanctioned parallelism extension to ALP. It relaxes the Linearity Constraint in exactly one way: a Station's Agent may spawn N Child Tasks that run in parallel at a downstream Station, and the spawning Task waits at a Join Station until every child has Settled before its own Job there is dispatched.
Everything else about the Assembly Line stays linear. There is no general graph execution, no conditional branching, and no fan-out of the Assembly Line itself — only Tasks fan out.
Concepts
| Term | Definition |
|---|---|
| Fan-Out | The act of a Station's Agent spawning N Child Tasks at a downstream Station via the fan-out API. The spawning Task becomes the join task. |
| Child Task | A Task Card created by a Fan-Out. It carries a reference to its parent Task and flows through Transition Rules like any other Task — with one exception: arriving at its parent's Join Station settles it instead of dispatching it. |
| Join Station | The Station where the parent Task waits for its children. Children collect here visually; the parent's Job at this Station is dispatched only when the join releases. |
| Settled | A per-Task resting predicate: the Task's work reached a final outcome (success or failure) and nothing further was dispatched for it. The join waits on Settled — not on raw Job conclusions. Every dispatch clears Settled. |
| Join Release | The at-most-once event that consumes a join: child results are aggregated onto the parent and the parent's held dispatch (if any) is fired. |
The Parent-as-Join Model
There is no separate "join task". The parent Task is the join.
- A Task reaches a Station whose Agent decides the work decomposes (or whose trigger declares a Fan-Out — see Declarative Authoring).
- The Agent calls the fan-out API on its own Task. The Server creates N Child Tasks at the requested Station and records the child ids on the parent as its blocker list (
blockedBy; reference implementation:blockedByTaskIds). - The parent's Job concludes as normal and the parent moves on via its Transition Rules — typically to the Join Station.
- Any dispatch of the parent while it has unsettled blockers is held (the Task enters the
heldstate, a sibling ofawaiting_review— see 05-tasks.md). The hold is Station-independent: wherever the parent is dispatched, unsettled blockers hold it.joinStationis where children settle, not a precondition for the hold. - Children execute in parallel (real parallelism requires multiple registered Runners — one Runner still runs one Job at a time, see 08-runner.md).
- When the last blocker settles, the join releases exactly once: child results are aggregated onto the parent and the held dispatch fires.
Because children flow through the line's ordinary Transition Rules, authors write the natural rule childStation → joinStation on success. Rule matching is parentage-blind by design; the arrival-settles rule (below) is what prevents a child from ever running the Join Station.
Fan-Out API
POST /api/owners/{owner}/projects/{project}/assembly-lines/{assemblyLineId}/tasks/{taskId}/fan-out
Authorization: Bearer {token}
{
"columnId": "research",
"tasks": [
{ "title": "Research topic A", "description": "...", "labels": ["claude-code"] },
{ "title": "Research topic B", "description": "..." }
],
"joinColumnId": "synthesize"
}
Naming note: on the wire the fields are
columnId/joinColumnId— the reference implementation's internal name for a Station is "column" (see the naming crosswalk in 05-tasks.md). In spec prose these are the Station where children spawn and the Join Station. Earlier documents in this spec also write the legacy/stages/{assemblyLineId}path segment; the reference implementation serves/assembly-lines/{assemblyLineId}.
| Field | Type | Required | Description |
|---|---|---|---|
columnId | string | yes | Station id where the Child Tasks are created (and dispatched, if the Station's trigger is dispatch_worker) |
tasks | {title, description?, labels?}[] | yes | The Child Task Cards, 1–20 per call |
joinColumnId | string | no | The Join Station id, recorded on the parent. Strongly recommended: without it children never settle by arrival and must settle by resting outcome only |
Response: { "childTaskIds": string[], "joinColumnId"?: string, "reused": boolean, "warnings": string[] }
Auth: the same bearer chain as Task submission (see 07-server.md) — a user token, a GitHub Actions OIDC token trusted by the project/line, or the Runner's own token. The reference pattern is the Agent calling the endpoint from inside its sandbox using the token the Operator environment already carries.
Idempotent reuse
While the parent has an unreleased join (a non-empty blocker list and no Join Release yet), repeated fan-out calls do not spawn duplicates: the Server responds 200 with the existing child ids and "reused": true. Agent-side curl retries and re-run Stations are therefore safe. A genuinely new Fan-Out is accepted only when the parent has no join state or its previous join was released — in that case the join re-arms: the blocker list, Join Station, and child results are reset, and the previous children remain on the board as ordinary settled cards.
Caps
| Cap | Value | On violation |
|---|---|---|
| Children per fan-out call | 20 | 400 |
| Fan-out nesting depth (a child fanning out again) | 2 | 400 |
| Active (unsettled) children per Assembly Line | 50 | 429 |
Settle Rules
Settled is the predicate the join waits on. A Task settles when:
| # | Event | Conclusion |
|---|---|---|
| 1 | Its Job concludes success or failure and no further dispatch was chained (no Transition Rule matched, or the rule moved it to a Station that did not dispatch, and no Gate hold intervened) | the Job's conclusion |
| 2 | It is a Child Task arriving at its parent's Join Station — whether via a Transition Rule or a manual move. It settles instead of dispatching; the Join Station's Job never runs for a child | the arriving Job's conclusion (manual move: success) |
| 3 | It is a Child Task manually moved to any Station that does not dispatch (e.g. a Done column) | success |
Settle is idempotent per Job — a re-delivered completion report cannot settle the same Task twice.
A Task does not settle when:
| Situation | Why |
|---|---|
The Job ended with an abnormal conclusion (idle_timeout, timeout, incomplete) | Not a resting outcome — Transition Rules are skipped and the Task stays in place awaiting human retry |
The Task has a pending question or is blocked at a Gate (awaiting_review) | Work is suspended, not finished |
The Task is itself a fan-out parent whose dispatch is held | A held inner parent has not finished — nested joins wait on the whole subtree |
The Job was cancelled (e.g. the Runner died and the Server swept the stale Job) | Surfaced as actionable; a human retries the child or force-releases the join. Runner deaths never silently release a join |
Every dispatch clears Settled. This is what makes retries coherent: retrying a settled child before the join releases re-arms its slot (the join waits for it again); retrying after the release is a detached re-run that cannot re-trigger the released join.
A child that settles with failure does not deadlock the join — the join waits for all children to settle, not to succeed. The Join Station's Agent sees each child's conclusion and decides what a partial result means.
Join Release
The release is at-most-once and atomic. When a settle event finds every blocker settled, in a single critical section the Server:
- Aggregates
childResultson the parent — for each child:{ title, conclusion, result }, whereresultis the child's final Station output (its completion summary). A child that was deleted appears as a tombstone with conclusionmissing. - Stamps the release marker (
joinReleasedAt). The stamp is the consume: concurrent settle events observing the marker back off, so two children settling simultaneously cannot double-release. - If a dispatch was actually held, clears the hold and fires the parent's dispatch at its current Station — exactly once.
If the children all settled before the parent ever arrived at a dispatching Station (fast children), no hold exists; the aggregation instead happens inline at the parent's next dispatch, in the same critical section that would otherwise have held it. Either way the Join Station's prompt never sees empty results.
Template variables
The Join Station's promptTemplate may use:
| Variable | Value |
|---|---|
{{children.results}} | Markdown digest of childResults: per child, its title, conclusion, and result |
{{children.count}} | Number of children in the join |
Force release
A human (or a supervising Agent, via MCP) can give up waiting:
POST /api/owners/{owner}/projects/{project}/assembly-lines/{assemblyLineId}/tasks/{taskId}/release-join
Authorization: Bearer {token}
{ "reason": "child stuck on dead runner" }
A force release aggregates whatever has settled — children that have not settled appear in childResults with conclusion unresolved — stamps the release, and fires the held dispatch if one existed.
Two human actions are implicit force releases of a held parent, so the board never fights the human:
- Manually moving a held parent to another Station
- Manually re-dispatching a held parent (retry/continue)
Child Workspace — Amendment to Decision Q12
Decision Q12 stands: the Assembly Line Repository is the only sanctioned channel for passing work between Stations. Fan-Out amends it minimally:
A Child Task shares its parent's Assembly Line Repository.
At spawn, each child inherits the parent's repository reference — same URL, same credentials scope. Children write their outputs under:
reports/children/<child-task-title-slug>/
so the Join Station's Agent clones the one repository and reads every child's artifacts. childResults / {{children.results}} carry only the per-child completion summaries — the artifacts themselves travel through the shared repository, exactly as Q12 requires.
If the parent has no Assembly Line Repository, children run repo-less and only their completion summaries flow into the join. That is acceptable for light lines; lines that fan out real work should provision the repository before the fan-out Station.
Declarative Authoring: `StationTrigger.fanOut`
A Station can declare its Fan-Out up front, so the Server injects the protocol into the Agent's prompt instead of relying on the line author to hand-write the API call:
{
"id": "scope",
"name": "SCOPE",
"trigger": {
"type": "dispatch_worker",
"labels": ["claude-code"],
"promptTemplate": "Break the task into research topics...",
"fanOut": {
"childColumnId": "research",
"joinColumnId": "synthesize",
"maxChildren": 5,
"instructions": "One child per research topic. Keep titles short."
}
}
}
| Field | Type | Description |
|---|---|---|
childColumnId | string | Station where children spawn |
joinColumnId | string | The Join Station |
maxChildren | number? | Guidance cap for the Agent (the 20-per-call server cap still applies) |
instructions | string? | Extra guidance injected into the protocol prompt section |
When set, the Server appends a fan-out protocol section to the rendered prompt containing the exact API call for this Task — the Agent only decides how many children and what each Task Card says.
Disambiguation: Two Things Called "fan-out"
These are different features and must not be conflated:
OperatorConfig.orchestration.fanOut | Task-level Fan-Out (this document) | |
|---|---|---|
| Granularity | Inside one Agent session | Across Task Cards |
| What runs in parallel | Subagents spawned by the Agent's own orchestration tooling | N Child Tasks, each a full Job in its own sandbox |
| Visible on the board | No — one card, one Job | Yes — N child cards flow through Stations |
| Join semantics | None — the session ends when the Agent ends | Settled predicate + Join Release on the parent |
| Spec status | Implementation hint (reference implementation only) | Normative v1.1 extension |
orchestration.fanOut is a prompt hint telling a single Station Operator's Agent to parallelize within its session. Task-level Fan-Out creates real Tasks with real Jobs, real sandboxes, and real Runner scheduling.
Interaction with Existing Machinery
- Transition Rules (06-transition-rules.md): unchanged. Children match rules like any Task; the arrival-settles rule is evaluated by the dispatch path, not the rule matcher.
- Gates: a Gate on the parent's path behaves normally; a gated parent is
awaiting_review, not settled and not held. Gates on child paths pause that child — the join waits. - Task lifecycle (05-tasks.md): adds the
heldstate and the Settled predicate. - Runner protocol (08-runner.md): completely unchanged. Runners cannot tell a Child Task's Job from any other Job.
- Nesting: a Child Task may itself fan out (depth cap 2). Its own held state keeps it unsettled, so the outer join waits for the entire subtree.
Worked Example
See examples/fan-out-join.md — a four-Station research line: SCOPE (fan-out) → RESEARCH (children) → SYNTHESIZE (join) → DELIVER.