fabro/docs/internal/parallel-strategy.md
Bryan Helmkamp 736e302636 Add internal parallelization strategy doc
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-22 13:37:31 -04:00

318 lines
17 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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.