mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-10-09 03:20:56 +00:00
318 lines
17 KiB
Markdown
318 lines
17 KiB
Markdown
# 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.
|