17 KiB
Parallel fan-out / fan-in strategy
Status: proposed (not yet implemented). Line numbers reference the tree at the time of writing and will drift.
This document specifies the intended product behavior of parallel fan-out
(shape=component) and fan-in (shape=tripleoctagon). Appendix A catalogs
pre-existing bugs this design resolves. Appendix B lists behavior changes
relative to today's implementation.
1. Introduction: goals and current weaknesses
The goal of this design is a parallel execution model that is simple, coherent, and correct:
- Simple — a user should be able to predict what a fan-out/fan-in does from the graph alone. One mental model ("branches produce candidates; fan-in picks a commit; downstream sees all responses"), no new syntax for synthesis, no incantations (special fidelity settings, escape-hatch attributes) to make the flagship patterns work.
- Coherent — the same rules apply regardless of node type or channel. What holds for a sequential command node's output should hold for a branch command node's output; the workspace (git) facet and the text (context) facet should follow parallel logic; selection should live in exactly one place.
- Correct — the engine must do what the graph, the docs, and the recorded run state say it does. Nodes drawn in the graph must run; documented outputs must exist; a judge asked to pick the best candidate must be shown the candidates.
Today's implementation misses all three. Issue #490 is the visible symptom:
a synthesis node after fan-in never sees branch responses — only
{id, status, head_sha} metadata — while two tutorials promise the opposite.
Investigation showed a broader incoherence:
- Inherited spec gap. The Attractor spec (fabro's ancestor) deliberately
isolates branch context and never defines a channel for branch output text.
Its fan-in pseudocode judges candidates it cannot see (
llm_evaluatereceives only statuses) and sorts by ascorefield nothing sets. Fabro inherited this gap faithfully. - Missing compensation. Kilroy (a sibling Attractor implementation)
compensates with a file/git handoff: the post-merge node's prompt is
injected with each branch's
worktree_dir,logs_root, andhead_shaplus instructions to read/merge them. Fabro has no equivalent, but its tutorials promise kilroy-like behavior ("the synth node receives all four perspectives in its preamble"). - Accidental semantics. Several adjacent behaviors are unprincipled accidents rather than decisions: branches execute a single node and silently skip chained nodes; two handlers fast-forward to two different definitions of "winner"; branch nodes run with a stale preamble built for the fan-out node; selection is vacuous in both modes; nested fan-outs silently lose git isolation. Appendix A catalogs these.
2. Core model: candidates
A parallel branch is an isolated unit of execution that produces a candidate. A candidate has exactly three facets:
| Facet | Content | Carried by |
|---|---|---|
| Commit | Workspace state produced by the branch | Per-branch git commit (head_sha) |
| Response | Text output (LLM response, command output) | Branch stage records + context keys |
| Verdict | Terminal status plus optional numeric score |
parallel.results entries |
Fan-out produces N candidates in isolation. Fan-in selects one commit to
continue on. Downstream nodes (synthesis) get all responses. Selection and
synthesis are distinct concerns: selection needs a node (tripleoctagon);
synthesis is any ordinary downstream node, because responses propagate.
The post-fan-in contract, in one sentence: after fan-in, the run looks as if every branch had run sequentially, and the winner ran last.
3. Topology: branches are subgraphs
Each outgoing edge of a fan-out node starts a branch. A branch executes as a
subgraph walk: the engine traverses nodes and edges from the branch entry node
until it reaches the join node (the fan-in). Multi-node chains
(fork -> plan_a; plan_a -> impl_a; impl_a -> merge) run every node in the
chain. This matches the Attractor spec (execute_subgraph) and kilroy
(runSubgraphUntil); today fabro executes exactly one node per branch and
silently skips the rest (Appendix A.2).
Structural validation (lint):
- Every path from a branch entry must converge on the run's join node. A branch path that escapes the join (reaches exit, or a node outside the fan-out region) is a validation error.
- A nested
componentnode inside a branch is rejected until worktree-from-worktree isolation is implemented (today it silently runs with no git isolation; Appendix A.6). fidelity="full"on a fan-in's outgoing edge gets a lint warning: branches run on different threads, so full fidelity can never carry branch outputs (it drops the preamble entirely).
4. Node types in branches
No type-based restrictions. Restriction is structural (§3), not by allowlist:
- Agent / prompt nodes — the primary case.
- Command / script / tool nodes — first-class. Deterministic fan-out
(test matrices, benchmark bake-offs across worktrees) is a supported pattern
with no LLM anywhere: branches emit
scorevia status fields, heuristic selection picks the winner by measurement. - Conditionals — meaningful under subgraph branches: they route within the branch.
- Human gates — allowed; each branch may pause independently.
- Nested parallel — rejected by lint until isolation composes (§3).
5. Git isolation
Unchanged mechanics, with ownership fixed:
- Before fan-out, checkpoint the sandbox to produce
base_sha. - Each branch gets a worktree on a branch ref
(
fabro/run/parallel/<run>/<node>/pass<N>/<branch>), rooted atbase_sha. The branch'sinternal.work_dirpoints at the worktree. - After a branch completes,
git add -A+ commit (--allow-empty) yields the candidate'shead_sha. - Worktrees are removed after the join. Loser branch refs are kept so
downstream nodes and humans can
git show/git diffany candidate. - Fan-in exclusively owns the fast-forward. The parallel handler performs
no merge. After selection, fan-in fast-forwards the primary workspace to the
winner's
head_sha. (Today both handlers fast-forward, to potentially different winners; Appendix A.3.)
Degradation without git (no repo, or git isolation disabled): branches share
the primary sandbox with no workspace isolation, head_sha is absent from
candidates, and fan-in performs no merge. Response and verdict facets work
unchanged — prompt-only ensembles do not require git.
6. Execution and stage recording
Branch nodes execute as real stages, recorded through the normal
ExecutionState::record path and namespaced under the fan-out
(e.g. stage a@1 within fork@1). Consequences (all fixes to current
behavior):
- Branch prompts/responses appear in events,
fabro dump, and the web UI as ordinary stages. - Each branch node gets a freshly built preamble for its own position, via the standard lifecycle, instead of reusing the fan-out node's stale preamble.
- Branch stages participate in the standard retry policy per node.
7. Context merge-back at fan-in
When fan-in completes, branch context updates are applied to the parent context with a collision rule:
- Per-node keys (
response.<branch_node_id>, structured-output fields namespaced by node) apply for all branches. Branch node IDs are unique, so no collisions. - Singleton keys (
last_stage,last_response,command.output, un-namespaced status fields) are taken from the winner only. - Failed branches'
response.<id>values are still applied (a synthesis node analyzing disagreement wants to see the failure text). Their singletons are never applied.
Fan-in additionally writes (as today):
parallel.results— one entry per candidate:{id, status, head_sha?, score?}.parallel.branch_count,parallel.fan_in.best_id,parallel.fan_in.best_outcome,parallel.fan_in.best_head_sha.
No file is materialized into the run workspace. parallel.results reaches LLM
consumers through the preamble's context section, and agents can git show
any candidate via its head_sha. (The current docs claim
parallel_results.json is available to downstream nodes; that claim is false
today and should be corrected rather than implemented — see open question 4
for the one consumer this leaves unserved.)
8. Selection
Fan-in selects the winning candidate. Two modes, as today, but with real signal:
- Heuristic (no prompt on the fan-in node): rank by status
(succeeded < partially_succeeded < failed), then
scoredescending, then lexical id.scorebecomes settable: branches emit it via structured-output / status fields, which now survive intoparallel.results(§7). - LLM judge (fan-in node has a
promptand a backend is configured): the judge prompt includes, per candidate: id, status, score, a bounded response excerpt, andgit diff --statvs.base_shawhen git isolation is active. Today the judge sees only{id, status, head_sha}and cannot possibly discriminate (Appendix A.4).
Selection determines the commit facet only. It does not suppress loser responses (§7) or loser refs (§5).
9. Downstream visibility (preamble)
Prompt templates render once at manifest build time with {goal, inputs}
only; the preamble is the sole channel for runtime context into a
fresh-session node. Therefore:
- At
compact(default) fidelity, branch stage summaries render their responses inline, bounded, with aSee: <blob path>reference when truncated — the same treatmentcommand.outputalready receives at compact. Rationale: post-fan-in branch responses are unrecoverable through any fidelity setting (different threads), exactly like command output. - The per-branch response budget is larger than command output's 25-line tail (ensemble analyses front-load their substance; a small tail amputates it). Exact budget TBD at implementation; must remain bounded so N long branches cannot blow the downstream context. Agent-type synthesis nodes can read the full text from the blob/artifact reference.
summary:highrenders the same with its larger budget.truncatecarries goal only (explicit opt-out).fullremains the degenerate case and lints (§3).
10. Join policies
wait_all(default): all branches run to completion; join proceeds when all are terminal. Succeeds if no branch failed, else partially succeeds (fan-in fails only when all candidates failed).first_success: join proceeds at the first successful branch. Remaining branches are cancelled; cancelled branches record a terminal cancelled stage (their partial responses are not merged back). The sole successful branch is the winner.k_of_n/quorum(kilroy has them): deliberately not added now. The surface stays minimal until a concrete need appears.
Open questions
- Implementation phasing: subgraph branches (§3) are the largest lift. Response propagation (§6–§9) fixes #490 and both tutorials on its own and can ship first.
first_successcancellation semantics for in-flight agent sessions (graceful stop vs. abort; what the cancelled stage records).- Exact preamble budget per branch response (§9).
- Context access for deterministic post-fan-in consumers. Command/script
nodes have no channel to context (no preamble, no template rendering in
script), so a deterministic aggregator after fan-in cannot learn candidatehead_shas. Candidate mechanisms: a results file under a checkpoint-excluded workspace path, or an env var (e.g.FABRO_PARALLEL_RESULTS) pointing at a file outside the workspace. A bare workspace file is ruled out: checkpoint commitsgit add -A, so it would leak into run history and PRs. Design alongside the deterministic fan-out pattern (§4).
Appendix A: pre-existing bugs
Cataloged against the current tree; line numbers will drift.
- Branch outputs dropped (#490). The fan-out task reads only
outcome.statusandhead_shafrom each branch; branchcontext_updates(includingresponse.<id>) are discarded with the forked context (fabro-workflow/src/handler/parallel.rs:389-397,465-470). No channel carries branch text to downstream nodes. Confirmed by live repro on fabro-testing (run01KX148ZAMMJRAADHK1HBF7PC3, server 0.287.0-nightly.0). - Chained branch nodes silently skipped. Branches execute exactly one
node; the engine then jumps to the join (
parallel.rs:388-397,612,628;fabro-core/src/executor.rs:421). Infork -> a; a -> a2; a2 -> merge,a2never runs and nothing warns. - Double fast-forward with two different winner definitions. The parallel
handler fast-forwards the lexically first successful branch
(
parallel.rs:511-538); fan-in then fast-forwards its selected winner (fan_in.rs:120-131). If selection ever picks a non-lexical-first branch, the second--ff-onlymerge cannot succeed (sibling commits diverge). Masked today only because selection is vacuous (A.4). - Selection is vacuous. Heuristic tie-breaks on a
scorefield nothing can set (scores would arrive via branch context updates, which are dropped per A.1). The LLM judge prompt isserde_json::to_string_prettyofparallel.results— id/status/head_sha only (fan_in.rs:247-250). Both modes reduce to "first successful branch, alphabetically." - Branch preambles are stale. Branches run via
dispatch_handler, bypassing the lifecycle's per-node preamble rebuild; each branch node inheritscurrent.preambleas computed for the fan-out node itself. - Nested parallel silently loses git isolation. Branch
EngineServicesare built withgit_state: RwLock::new(None)(parallel.rs:380), so acomponentnode inside a branch runs its own branches with no worktrees and no warning. - Docs contradict the engine.
tutorials/ensemble.mdx:85andtutorials/parallel-review.mdx:81claim the post-merge node receives all branch perspectives in its preamble (false, per A.1).workflows/stages-and-nodes.mdx:195claims merged results are available to downstream nodes asparallel_results.json(the file exists only understages/<fork>@1/in dumps, not in any node's working directory). fidelity="full"across a fan-in is a trap. It drops the preamble (metadata included) and cannot attach to any branch thread; raising fidelity strictly reduces what the downstream node sees. No lint warns.
Appendix B: behavior changes vs. today
Changes a user could observe if this spec is implemented as written.
- Chained branch nodes execute. Graphs that (unknowingly) relied on single-node branch semantics will now run the full chain (fixes A.2; may lengthen existing runs).
- New validation errors. Branch paths that don't converge on the join,
and nested
componentnodes inside branches, become lint failures for graphs that previously ran (with wrong or silently degraded semantics). - Fan-in owns the fast-forward. The workspace after fan-in may land on a different commit than today whenever selection (scores, LLM judge) disagrees with lexical-first order. The parallel handler no longer merges.
- Post-fan-in context is richer.
response.<id>for every branch, winner-sourced singletons (last_stage,last_response,command.output), andscoreinparallel.results. Today those singletons retain their pre-fork values; workflows with edge conditions over them could route differently. - Preambles after fan-in grow. Branch responses render inline at
compactfidelity (bounded). Downstream nodes see more tokens per run; snapshot tests over preambles will churn. - Branch executions become visible stages. Events, dumps, the web UI, and
the stage list gain per-branch stages (
a@1underfork@1). Consumers ofevents.jsonl/ the API will see new stage records. parallel_results.jsondocs claim is corrected, not implemented.workflows/stages-and-nodes.mdx:195is updated to describe the real channels (context key + preamble); no file appears in the workspace (open question 4 covers deterministic consumers).- Loser branch refs are documented as retained and become part of the product contract instead of an accident of not deleting them.
first_successcancels losers explicitly and records cancelled stages; today's exact cancellation behavior is unspecified.- LLM judge prompts change shape. Fan-in nodes with prompts now send candidate excerpts and diff stats to the judge — more tokens, different (better) selections than today's id-only prompt.