Add internal parallelization strategy doc

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Bryan Helmkamp 2026-07-11 15:48:55 -04:00 committed by Scott Werner
parent 6ec4c9a13d
commit 736e302636

View file

@ -0,0 +1,318 @@
# 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_evaluate`
receives only statuses) and sorts by a `score` field 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`, and `head_sha` plus
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 `component` node 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 `score` via 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:
1. Before fan-out, checkpoint the sandbox to produce `base_sha`.
2. Each branch gets a worktree on a branch ref
(`fabro/run/parallel/<run>/<node>/pass<N>/<branch>`), rooted at `base_sha`.
The branch's `internal.work_dir` points at the worktree.
3. After a branch completes, `git add -A` + commit (`--allow-empty`) yields the
candidate's `head_sha`.
4. Worktrees are removed after the join. Loser branch refs are **kept** so
downstream nodes and humans can `git show`/`git diff` any candidate.
5. **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 `score` descending, then
lexical id. `score` becomes settable: branches emit it via structured-output
/ status fields, which now survive into `parallel.results` (§7).
- **LLM judge** (fan-in node has a `prompt` and a backend is configured): the
judge prompt includes, per candidate: id, status, score, a bounded response
excerpt, and `git diff --stat` vs. `base_sha` when 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 a `See: <blob path>` reference when
truncated — the same treatment `command.output` already 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:high` renders the same with its larger budget. `truncate` carries
goal only (explicit opt-out). `full` remains 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
1. 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.
2. `first_success` cancellation semantics for in-flight agent sessions
(graceful stop vs. abort; what the cancelled stage records).
3. Exact preamble budget per branch response (§9).
4. 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
candidate `head_sha`s. 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 commits `git 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.
1. **Branch outputs dropped (#490).** The fan-out task reads only
`outcome.status` and `head_sha` from each branch; branch `context_updates`
(including `response.<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 (run `01KX148ZAMMJRAADHK1HBF7PC3`, server 0.287.0-nightly.0).
2. **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`). In `fork -> a; a -> a2; a2 -> merge`,
`a2` never runs and nothing warns.
3. **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-only` merge cannot succeed (sibling commits diverge).
Masked today only because selection is vacuous (A.4).
4. **Selection is vacuous.** Heuristic tie-breaks on a `score` field nothing
can set (scores would arrive via branch context updates, which are dropped
per A.1). The LLM judge prompt is `serde_json::to_string_pretty` of
`parallel.results` — id/status/head_sha only (`fan_in.rs:247-250`). Both
modes reduce to "first successful branch, alphabetically."
5. **Branch preambles are stale.** Branches run via `dispatch_handler`,
bypassing the lifecycle's per-node preamble rebuild; each branch node
inherits `current.preamble` as computed for the fan-out node itself.
6. **Nested parallel silently loses git isolation.** Branch `EngineServices`
are built with `git_state: RwLock::new(None)` (`parallel.rs:380`), so a
`component` node inside a branch runs its own branches with no worktrees
and no warning.
7. **Docs contradict the engine.** `tutorials/ensemble.mdx:85` and
`tutorials/parallel-review.mdx:81` claim the post-merge node receives all
branch perspectives in its preamble (false, per A.1).
`workflows/stages-and-nodes.mdx:195` claims merged results are available to
downstream nodes as `parallel_results.json` (the file exists only under
`stages/<fork>@1/` in dumps, not in any node's working directory).
8. **`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.
1. **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).
2. **New validation errors.** Branch paths that don't converge on the join,
and nested `component` nodes inside branches, become lint failures for
graphs that previously ran (with wrong or silently degraded semantics).
3. **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.
4. **Post-fan-in context is richer.** `response.<id>` for every branch,
winner-sourced singletons (`last_stage`, `last_response`,
`command.output`), and `score` in `parallel.results`. Today those
singletons retain their pre-fork values; workflows with edge conditions
over them could route differently.
5. **Preambles after fan-in grow.** Branch responses render inline at
`compact` fidelity (bounded). Downstream nodes see more tokens per run;
snapshot tests over preambles will churn.
6. **Branch executions become visible stages.** Events, dumps, the web UI, and
the stage list gain per-branch stages (`a@1` under `fork@1`). Consumers of
`events.jsonl` / the API will see new stage records.
7. **`parallel_results.json` docs claim is corrected, not implemented.**
`workflows/stages-and-nodes.mdx:195` is updated to describe the real
channels (context key + preamble); no file appears in the workspace
(open question 4 covers deterministic consumers).
8. **Loser branch refs are documented as retained** and become part of the
product contract instead of an accident of not deleting them.
9. **`first_success` cancels losers explicitly** and records cancelled stages;
today's exact cancellation behavior is unspecified.
10. **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.