diff --git a/run.json b/run.json index 6402009d5..6bf8d9d20 100644 --- a/run.json +++ b/run.json @@ -480,7 +480,7 @@ "kind": "running" }, "status_updated_at": "2026-07-23T16:47:45.194455943Z", - "last_event_at": "2026-07-23T17:41:28.002857732Z", + "last_event_at": "2026-07-23T19:10:37.949679941Z", "pending_control": null, "checkpoints": [ { @@ -760,9 +760,9 @@ } }, { - "seq": 0, + "seq": 1348, "checkpoint": { - "timestamp": "2026-07-23T17:41:28.063182726Z", + "timestamp": "2026-07-23T17:41:31.844575764Z", "current_node": "implement", "completed_nodes": [ "start", @@ -774,29 +774,189 @@ "node_retries": {}, "context_values": { "internal.retry_count.toolchain": 0, - "internal.run_id": "01KY7Y01REECZ24XXTMBZ3PPV9", - "internal.fidelity": "compact", - "internal.thread_id": "preflight_lint", + "internal.retry_count.preflight_compile": 0, "last_stage": "implement", + "failure_class": "", + "outcome": "succeeded", + "last_response": "Implemented the complete per-branch fidelity plan.\n\n### Highlights\n- Added ordered per-branch preamble stash generation and consumption.\n- Implemented edge → branch node → fork inheritance precede", + "internal.work_dir": "/home/daytona/workspace/fabro", + "internal.retry_count.preflight_lint": 0, + "graph.goal": "# Per-Branch Fidelity for Parallel Branches — Implementation Plan\n\n## Context\n\nParallel branch nodes are dispatched via `dispatch_handler`, bypassing `FidelityLifecycle::before_node` (`lib/crates/fabro-workflow/src/lifecycle/fidelity.rs:76-180`) — the only place fidelity is resolved and preambles are built. Every branch therefore inherits the stale `current.preamble` copied at `context.fork()` (`handler/parallel.rs:226`), and `fidelity`/`thread_id` on branch nodes or `fork -> branch` edges are silently ignored. Confirmed live on the testing server (probes `01KY7KRA7E…`/`01KY7KRAAW…`, 2026-07-23): a `truncate` branch received the identical compact preamble as its default sibling; fork-level fidelity works and is the current workaround. Design reviewed via the Quarry doc \"Fix: Per-Branch Fidelity for Parallel Branches\".\n\nWhy not run the lifecycle per branch: it is a single-token state machine (one-slot `incoming_edge_data` baton, singleton context keys written to shared run state); concurrent invocation would corrupt run state. And `build_preamble` (`handler/llm/preamble.rs:24`, public and pure) needs `state.completed_nodes`/`state.node_outcomes`, which only the lifecycle sees. So: **pre-render per-branch preambles in the lifecycle, hand off to the handler via one context key.**\n\n## Semantics (final, after design pressure-test)\n\n- **Explicit-only resolution.** A branch's fidelity comes from the `fork -> branch` edge attr, else the branch node attr, else **no entry** — the branch inherits the fork's preamble via `fork()` exactly as today. The fork's own resolved fidelity is never re-applied per branch; this keeps the default path byte-identical even when the fork resolves `Full` (where re-derivation would have wrongly degraded every branch).\n- **`full` degrades to `summary:high`** (`Fidelity::degraded()`, `fabro-graphviz/src/fidelity.rs:35-40`) — applied only to *explicitly set* branch fidelity, with a log line (per `docs/internal/logging-strategy.md` — read before writing it).\n- **Equality skip**: if the branch's post-degradation fidelity equals the fork's post-degradation fidelity, store no entry (avoid redundant renders).\n- **`thread_id` stays inert in branches** (concurrent branches must never share an LLM session).\n- **`CURRENT_NODE` in branch contexts stays inherited (fork id).** The pressure-test showed changing it would re-attribute every branch-internal event's stage scope (`context.rs:185-190` → `StageScope::for_handler` used by all handlers) with a visit mismatch against `for_parallel_branch` scoping. The Quarry doc's \"bookkeeping keys describe the branch\" line is consciously deferred to a separate change with proper visit accounting.\n- **`simulate()` untouched.** No simulated handler reads preambles; partial mirroring would risk nested-parallel stash misreads. All-or-nothing → nothing.\n- **Stash shape**: `Value::Array`, length = branch count, `Null` = inherit, else `{\"fidelity\": \"...\", \"preamble\": \"...\"}`. Array length ≠ edge count → treat as absent (legacy). Keyed by edge index; `graph.outgoing_edges` is an ordered Vec filter (`fabro-types/src/graph.rs:393-395`) and lifecycle + handler share the same `Arc`, so indices align deterministically (including two edges to the same target).\n\n## Implementation steps (ordered; tree compiles at each step)\n\n1. **`lib/crates/fabro-workflow/src/context.rs`** — add `pub const INTERNAL_PARALLEL_BRANCH_PREAMBLES: &str = \"internal.parallel_branch_preambles\";` to `keys`. The `internal.` prefix already excludes it from preamble rendering (`preamble.rs:99-109`) and child→parent propagation (`context.rs:80-85`).\n\n2. **`lib/crates/fabro-workflow/src/artifact.rs`** — strip the new key in `durable_context_snapshot` (`:81`) and `normalize_checkpoint_for_resume` (`:101`), beside `CURRENT_PREAMBLE`. Without this, every post-parallel checkpoint and `CheckpointCompleted` event payload carries the full per-branch preamble map (a `summary:high` preamble embeds up to 50 lines of every command output — multiplied per branch).\n\n3. **`lib/crates/fabro-workflow/src/lifecycle/fidelity.rs`** — in `before_node`:\n - Set the stash key to `Null` on `state.context` **first**, before the two fallible `resolve_*` calls, so the always-overwritten invariant holds on every early-return path.\n - After the existing preamble build, when `gv_node.handler_type() == Some(\"parallel\")`: iterate `self.graph.outgoing_edges(node.id())` in order; per edge resolve explicit fidelity (edge attr → target-node attr → none); apply `degraded()` to explicit values (log when it was `full`); push `Null` for inherit/equal-to-fork, else render `build_preamble(final_fidelity, …)` reusing the already-resolved snapshot (blobs resolved once at `:113-128`) and push the entry. Set the array on the stash key.\n - Extract the per-branch resolution as a pure helper beside `resolve_fidelity` (`:206`, same module — no visibility change) for unit testing.\n\n4. **`lib/crates/fabro-workflow/src/handler/parallel.rs`** — in `execute()`'s branch-setup loop (insert after `:238`, where `INTERNAL_PARALLEL_BRANCH_ID` is set):\n - Read the stash from the parent context once before the loop; `None`, `Some(Null)`, or length-mismatch all mean strict legacy behavior (note: `Context::get` returns `Some(Null)` for a Nulled key — both must be treated as absent).\n - Per branch with an entry: `branch_context.set(CURRENT_PREAMBLE, preamble)` and `branch_context.set(INTERNAL_FIDELITY, fidelity)`. Downstream needs nothing: `agent.rs:244`/`prompt.rs:63` read `context.preamble()`.\n - In **every** branch fork, set the stash key to `Null` — load-bearing, not hygiene: a nested parallel branch target reads its fork's stash, and without the Null it would misinterpret the outer node's array as its own.\n - After the loop, set the stash key to `Null` on the handler's own context — the write-back diff (`node_handler.rs:99-105`) clears `state.context` so the post-parallel checkpoint carries Null even before the artifact strip.\n\n5. **`lib/crates/fabro-validate/src/rules/parallel_branch_inert_attribute.rs`** — drop `\"fidelity\"` from `BRANCH_IGNORED_ATTRS` and its `fix_message` arm; add a narrow diagnostic in its place: `fidelity=\"full\"` on a fork→branch edge or branch-only node warns \"parallel branches run at most at summary:high; full is degraded at runtime because branches cannot share a session\". Other fidelity values now lint clean. Update doc comment (the snapshot rationale now applies to `thread_id` only) and tests.\n\n6. **`lib/crates/fabro-validate/src/rules/thread_id_requires_fidelity_full.rs`** — skip fork→branch edges and branch-only nodes (factor the branch-only detection from rule 5 into a shared helper). Today it tells branch nodes with `thread_id` to *add* `fidelity=\"full\"` — advice that, post-change, would actively alter runtime behavior while the other rule says \"remove thread_id\". Defer to the inert-attribute rule's guidance on branches.\n\n7. **Docs** — `docs/public/execution/context.mdx` (fidelity precedence: branch edge → branch node → inherit fork; per-branch preamble rendering; `thread_id` inert in branches; `full` degradation), `docs/public/workflows/stages-and-nodes.mdx` (parallel fan-out section + fidelity attribute notes), `docs/public/reference/dot-language.mdx` (edge/node attr rows). Optional changelog entry via the changelog conventions.\n\n## Tests\n\nPer `docs/internal/testing-strategy.md`, preamble content is implementation-facing → `fabro-workflow`, not CLI layers.\n\n- **Pure unit tests** (`lifecycle/fidelity.rs` tests, beside `resolve_fidelity`'s at `:271-321`): explicit edge > node precedence; no-attr → inherit (no entry); explicit `full` → `summary:high` entry; branch fidelity equal to fork's (post-degradation) → no entry; fork resolved `Full` + no branch attrs → no entries at all.\n- **Lifecycle-level**: two consecutive `before_node` calls on the same parallel node rebuild (not merge) the stash; non-parallel node overwrites stash to Null; resume-degrade flag interaction (fork degrades, fallback branches still get no entry).\n- **`artifact.rs` tests** (`:519+` pattern): both snapshot functions strip the stash key.\n- **Parallel handler unit tests** (`handler/parallel.rs` tests module, `EngineServices::test_default()` + recording handler mirroring `PreambleEchoHandler`, `manager_loop.rs:973-1043`): entry applies `CURRENT_PREAMBLE`/`INTERNAL_FIDELITY` to the right branch by index; stash Null in every branch fork; `Some(Null)`/absent/length-mismatch → legacy; duplicate-target edges get distinct entries at indices 0/1 (no-git test — a pre-existing worktree-name collision exists for that topology, don't let it pollute the assertion); existing tests stay unmodified as the legacy guard.\n- **Engine-level regression** in `lib/crates/fabro-workflow/tests/it/integration.rs` beside the `fidelity_prompt_*` tests (`:9245-9479`), reusing `FidelityCapturingHandler` (`:4917-4974`) and the `end_to_end_parallel_fan_out_fan_in` scaffold (`:2441-2480`) via `WorkflowRunner::run_with_state`:\n - **Probe A analog** (`parallel_branches_get_per_branch_preambles_by_fidelity`): seed sets a context marker → fork → `branch_a` (`fidelity=\"truncate\"`) + `branch_b` (default) → fan-in. Assert branch_a's preamble is goal-only (no marker) while branch_b's contains the marker.\n - **Probe B analog**: `fidelity=\"truncate\"` on the fork only → both branches goal-only (compat guarantee, unchanged behavior).\n - Edge-attr-beats-node-attr variant.\n- **Lint tests**: no warning for non-full branch fidelity; warning for branch `fidelity=\"full\"`; `thread_id_requires_fidelity_full` silent on branch-only nodes, still firing elsewhere.\n\n## Verification\n\n- `cargo nextest run -p fabro-workflow -p fabro-validate`, then `ulimit -n 4096 && cargo nextest run --workspace` (do not export `FORCE_COLOR`).\n- `cargo +nightly-2026-04-14 fmt --check --all`; `cargo +nightly-2026-04-14 clippy --workspace --all-targets -- -D warnings`.\n- Live confirmation on the testing server: re-run the two probe workflows (session scratchpad `probes/isolation-a`, `probes/isolation-b`) against a locally built binary — probe A's `stage.prompt` events must now show differentiated branch preambles; probe B byte-identical to before.\n\n## Compatibility\n\n| Situation | Impact |\n|---|---|\n| No fidelity attrs near the parallel node | None — byte-identical (inherit path, no re-render) |\n| Fidelity on the fork node / its incoming edge | None — fork snapshot semantics unchanged |\n| Previously-dead attrs on branch nodes / fork→branch edges | Start working (the fix) |\n| `full` on a branch | Degrades to `summary:high` + log + lint warning |\n| `thread_id` on a branch | Still inert; lint still warns; the contradictory companion lint goes quiet on branches |\n\n## Decisions (user-confirmed 2026-07-23)\n\n1. **`CURRENT_NODE` in branch contexts stays inherited** — the branch-scoped bookkeeping change is deferred to a dedicated event-attribution change.\n2. **The narrow `fidelity=\"full\"` branch lint is in scope** (step 5 stands as written).\n3. **No changelog entry in this PR** — changelog handled in the usual batch.\n", + "internal.retry_count.implement": 0, + "internal.run_id": "01KY7Y01REECZ24XXTMBZ3PPV9", + "thread.preflight_compile.current_node": "preflight_lint", + "thread.toolchain.current_node": "preflight_compile", + "response.implement": "Implemented the complete per-branch fidelity plan.\n\n### Highlights\n- Added ordered per-branch preamble stash generation and consumption.\n- Implemented edge → branch node → fork inheritance precedence.\n- Explicit branch `full` degrades to `summary:high` with structured logging.\n- Preserved byte-identical fork inheritance for unspecified/equal fidelities.\n- Added strict legacy fallback for missing, null, malformed, non-array, and mismatched stashes.\n- Cleared branch stashes for nested-parallel safety.\n- Removed stash data from checkpoints, durable snapshots, and persisted event contexts.\n- Kept branch `thread_id` inert, `CURRENT_NODE` inherited, and `simulate()` unchanged.\n- Updated validation rules and all three requested documentation pages.\n- Added unit, lifecycle, handler, integration, persistence, and lint regression coverage.\n- No changelog added.\n\n### Verification\n- `cargo nextest run --workspace`: **7,063 passed, 193 skipped**\n- `cargo +nightly-2026-04-14 fmt --check --all`: passed\n- `cargo +nightly-2026-04-14 clippy --workspace --all-targets -- -D warnings`: passed\n- `git diff --check`: passed\n\nLive testing-server probes were not run because the probe definitions and testing-server access were not available in this checkout/session.", + "internal.retry_count.start": 0, + "graph.rankdir": "LR", + "internal.fidelity": "compact", + "current_node": "implement", + "command.output": "blob://sha256/12ae32cb1ec02d01eda3581b127c1fee3b0dc53572ed6baf239721a03d82e126", + "internal.node_visit_count": 1, + "thread.preflight_lint.current_node": "implement", + "thread.start.current_node": "toolchain", + "internal.thread_id": "preflight_lint", + "failure_signature": "" + }, + "node_outcomes": { + "implement": { + "status": "succeeded", + "context_updates": { + "last_stage": "implement", + "last_response": "Implemented the complete per-branch fidelity plan.\n\n### Highlights\n- Added ordered per-branch preamble stash generation and consumption.\n- Implemented edge → branch node → fork inheritance precede", + "response.implement": "Implemented the complete per-branch fidelity plan.\n\n### Highlights\n- Added ordered per-branch preamble stash generation and consumption.\n- Implemented edge → branch node → fork inheritance precedence.\n- Explicit branch `full` degrades to `summary:high` with structured logging.\n- Preserved byte-identical fork inheritance for unspecified/equal fidelities.\n- Added strict legacy fallback for missing, null, malformed, non-array, and mismatched stashes.\n- Cleared branch stashes for nested-parallel safety.\n- Removed stash data from checkpoints, durable snapshots, and persisted event contexts.\n- Kept branch `thread_id` inert, `CURRENT_NODE` inherited, and `simulate()` unchanged.\n- Updated validation rules and all three requested documentation pages.\n- Added unit, lifecycle, handler, integration, persistence, and lint regression coverage.\n- No changelog added.\n\n### Verification\n- `cargo nextest run --workspace`: **7,063 passed, 193 skipped**\n- `cargo +nightly-2026-04-14 fmt --check --all`: passed\n- `cargo +nightly-2026-04-14 clippy --workspace --all-targets -- -D warnings`: passed\n- `git diff --check`: passed\n\nLive testing-server probes were not run because the probe definitions and testing-server access were not available in this checkout/session." + }, + "notes": "Stage completed: implement", + "usage": { + "input": { + "usage": { + "model": { + "provider": "openrouter", + "model_id": "openai/gpt-5.6-sol" + }, + "tokens": { + "input_tokens": 394190, + "output_tokens": 32119, + "reasoning_tokens": 32341, + "cache_read_tokens": 13269488, + "cache_write_tokens": 886818 + } + }, + "facts": { + "algorithm": "openai" + } + } + }, + "files_touched": [ + "/home/daytona/workspace/fabro/docs/public/execution/context.mdx", + "/home/daytona/workspace/fabro/docs/public/reference/dot-language.mdx", + "/home/daytona/workspace/fabro/docs/public/workflows/stages-and-nodes.mdx", + "/home/daytona/workspace/fabro/lib/crates/fabro-validate/src/rules/mod.rs", + "/home/daytona/workspace/fabro/lib/crates/fabro-validate/src/rules/parallel_branch.rs", + "/home/daytona/workspace/fabro/lib/crates/fabro-validate/src/rules/parallel_branch_inert_attribute.rs", + "/home/daytona/workspace/fabro/lib/crates/fabro-validate/src/rules/thread_id_requires_fidelity_full.rs", + "/home/daytona/workspace/fabro/lib/crates/fabro-workflow/src/artifact.rs", + "/home/daytona/workspace/fabro/lib/crates/fabro-workflow/src/context.rs", + "/home/daytona/workspace/fabro/lib/crates/fabro-workflow/src/handler/parallel.rs", + "/home/daytona/workspace/fabro/lib/crates/fabro-workflow/src/lifecycle/event.rs", + "/home/daytona/workspace/fabro/lib/crates/fabro-workflow/src/lifecycle/fidelity.rs", + "/home/daytona/workspace/fabro/lib/crates/fabro-workflow/tests/it/integration.rs" + ], + "timing": { + "wall_time_ms": 0, + "inference_time_ms": 1625877, + "tool_time_ms": 1190483, + "active_time_ms": 2816360 + } + }, + "start": { + "status": "succeeded", + "usage": null + }, + "preflight_lint": { + "status": "succeeded", + "context_updates": { + "command.output": "blob://sha256/12ae32cb1ec02d01eda3581b127c1fee3b0dc53572ed6baf239721a03d82e126" + }, + "notes": "Script completed: cargo +nightly-2026-04-14 clippy -q --workspace --all-targets -- -D warnings 2>&1", + "usage": null, + "timing": { + "wall_time_ms": 0, + "inference_time_ms": 0, + "tool_time_ms": 154518, + "active_time_ms": 154518 + } + }, + "toolchain": { + "status": "succeeded", + "context_updates": { + "command.output": "blob://sha256/fc14b2ba2d770e5cd3169df7a29525c962adfc4cfa3097b9098c63ebd61a748c" + }, + "notes": "Script completed: command -v cargo >/dev/null || { curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y && sudo ln -sf $HOME/.cargo/bin/* /usr/local/bin/; }; cargo --version 2>&1", + "usage": null, + "timing": { + "wall_time_ms": 0, + "inference_time_ms": 0, + "tool_time_ms": 1360, + "active_time_ms": 1360 + } + }, + "preflight_compile": { + "status": "succeeded", + "context_updates": { + "command.output": "blob://sha256/12ae32cb1ec02d01eda3581b127c1fee3b0dc53572ed6baf239721a03d82e126" + }, + "notes": "Script completed: cargo check -q --workspace 2>&1", + "usage": null, + "timing": { + "wall_time_ms": 0, + "inference_time_ms": 0, + "tool_time_ms": 140243, + "active_time_ms": 140243 + } + } + }, + "next_node_id": "simplify_fable", + "git_commit_sha": "1a2bd7966dc15ac5228546964879e1b4b577ad85", + "node_visits": { + "implement": 1, + "preflight_lint": 1, + "start": 1, + "toolchain": 1, + "preflight_compile": 1 + } + }, + "diff": { + "patch": "diff --git a/docs/public/execution/context.mdx b/docs/public/execution/context.mdx\nindex c67012b3c..a08b2d960 100644\n--- a/docs/public/execution/context.mdx\n+++ b/docs/public/execution/context.mdx\n@@ -139,6 +139,16 @@ Fidelity can be set at three levels. The first match wins:\n \n If none of these are set, fidelity defaults to `compact`.\n \n+### Parallel branch fidelity\n+\n+The first node in each parallel branch uses this precedence:\n+\n+1. `fidelity` on the fork-to-branch edge\n+2. `fidelity` on the branch node\n+3. Otherwise, inherit the fork's preamble unchanged\n+\n+Fabro renders any branch-specific preambles before fan-out from the fork's context snapshot, then places them into the isolated branch contexts. An explicit branch-level `full` degrades to `summary:high` because concurrent branches cannot share conversation sessions. `thread_id` on a branch node or fork-to-branch edge is inert.\n+\n ### Full fidelity and threads\n \n `full` fidelity is typically used with `thread_id` to create a shared conversation across multiple nodes. Nodes with the same `thread_id` share a single LLM session, preserving full context continuity:\ndiff --git a/docs/public/reference/dot-language.mdx b/docs/public/reference/dot-language.mdx\nindex f01fbf2ed..3e407242b 100644\n--- a/docs/public/reference/dot-language.mdx\n+++ b/docs/public/reference/dot-language.mdx\n@@ -201,8 +201,8 @@ Start nodes can also be identified by ID (`start` or `Start`). Exit nodes can be\n | `prompt` | String | Task instructions for the LLM. Supports file references with `@path/to/file.md` |\n | `reasoning_effort` | String | `low`, `medium`, or `high` (default: `high`) |\n | `max_tokens` | Integer | Maximum output tokens |\n-| `fidelity` | String | How much prior context is passed: `compact`, `full`, `summary:high`, `summary:medium`, `summary:low`, `truncate` |\n-| `thread_id` | String | Groups nodes into a shared conversation thread |\n+| `fidelity` | String | How much prior context is passed: `compact`, `full`, `summary:high`, `summary:medium`, `summary:low`, `truncate`. On a node entered directly from a parallel fork, this is overridden by the fork-to-branch edge; explicit `full` degrades to `summary:high`. |\n+| `thread_id` | String | Groups nodes into a shared conversation thread. Inert when the node is entered directly from a parallel fork. |\n | `model` | String | Explicit model ID (overrides stylesheet) |\n | `provider` | String | Explicit provider name (overrides stylesheet). Auto-inferred from the model catalog when omitted. |\n | `project_memory` | Boolean | When `true` (default), prompt nodes discover and include project docs (`AGENTS.md`, `CLAUDE.md`, etc.) as a system prompt. Set to `false` to disable. |\n@@ -252,6 +252,8 @@ audit [\n | `join_policy` | String | When the merge can proceed: `wait_all` (default), `first_success` |\n | `max_parallel` | Integer | Maximum concurrent branches (default: 4) |\n \n+For the first node in each branch, `fidelity` resolves from the fork-to-branch edge, then the branch node; without either, the fork preamble is inherited unchanged. Branch-specific preambles are rendered before fan-out from the fork's context snapshot. Concurrent branches cannot share sessions, so explicit branch `full` becomes `summary:high`, and branch-level `thread_id` is inert.\n+\n ### Wait nodes\n \n | Attribute | Type | Description |\n@@ -282,8 +284,8 @@ audit [\n | `label` | String | Display text; also used for human gate option matching |\n | `condition` | String | Boolean expression for conditional routing (see below) |\n | `weight` | Integer | Priority for tiebreaking (higher wins, default: 0) |\n-| `fidelity` | String | Override fidelity level for this transition |\n-| `thread_id` | String | Override thread ID for this transition |\n+| `fidelity` | String | Override fidelity level for this transition. On a fork-to-branch edge, takes precedence over the branch node; explicit `full` degrades to `summary:high`. |\n+| `thread_id` | String | Override thread ID for this transition. Inert on fork-to-branch edges. |\n | `loop_restart` | Boolean | Restart the workflow from this edge's target when taken: stage history and retry counts clear and the context resets to empty (visit counts are kept). Failed outcomes may only take it for `transient_infra` failures — see [Failures](/execution/failures#loop-restart-edges) |\n | `freeform` | Boolean | When `true` on a human-gate edge, accept free-text input instead of fixed choices |\n \ndiff --git a/docs/public/workflows/stages-and-nodes.mdx b/docs/public/workflows/stages-and-nodes.mdx\nindex 1dd0f71ac..1b8c252c2 100644\n--- a/docs/public/workflows/stages-and-nodes.mdx\n+++ b/docs/public/workflows/stages-and-nodes.mdx\n@@ -170,6 +170,8 @@ fork -> quality\n | `join_policy` | When the merge can proceed (see table below) |\n | `max_parallel` | Maximum concurrent branches (default: 4) |\n \n+For each branch's first node, fidelity resolves from the fork-to-branch edge, then the branch node; otherwise it inherits the fork preamble unchanged. Fabro renders branch-specific preambles before fan-out from the fork snapshot. Branch-level `full` degrades to `summary:high` because concurrent branches cannot share sessions, and `thread_id` on a branch node or fork-to-branch edge is inert.\n+\n **Join policies:**\n \n | Policy | Behavior |\ndiff --git a/lib/crates/fabro-validate/src/rules/mod.rs b/lib/crates/fabro-validate/src/rules/mod.rs\nindex 428004240..87075bc17 100644\n--- a/lib/crates/fabro-validate/src/rules/mod.rs\n+++ b/lib/crates/fabro-validate/src/rules/mod.rs\n@@ -12,6 +12,7 @@ mod inert_attribute;\n mod model_support;\n mod node_model_known;\n mod orphan_custom_outcome;\n+mod parallel_branch;\n mod parallel_branch_inert_attribute;\n mod prompt_on_llm_nodes;\n mod random_selection_no_conditions;\ndiff --git a/lib/crates/fabro-validate/src/rules/parallel_branch.rs b/lib/crates/fabro-validate/src/rules/parallel_branch.rs\nnew file mode 100644\nindex 000000000..2f1cba2e7\n--- /dev/null\n+++ b/lib/crates/fabro-validate/src/rules/parallel_branch.rs\n@@ -0,0 +1,56 @@\n+use std::collections::BTreeSet;\n+\n+use fabro_graphviz::graph::{Edge, Graph};\n+\n+pub(super) struct ParallelBranches<'a> {\n+ graph: &'a Graph,\n+ fork_ids: BTreeSet<&'a str>,\n+}\n+\n+impl<'a> ParallelBranches<'a> {\n+ pub(super) fn new(graph: &'a Graph) -> Self {\n+ let fork_ids = graph\n+ .nodes\n+ .values()\n+ .filter(|node| node.handler_type() == Some(\"parallel\"))\n+ .map(|node| node.id.as_str())\n+ .collect();\n+ Self { graph, fork_ids }\n+ }\n+\n+ pub(super) fn is_empty(&self) -> bool {\n+ self.fork_ids.is_empty()\n+ }\n+\n+ pub(super) fn is_fork_edge(&self, edge: &Edge) -> bool {\n+ self.fork_ids.contains(edge.from.as_str())\n+ }\n+\n+ pub(super) fn branch_targets(&self) -> BTreeSet<&str> {\n+ self.graph\n+ .edges\n+ .iter()\n+ .filter(|edge| self.is_fork_edge(edge))\n+ .map(|edge| edge.to.as_str())\n+ .collect()\n+ }\n+\n+ pub(super) fn is_branch_only_node(&self, node_id: &str) -> bool {\n+ self.branch_only_parents(node_id).is_some()\n+ }\n+\n+ pub(super) fn branch_only_parents(&self, node_id: &str) -> Option> {\n+ let mut incoming = self\n+ .graph\n+ .edges\n+ .iter()\n+ .filter(|edge| edge.to == node_id)\n+ .peekable();\n+ incoming.peek()?;\n+\n+ incoming\n+ .map(|edge| self.is_fork_edge(edge).then(|| edge.from.clone()))\n+ .collect::>>()\n+ .map(|parents| parents.into_iter().collect())\n+ }\n+}\ndiff --git a/lib/crates/fabro-validate/src/rules/parallel_branch_inert_attribute.rs b/lib/crates/fabro-validate/src/rules/parallel_branch_inert_attribute.rs\nindex eb051281e..da36dbbea 100644\n--- a/lib/crates/fabro-validate/src/rules/parallel_branch_inert_attribute.rs\n+++ b/lib/crates/fabro-validate/src/rules/parallel_branch_inert_attribute.rs\n@@ -1,18 +1,16 @@\n-use std::collections::BTreeSet;\n-\n use fabro_graphviz::graph::Graph;\n \n+use super::parallel_branch::ParallelBranches;\n use crate::{Diagnostic, LintRule, Severity};\n \n pub(super) fn rule() -> Box {\n Box::new(Rule)\n }\n \n-/// Attributes that parallel branch execution does not resolve. Branch nodes\n-/// are dispatched with a snapshot of the context taken when the parallel node\n-/// started, so per-branch `fidelity` never changes what a branch sees, and\n-/// per-branch `thread_id` never replaces the thread inherited in that snapshot.\n-const BRANCH_IGNORED_ATTRS: &[&str] = &[\"fidelity\", \"thread_id\"];\n+/// Attributes that parallel branch execution does not resolve. Per-branch\n+/// preambles now honor fidelity, while `thread_id` remains inert because\n+/// concurrent branches cannot share an LLM session.\n+const BRANCH_IGNORED_ATTRS: &[&str] = &[\"thread_id\"];\n \n struct Rule;\n \n@@ -24,21 +22,8 @@ fn quoted_list(ids: &[String]) -> String {\n .join(\", \")\n }\n \n-fn fix_message(attr: &str, parallel_ids: &[String]) -> String {\n+fn fix_message(attr: &str) -> String {\n match attr {\n- \"fidelity\" => {\n- if parallel_ids.len() == 1 {\n- format!(\n- \"Set fidelity on the parallel node {} (or its incoming edge) to control what every branch sees\",\n- quoted_list(parallel_ids),\n- )\n- } else {\n- format!(\n- \"Set fidelity on the parallel nodes {} (or their incoming edges) to control what every branch sees\",\n- quoted_list(parallel_ids),\n- )\n- }\n- }\n \"thread_id\" => format!(\n \"Remove '{attr}': parallel branches inherit the thread resolved when the parallel node started\"\n ),\n@@ -46,30 +31,49 @@ fn fix_message(attr: &str, parallel_ids: &[String]) -> String {\n }\n }\n \n+fn full_fidelity_message() -> String {\n+ \"parallel branches run at most at summary:high; full is degraded at runtime because branches cannot share a session\".to_string()\n+}\n+\n+fn full_fidelity_fix(parallel_ids: &[String]) -> String {\n+ let parent = if parallel_ids.len() == 1 {\n+ format!(\"parallel node {}\", quoted_list(parallel_ids))\n+ } else {\n+ format!(\"parallel nodes {}\", quoted_list(parallel_ids))\n+ };\n+ format!(\n+ \"Use fidelity=\\\"summary:high\\\" or another lower mode on this branch; to reuse a full session before fan-out, set fidelity=\\\"full\\\" on {parent} or its incoming edge\"\n+ )\n+}\n+\n impl LintRule for Rule {\n fn name(&self) -> &'static str {\n \"parallel_branch_inert_attribute\"\n }\n \n fn apply(&self, graph: &Graph) -> Vec {\n- let parallel_ids: BTreeSet<&str> = graph\n- .nodes\n- .values()\n- .filter(|n| n.handler_type() == Some(\"parallel\"))\n- .map(|n| n.id.as_str())\n- .collect();\n- if parallel_ids.is_empty() {\n+ let branches = ParallelBranches::new(graph);\n+ if branches.is_empty() {\n return Vec::new();\n }\n \n let mut diagnostics = Vec::new();\n \n- // Branch edges (parallel node -> branch target) carrying an attribute\n- // that branch dispatch never reads.\n for edge in &graph.edges {\n- if !parallel_ids.contains(edge.from.as_str()) {\n+ if !branches.is_fork_edge(edge) {\n continue;\n }\n+ if edge.fidelity() == Some(\"full\") {\n+ diagnostics.push(Diagnostic {\n+ rule: self.name().to_string(),\n+ severity: Severity::Warning,\n+ message: full_fidelity_message(),\n+ node_id: None,\n+ edge: Some((edge.from.clone(), edge.to.clone())),\n+ fix: Some(full_fidelity_fix(std::slice::from_ref(&edge.from))),\n+ ..Diagnostic::default()\n+ });\n+ }\n for attr in BRANCH_IGNORED_ATTRS {\n if !edge.attrs.contains_key(*attr) {\n continue;\n@@ -83,42 +87,32 @@ impl LintRule for Rule {\n ),\n node_id: None,\n edge: Some((edge.from.clone(), edge.to.clone())),\n- fix: Some(fix_message(attr, std::slice::from_ref(&edge.from))),\n+ fix: Some(fix_message(attr)),\n ..Diagnostic::default()\n });\n }\n }\n \n- // Branch target nodes carrying such an attribute — but only when every\n- // incoming edge comes from a parallel node. A node that is also\n- // reachable through a normal edge resolves the attribute on that path,\n- // so it is not inert there.\n- let branch_targets: BTreeSet<&str> = graph\n- .edges\n- .iter()\n- .filter(|e| parallel_ids.contains(e.from.as_str()))\n- .map(|e| e.to.as_str())\n- .collect();\n- for target in branch_targets {\n- let only_branch_entries = graph\n- .edges\n- .iter()\n- .filter(|e| e.to == target)\n- .all(|e| parallel_ids.contains(e.from.as_str()));\n- if !only_branch_entries {\n+ // A node with any normal incoming path still resolves its attributes on\n+ // that path, so branch-only diagnostics do not apply to it.\n+ for target in branches.branch_targets() {\n+ let Some(parents) = branches.branch_only_parents(target) else {\n continue;\n- }\n+ };\n let Some(node) = graph.nodes.get(target) else {\n continue;\n };\n- let parents: Vec = graph\n- .edges\n- .iter()\n- .filter(|e| e.to == target && parallel_ids.contains(e.from.as_str()))\n- .map(|e| e.from.clone())\n- .collect::>()\n- .into_iter()\n- .collect();\n+ if node.fidelity() == Some(\"full\") {\n+ diagnostics.push(Diagnostic {\n+ rule: self.name().to_string(),\n+ severity: Severity::Warning,\n+ message: full_fidelity_message(),\n+ node_id: Some(node.id.clone()),\n+ edge: None,\n+ fix: Some(full_fidelity_fix(&parents)),\n+ ..Diagnostic::default()\n+ });\n+ }\n for attr in BRANCH_IGNORED_ATTRS {\n if !node.attrs.contains_key(*attr) {\n continue;\n@@ -133,7 +127,7 @@ impl LintRule for Rule {\n ),\n node_id: Some(node.id.clone()),\n edge: None,\n- fix: Some(fix_message(attr, &parents)),\n+ fix: Some(fix_message(attr)),\n ..Diagnostic::default()\n });\n }\n@@ -181,7 +175,7 @@ mod tests {\n }\n \n #[test]\n- fn warns_on_fidelity_on_branch_node() {\n+ fn accepts_non_full_fidelity_on_branch_node() {\n let mut g = parallel_graph();\n g.nodes\n .get_mut(\"branch_a\")\n@@ -191,14 +185,73 @@ mod tests {\n \"fidelity\".to_string(),\n AttrValue::String(\"truncate\".to_string()),\n );\n+\n+ assert!(Rule.apply(&g).is_empty());\n+ }\n+\n+ #[test]\n+ fn warns_when_full_fidelity_on_branch_node_degrades() {\n+ let mut g = parallel_graph();\n+ g.nodes\n+ .get_mut(\"branch_a\")\n+ .expect(\"graph has branch_a\")\n+ .attrs\n+ .insert(\n+ \"fidelity\".to_string(),\n+ AttrValue::String(\"full\".to_string()),\n+ );\n+\n let d = Rule.apply(&g);\n+\n assert_eq!(d.len(), 1);\n assert_eq!(d[0].severity, Severity::Warning);\n assert_eq!(d[0].node_id.as_deref(), Some(\"branch_a\"));\n- assert!(d[0].message.contains(\"'fidelity'\"));\n+ assert!(d[0].message.contains(\"full\"));\n+ assert!(d[0].message.contains(\"summary:high\"));\n assert!(d[0].fix.as_deref().is_some_and(|f| f.contains(\"'fork'\")));\n }\n \n+ #[test]\n+ fn accepts_every_non_full_fidelity_on_branch_edges() {\n+ for fidelity in [\n+ \"truncate\",\n+ \"compact\",\n+ \"summary:low\",\n+ \"summary:medium\",\n+ \"summary:high\",\n+ ] {\n+ let mut g = parallel_graph();\n+ g.edges[1].attrs.insert(\n+ \"fidelity\".to_string(),\n+ AttrValue::String(fidelity.to_string()),\n+ );\n+\n+ assert!(\n+ Rule.apply(&g).is_empty(),\n+ \"{fidelity} should be accepted on a branch edge\"\n+ );\n+ }\n+ }\n+\n+ #[test]\n+ fn warns_when_full_fidelity_on_branch_edge_degrades() {\n+ let mut g = parallel_graph();\n+ g.edges[1].attrs.insert(\n+ \"fidelity\".to_string(),\n+ AttrValue::String(\"full\".to_string()),\n+ );\n+\n+ let d = Rule.apply(&g);\n+\n+ assert_eq!(d.len(), 1);\n+ assert_eq!(\n+ d[0].edge,\n+ Some((\"fork\".to_string(), \"branch_a\".to_string()))\n+ );\n+ assert!(d[0].message.contains(\"full\"));\n+ assert!(d[0].message.contains(\"summary:high\"));\n+ }\n+\n #[test]\n fn warns_on_thread_id_on_branch_edge() {\n let mut g = parallel_graph();\n@@ -221,6 +274,31 @@ mod tests {\n );\n }\n \n+ #[test]\n+ fn warns_on_thread_id_on_branch_only_node() {\n+ let mut g = parallel_graph();\n+ g.nodes\n+ .get_mut(\"branch_a\")\n+ .expect(\"graph has branch_a\")\n+ .attrs\n+ .insert(\n+ \"thread_id\".to_string(),\n+ AttrValue::String(\"impl\".to_string()),\n+ );\n+\n+ let d = Rule.apply(&g);\n+\n+ assert_eq!(d.len(), 1);\n+ assert_eq!(d[0].node_id.as_deref(), Some(\"branch_a\"));\n+ assert!(d[0].message.contains(\"'thread_id'\"));\n+ assert_eq!(\n+ d[0].fix.as_deref(),\n+ Some(\n+ \"Remove 'thread_id': parallel branches inherit the thread resolved when the parallel node started\"\n+ )\n+ );\n+ }\n+\n #[test]\n fn accepts_fidelity_on_the_parallel_node_itself() {\n let mut g = parallel_graph();\n@@ -265,11 +343,10 @@ mod tests {\n .attrs\n .insert(\n \"fidelity\".to_string(),\n- AttrValue::String(\"truncate\".to_string()),\n+ AttrValue::String(\"full\".to_string()),\n );\n let d = Rule.apply(&g);\n assert_eq!(d.len(), 1);\n- assert!(d[0].message.contains(\"'fork', 'fork2'\"));\n let fix = d[0].fix.as_deref().expect(\"diagnostic has a fix\");\n assert!(fix.contains(\"'fork', 'fork2'\"));\n assert!(fix.contains(\"parallel nodes\"));\ndiff --git a/lib/crates/fabro-validate/src/rules/thread_id_requires_fidelity_full.rs b/lib/crates/fabro-validate/src/rules/thread_id_requires_fidelity_full.rs\nindex 457553788..a73335da5 100644\n--- a/lib/crates/fabro-validate/src/rules/thread_id_requires_fidelity_full.rs\n+++ b/lib/crates/fabro-validate/src/rules/thread_id_requires_fidelity_full.rs\n@@ -1,5 +1,6 @@\n use fabro_graphviz::graph::Graph;\n \n+use super::parallel_branch::ParallelBranches;\n use crate::{Diagnostic, LintRule, Severity};\n \n pub(super) fn rule() -> Box {\n@@ -20,9 +21,13 @@ impl LintRule for Rule {\n fn apply(&self, graph: &Graph) -> Vec {\n let mut diagnostics = Vec::new();\n let graph_default_full = graph.default_fidelity() == Some(\"full\");\n+ let branches = ParallelBranches::new(graph);\n \n for node in graph.nodes.values() {\n- if node.thread_id().is_some() && node.fidelity() != Some(\"full\") && !graph_default_full\n+ if node.thread_id().is_some()\n+ && !branches.is_branch_only_node(&node.id)\n+ && node.fidelity() != Some(\"full\")\n+ && !graph_default_full\n {\n diagnostics.push(Diagnostic {\n rule: self.name().to_string(),\n@@ -41,7 +46,7 @@ impl LintRule for Rule {\n }\n \n for edge in &graph.edges {\n- if edge.thread_id().is_some() {\n+ if edge.thread_id().is_some() && !branches.is_fork_edge(edge) {\n let edge_full = edge.fidelity() == Some(\"full\");\n let target_full =\n graph.nodes.get(&edge.to).and_then(|n| n.fidelity()) == Some(\"full\");\n@@ -82,12 +87,29 @@ impl LintRule for Rule {\n \n #[cfg(test)]\n mod tests {\n- use fabro_graphviz::graph::{AttrValue, Edge, Node};\n+ use fabro_graphviz::graph::{AttrValue, Edge, Graph, Node};\n \n use super::Rule;\n use crate::rules::test_support::minimal_graph;\n use crate::{LintRule, Severity};\n \n+ fn parallel_graph() -> Graph {\n+ let mut g = minimal_graph();\n+ let mut fork = Node::new(\"fork\");\n+ fork.attrs.insert(\n+ \"shape\".to_string(),\n+ AttrValue::String(\"component\".to_string()),\n+ );\n+ g.nodes.insert(\"fork\".to_string(), fork);\n+ g.nodes.insert(\"branch\".to_string(), Node::new(\"branch\"));\n+ g.edges = vec![\n+ Edge::new(\"start\", \"fork\"),\n+ Edge::new(\"fork\", \"branch\"),\n+ Edge::new(\"branch\", \"exit\"),\n+ ];\n+ g\n+ }\n+\n #[test]\n fn thread_id_requires_fidelity_full_node_warns() {\n let mut g = minimal_graph();\n@@ -206,6 +228,51 @@ mod tests {\n assert!(d.is_empty());\n }\n \n+ #[test]\n+ fn skips_thread_id_on_parallel_branch_edge() {\n+ let mut g = parallel_graph();\n+ g.edges[1].attrs.insert(\n+ \"thread_id\".to_string(),\n+ AttrValue::String(\"branch-thread\".to_string()),\n+ );\n+\n+ assert!(Rule.apply(&g).is_empty());\n+ }\n+\n+ #[test]\n+ fn skips_thread_id_on_branch_only_node() {\n+ let mut g = parallel_graph();\n+ g.nodes\n+ .get_mut(\"branch\")\n+ .expect(\"graph has branch\")\n+ .attrs\n+ .insert(\n+ \"thread_id\".to_string(),\n+ AttrValue::String(\"branch-thread\".to_string()),\n+ );\n+\n+ assert!(Rule.apply(&g).is_empty());\n+ }\n+\n+ #[test]\n+ fn checks_thread_id_on_branch_node_with_normal_entry() {\n+ let mut g = parallel_graph();\n+ g.edges.push(Edge::new(\"start\", \"branch\"));\n+ g.nodes\n+ .get_mut(\"branch\")\n+ .expect(\"graph has branch\")\n+ .attrs\n+ .insert(\n+ \"thread_id\".to_string(),\n+ AttrValue::String(\"shared-thread\".to_string()),\n+ );\n+\n+ let d = Rule.apply(&g);\n+\n+ assert_eq!(d.len(), 1);\n+ assert_eq!(d[0].node_id.as_deref(), Some(\"branch\"));\n+ }\n+\n #[test]\n fn thread_id_requires_fidelity_full_graph_warns() {\n let mut g = minimal_graph();\ndiff --git a/lib/crates/fabro-workflow/src/artifact.rs b/lib/crates/fabro-workflow/src/artifact.rs\nindex 3f9f9eb7d..975fee26d 100644\n--- a/lib/crates/fabro-workflow/src/artifact.rs\n+++ b/lib/crates/fabro-workflow/src/artifact.rs\n@@ -79,6 +79,7 @@ pub fn format_artifact_reference(path: &str) -> String {\n pub fn durable_context_snapshot(context: &Context) -> HashMap {\n let mut snapshot = context.snapshot();\n snapshot.remove(context::keys::CURRENT_PREAMBLE);\n+ snapshot.remove(context::keys::INTERNAL_PARALLEL_BRANCH_PREAMBLES);\n normalize_durable_updates(&mut snapshot);\n snapshot\n }\n@@ -99,6 +100,9 @@ pub fn normalize_checkpoint_for_resume(checkpoint: &mut Checkpoint) {\n checkpoint\n .context_values\n .remove(context::keys::CURRENT_PREAMBLE);\n+ checkpoint\n+ .context_values\n+ .remove(context::keys::INTERNAL_PARALLEL_BRANCH_PREAMBLES);\n normalize_durable_updates(&mut checkpoint.context_values);\n normalize_durable_outcomes(&mut checkpoint.node_outcomes);\n }\n@@ -515,6 +519,59 @@ mod tests {\n );\n }\n \n+ #[test]\n+ fn durable_context_snapshot_drops_parallel_branch_preambles() {\n+ let context = Context::new();\n+ context.set(\n+ context::keys::INTERNAL_PARALLEL_BRANCH_PREAMBLES,\n+ serde_json::json!({\"branch-a\": \"runtime only\"}),\n+ );\n+ context.set(\"response.work\", serde_json::json!(\"durable\"));\n+\n+ let snapshot = durable_context_snapshot(&context);\n+\n+ assert!(!snapshot.contains_key(context::keys::INTERNAL_PARALLEL_BRANCH_PREAMBLES));\n+ assert_eq!(\n+ snapshot.get(\"response.work\"),\n+ Some(&serde_json::json!(\"durable\"))\n+ );\n+ }\n+\n+ #[test]\n+ fn normalize_checkpoint_for_resume_drops_parallel_branch_preambles() {\n+ let mut checkpoint = crate::records::Checkpoint {\n+ timestamp: chrono::Utc::now(),\n+ current_node: \"work\".to_string(),\n+ completed_nodes: vec![\"work\".to_string()],\n+ node_retries: HashMap::new(),\n+ context_values: HashMap::from([\n+ (\n+ context::keys::INTERNAL_PARALLEL_BRANCH_PREAMBLES.to_string(),\n+ serde_json::json!({\"branch-a\": \"runtime only\"}),\n+ ),\n+ (\"response.work\".to_string(), serde_json::json!(\"durable\")),\n+ ]),\n+ node_outcomes: HashMap::new(),\n+ next_node_id: Some(\"exit\".to_string()),\n+ git_commit_sha: None,\n+ loop_failure_signatures: HashMap::new(),\n+ restart_failure_signatures: HashMap::new(),\n+ node_visits: HashMap::new(),\n+ };\n+\n+ normalize_checkpoint_for_resume(&mut checkpoint);\n+\n+ assert!(\n+ !checkpoint\n+ .context_values\n+ .contains_key(context::keys::INTERNAL_PARALLEL_BRANCH_PREAMBLES)\n+ );\n+ assert_eq!(\n+ checkpoint.context_values.get(\"response.work\"),\n+ Some(&serde_json::json!(\"durable\"))\n+ );\n+ }\n+\n #[test]\n fn normalize_checkpoint_for_resume_converts_managed_blob_file_refs_and_drops_preamble() {\n let blob_id = fabro_types::RunBlobId::new(b\"managed\");\ndiff --git a/lib/crates/fabro-workflow/src/context.rs b/lib/crates/fabro-workflow/src/context.rs\nindex af1233586..b911e2210 100644\n--- a/lib/crates/fabro-workflow/src/context.rs\n+++ b/lib/crates/fabro-workflow/src/context.rs\n@@ -25,6 +25,7 @@ pub mod keys {\n pub const INTERNAL_PARENT_PREAMBLE: &str = \"internal.parent_preamble\";\n pub const INTERNAL_PARALLEL_GROUP_ID: &str = \"internal.parallel_group_id\";\n pub const INTERNAL_PARALLEL_BRANCH_ID: &str = \"internal.parallel_branch_id\";\n+ pub const INTERNAL_PARALLEL_BRANCH_PREAMBLES: &str = \"internal.parallel_branch_preambles\";\n \n // --- current.* keys ---\n pub const CURRENT_PREAMBLE: &str = \"current.preamble\";\ndiff --git a/lib/crates/fabro-workflow/src/handler/parallel.rs b/lib/crates/fabro-workflow/src/handler/parallel.rs\nindex 22aa2aa9c..ac4e4f3e1 100644\n--- a/lib/crates/fabro-workflow/src/handler/parallel.rs\n+++ b/lib/crates/fabro-workflow/src/handler/parallel.rs\n@@ -4,6 +4,7 @@ use std::time::Instant;\n \n use async_trait::async_trait;\n use fabro_agent::{Sandbox, WorktreeOptions, WorktreeSandbox};\n+use fabro_graphviz::Fidelity;\n use fabro_graphviz::graph::{AttrValue, Graph, Node};\n use fabro_hooks::{HookContext, HookEvent};\n use fabro_types::{ParallelBranchId, RunId, StageId};\n@@ -56,6 +57,39 @@ struct BranchResult {\n worktree_path: Option,\n }\n \n+struct BranchPreamble {\n+ fidelity: Fidelity,\n+ preamble: String,\n+}\n+\n+fn parse_branch_preambles(\n+ value: Option,\n+ branch_count: usize,\n+) -> Option>> {\n+ let serde_json::Value::Array(entries) = value? else {\n+ return None;\n+ };\n+ if entries.len() != branch_count {\n+ return None;\n+ }\n+\n+ entries\n+ .into_iter()\n+ .map(|entry| match entry {\n+ serde_json::Value::Null => Some(None),\n+ serde_json::Value::Object(entry) if entry.len() == 2 => {\n+ let fidelity = entry.get(\"fidelity\")?.as_str()?.parse().ok()?;\n+ let preamble = entry.get(\"preamble\")?.as_str()?;\n+ Some(Some(BranchPreamble {\n+ fidelity,\n+ preamble: preamble.to_string(),\n+ }))\n+ }\n+ _ => None,\n+ })\n+ .collect()\n+}\n+\n #[async_trait]\n impl Handler for ParallelHandler {\n async fn simulate(\n@@ -220,6 +254,10 @@ impl Handler for ParallelHandler {\n None\n };\n \n+ let branch_preambles = parse_branch_preambles(\n+ context.get(keys::INTERNAL_PARALLEL_BRANCH_PREAMBLES),\n+ branches.len(),\n+ );\n let mut branch_setups: Vec = Vec::new();\n for (branch_index, edge) in branches.iter().enumerate() {\n let target_id = edge.to.clone();\n@@ -236,6 +274,21 @@ impl Handler for ParallelHandler {\n keys::INTERNAL_PARALLEL_BRANCH_ID,\n serde_json::Value::String(parallel_branch_id.to_string()),\n );\n+ if let Some(entry) = branch_preambles\n+ .as_ref()\n+ .and_then(|entries| entries.get(branch_index))\n+ .and_then(Option::as_ref)\n+ {\n+ branch_context.set(keys::CURRENT_PREAMBLE, serde_json::json!(&entry.preamble));\n+ branch_context.set(\n+ keys::INTERNAL_FIDELITY,\n+ serde_json::json!(entry.fidelity.to_string()),\n+ );\n+ }\n+ branch_context.set(\n+ keys::INTERNAL_PARALLEL_BRANCH_PREAMBLES,\n+ serde_json::Value::Null,\n+ );\n \n let (branch_sandbox, worktree_path): (Arc, Option) = if let (\n Some(ref gs),\n@@ -297,6 +350,10 @@ impl Handler for ParallelHandler {\n worktree_path,\n });\n }\n+ context.set(\n+ keys::INTERNAL_PARALLEL_BRANCH_PREAMBLES,\n+ serde_json::Value::Null,\n+ );\n \n // --- Fan out: concurrent execution ---\n let mut handles = Vec::new();\n@@ -693,7 +750,7 @@ fn parallel_branch_commit_cmd(\n \n #[cfg(test)]\n mod tests {\n- use std::sync::Arc;\n+ use std::sync::{Arc, Mutex};\n use std::time::Duration;\n \n use fabro_graphviz::graph::{AttrValue, Edge};\n@@ -756,6 +813,185 @@ mod tests {\n context\n }\n \n+ #[derive(Clone, Debug, PartialEq)]\n+ struct BranchContextCapture {\n+ node_id: String,\n+ preamble: String,\n+ fidelity: String,\n+ stash: Option,\n+ }\n+\n+ struct BranchContextRecordingHandler {\n+ captures: Arc>>,\n+ }\n+\n+ #[async_trait]\n+ impl Handler for BranchContextRecordingHandler {\n+ async fn execute(\n+ &self,\n+ node: &Node,\n+ context: &Context,\n+ _graph: &Graph,\n+ _run_dir: &Path,\n+ _services: &EngineServices,\n+ ) -> Result {\n+ self.captures.lock().unwrap().push(BranchContextCapture {\n+ node_id: node.id.clone(),\n+ preamble: context.preamble(),\n+ fidelity: context.fidelity().to_string(),\n+ stash: context.get(keys::INTERNAL_PARALLEL_BRANCH_PREAMBLES),\n+ });\n+ Ok(Outcome::success())\n+ }\n+ }\n+\n+ async fn execute_with_branch_stash(\n+ stash: Option,\n+ duplicate_target: bool,\n+ ) -> (Context, Vec) {\n+ let captures = Arc::new(Mutex::new(Vec::new()));\n+ let recorder = BranchContextRecordingHandler {\n+ captures: Arc::clone(&captures),\n+ };\n+ let mut registry = super::super::HandlerRegistry::new(Box::new(recorder));\n+ registry.register(\n+ \"record\",\n+ Box::new(BranchContextRecordingHandler {\n+ captures: Arc::clone(&captures),\n+ }),\n+ );\n+ let mut services = EngineServices::test_default();\n+ services.registry = Arc::new(registry);\n+\n+ let mut node = Node::new(\"par\");\n+ node.attrs.insert(\n+ \"shape\".to_string(),\n+ AttrValue::String(\"component\".to_string()),\n+ );\n+ let mut branch_a = Node::new(\"branch_a\");\n+ branch_a\n+ .attrs\n+ .insert(\"type\".to_string(), AttrValue::String(\"record\".to_string()));\n+ let mut branch_b = Node::new(\"branch_b\");\n+ branch_b\n+ .attrs\n+ .insert(\"type\".to_string(), AttrValue::String(\"record\".to_string()));\n+\n+ let mut graph = Graph::new(\"test\");\n+ graph.nodes.insert(node.id.clone(), node.clone());\n+ graph.nodes.insert(branch_a.id.clone(), branch_a);\n+ graph.nodes.insert(branch_b.id.clone(), branch_b);\n+ graph.edges.push(Edge::new(\"par\", \"branch_a\"));\n+ graph.edges.push(Edge::new(\n+ \"par\",\n+ if duplicate_target {\n+ \"branch_a\"\n+ } else {\n+ \"branch_b\"\n+ },\n+ ));\n+\n+ let context = test_context();\n+ context.set(keys::CURRENT_PREAMBLE, serde_json::json!(\"fork preamble\"));\n+ context.set(keys::INTERNAL_FIDELITY, serde_json::json!(\"compact\"));\n+ if let Some(stash) = stash {\n+ context.set(keys::INTERNAL_PARALLEL_BRANCH_PREAMBLES, stash);\n+ }\n+\n+ let run_dir = tempfile::tempdir().unwrap();\n+ ParallelHandler\n+ .execute(&node, &context, &graph, run_dir.path(), &services)\n+ .await\n+ .unwrap();\n+\n+ let captures = captures.lock().unwrap().clone();\n+ (context, captures)\n+ }\n+\n+ #[tokio::test]\n+ async fn parallel_handler_applies_indexed_branch_preambles_and_clears_stash() {\n+ let stash = serde_json::json!([\n+ {\"fidelity\": \"truncate\", \"preamble\": \"branch zero\"},\n+ {\"fidelity\": \"summary:high\", \"preamble\": \"branch one\"}\n+ ]);\n+\n+ let (context, mut captures) = execute_with_branch_stash(Some(stash), false).await;\n+ captures.sort_by(|left, right| left.node_id.cmp(&right.node_id));\n+\n+ assert_eq!(captures.len(), 2);\n+ assert_eq!(captures[0].node_id, \"branch_a\");\n+ assert_eq!(captures[0].preamble, \"branch zero\");\n+ assert_eq!(captures[0].fidelity, \"truncate\");\n+ assert_eq!(captures[0].stash, Some(serde_json::Value::Null));\n+ assert_eq!(captures[1].node_id, \"branch_b\");\n+ assert_eq!(captures[1].preamble, \"branch one\");\n+ assert_eq!(captures[1].fidelity, \"summary:high\");\n+ assert_eq!(captures[1].stash, Some(serde_json::Value::Null));\n+ assert_eq!(\n+ context.get(keys::INTERNAL_PARALLEL_BRANCH_PREAMBLES),\n+ Some(serde_json::Value::Null)\n+ );\n+ }\n+\n+ #[tokio::test]\n+ async fn parallel_handler_uses_edge_index_for_duplicate_targets() {\n+ let stash = serde_json::json!([\n+ {\"fidelity\": \"truncate\", \"preamble\": \"first edge\"},\n+ {\"fidelity\": \"summary:low\", \"preamble\": \"second edge\"}\n+ ]);\n+\n+ let (_context, captures) = execute_with_branch_stash(Some(stash), true).await;\n+ let observed = captures\n+ .iter()\n+ .map(|capture| (capture.preamble.as_str(), capture.fidelity.as_str()))\n+ .collect::>();\n+\n+ assert_eq!(observed.len(), 2);\n+ assert!(observed.contains(&(\"first edge\", \"truncate\")));\n+ assert!(observed.contains(&(\"second edge\", \"summary:low\")));\n+ assert!(\n+ captures\n+ .iter()\n+ .all(|capture| capture.stash == Some(serde_json::Value::Null))\n+ );\n+ }\n+\n+ #[tokio::test]\n+ async fn parallel_handler_legacy_stashes_inherit_fork_context() {\n+ for stash in [\n+ None,\n+ Some(serde_json::Value::Null),\n+ Some(serde_json::json!({\n+ \"fidelity\": \"truncate\",\n+ \"preamble\": \"not an array\"\n+ })),\n+ Some(serde_json::json!([\n+ {\"fidelity\": \"truncate\", \"preamble\": \"wrong length\"}\n+ ])),\n+ Some(serde_json::json!([\n+ {\"fidelity\": \"truncate\"},\n+ null\n+ ])),\n+ Some(serde_json::json!([\n+ {\"fidelity\": \"not-a-fidelity\", \"preamble\": \"malformed fidelity\"},\n+ null\n+ ])),\n+ ] {\n+ let (context, captures) = execute_with_branch_stash(stash, false).await;\n+\n+ assert_eq!(captures.len(), 2);\n+ assert!(captures.iter().all(|capture| {\n+ capture.preamble == \"fork preamble\"\n+ && capture.fidelity == \"compact\"\n+ && capture.stash == Some(serde_json::Value::Null)\n+ }));\n+ assert_eq!(\n+ context.get(keys::INTERNAL_PARALLEL_BRANCH_PREAMBLES),\n+ Some(serde_json::Value::Null)\n+ );\n+ }\n+ }\n+\n #[tokio::test]\n async fn parallel_handler_no_branches() {\n let services = make_services();\ndiff --git a/lib/crates/fabro-workflow/src/lifecycle/event.rs b/lib/crates/fabro-workflow/src/lifecycle/event.rs\nindex 5828dc86b..3965d7261 100644\n--- a/lib/crates/fabro-workflow/src/lifecycle/event.rs\n+++ b/lib/crates/fabro-workflow/src/lifecycle/event.rs\n@@ -14,7 +14,7 @@ use fabro_types::{Principal, RunId, StageTiming};\n \n use super::circuit_breaker::CircuitBreakerLifecycle;\n use super::git::GitCheckpointResult;\n-use crate::context::WorkflowContext;\n+use crate::context::{Context, WorkflowContext};\n use crate::event::{Emitter, Event, StageScope};\n use crate::graph::{WorkflowGraph, WorkflowNode};\n use crate::outcome::{BilledModelUsage, FailureCategory, FailureDetail, Outcome, StageOutcome};\n@@ -92,6 +92,12 @@ fn response_from_outcome(node_id: &str, outcome: &Outcome) -> Option {\n .and_then(|value| value.as_str().map(ToOwned::to_owned))\n }\n \n+fn stage_context_values(workflow_context: &Context) -> Option> {\n+ let mut snapshot = workflow_context.snapshot();\n+ snapshot.remove(context::keys::INTERNAL_PARALLEL_BRANCH_PREAMBLES);\n+ (!snapshot.is_empty()).then(|| snapshot.into_iter().collect())\n+}\n+\n pub(super) fn stage_visit(state: &WfRunState, node_id: &str) -> u32 {\n let visits = state.node_visits.get(node_id).copied().unwrap_or(1);\n u32::try_from(visits).unwrap_or(u32::MAX)\n@@ -318,11 +324,7 @@ impl RunLifecycle for EventLifecycle {\n .collect::>()\n }),\n jump_to_node: outcome.jump_to_node.clone(),\n- context_values: {\n- let snapshot = state.context.snapshot();\n- (!snapshot.is_empty())\n- .then(|| snapshot.into_iter().collect::>())\n- },\n+ context_values: stage_context_values(&state.context),\n node_visits: (!state.node_visits.is_empty()).then(|| {\n state\n .node_visits\n@@ -446,3 +448,26 @@ impl RunLifecycle for EventLifecycle {\n Ok(())\n }\n }\n+\n+#[cfg(test)]\n+mod tests {\n+ use super::*;\n+\n+ #[test]\n+ fn stage_context_values_drops_parallel_branch_preambles() {\n+ let workflow_context = Context::new();\n+ workflow_context.set(\n+ context::keys::INTERNAL_PARALLEL_BRANCH_PREAMBLES,\n+ serde_json::json!([{\"fidelity\": \"summary:high\", \"preamble\": \"runtime only\"}]),\n+ );\n+ workflow_context.set(\"response.work\", serde_json::json!(\"durable\"));\n+\n+ let values = stage_context_values(&workflow_context).expect(\"snapshot should not be empty\");\n+\n+ assert!(!values.contains_key(context::keys::INTERNAL_PARALLEL_BRANCH_PREAMBLES));\n+ assert_eq!(\n+ values.get(\"response.work\"),\n+ Some(&serde_json::json!(\"durable\"))\n+ );\n+ }\n+}\ndiff --git a/lib/crates/fabro-workflow/src/lifecycle/fidelity.rs b/lib/crates/fabro-workflow/src/lifecycle/fidelity.rs\nindex 3f45c8142..3f0b24228 100644\n--- a/lib/crates/fabro-workflow/src/lifecycle/fidelity.rs\n+++ b/lib/crates/fabro-workflow/src/lifecycle/fidelity.rs\n@@ -78,6 +78,11 @@ impl RunLifecycle for FidelityLifecycle {\n node: &WorkflowNode,\n state: &WfRunState,\n ) -> CoreResult {\n+ state.context.set(\n+ keys::INTERNAL_PARALLEL_BRANCH_PREAMBLES,\n+ serde_json::Value::Null,\n+ );\n+\n let incoming = self\n .incoming_edge_data\n .lock()\n@@ -138,6 +143,46 @@ impl RunLifecycle for FidelityLifecycle {\n .context\n .set(keys::CURRENT_PREAMBLE, serde_json::json!(preamble));\n \n+ if gv_node.handler_type() == Some(\"parallel\") {\n+ let mut branch_preambles = Vec::new();\n+ for (branch_index, edge) in self.graph.outgoing_edges(node.id()).iter().enumerate() {\n+ let Some(target_node) = self.graph.nodes.get(&edge.to) else {\n+ branch_preambles.push(serde_json::Value::Null);\n+ continue;\n+ };\n+ let resolution = resolve_parallel_branch_fidelity(edge, target_node, fidelity);\n+ if resolution.requested() == Some(keys::Fidelity::Full) {\n+ tracing::warn!(\n+ parallel_node = %node.id(),\n+ branch = %edge.to,\n+ branch_index,\n+ fidelity = %keys::Fidelity::Full,\n+ effective_fidelity = %keys::Fidelity::SummaryHigh,\n+ \"Parallel branch fidelity degraded\"\n+ );\n+ }\n+ let Some(branch_fidelity) = resolution.entry_fidelity() else {\n+ branch_preambles.push(serde_json::Value::Null);\n+ continue;\n+ };\n+ let branch_preamble = build_preamble(\n+ branch_fidelity,\n+ &resolved_context,\n+ &self.graph,\n+ &state.completed_nodes,\n+ &resolved_outcomes,\n+ );\n+ branch_preambles.push(serde_json::json!({\n+ \"fidelity\": branch_fidelity.to_string(),\n+ \"preamble\": branch_preamble,\n+ }));\n+ }\n+ state.context.set(\n+ keys::INTERNAL_PARALLEL_BRANCH_PREAMBLES,\n+ serde_json::Value::Array(branch_preambles),\n+ );\n+ }\n+\n // 5. Thread ID resolution via resolve_thread_id: edge → node → graph default →\n // class → previous\n let thread_id = resolve_thread_id(\n@@ -198,6 +243,47 @@ impl RunLifecycle for FidelityLifecycle {\n }\n }\n \n+#[derive(Debug, Clone, Copy, PartialEq, Eq)]\n+struct ParallelBranchFidelityResolution {\n+ requested: Option,\n+ effective: Option,\n+}\n+\n+impl ParallelBranchFidelityResolution {\n+ fn requested(self) -> Option {\n+ self.requested\n+ }\n+\n+ fn entry_fidelity(self) -> Option {\n+ self.effective\n+ }\n+}\n+\n+/// Resolve explicit branch fidelity with edge-over-node precedence.\n+///\n+/// Branches with no explicit fidelity inherit the parallel node's preamble.\n+/// Explicit full fidelity is degraded because concurrent branches cannot share\n+/// an LLM session. An effective fidelity equal to the parallel node also\n+/// inherits, avoiding a redundant preamble render.\n+fn resolve_parallel_branch_fidelity(\n+ edge: &GvEdge,\n+ target_node: &GvNode,\n+ parallel_fidelity: keys::Fidelity,\n+) -> ParallelBranchFidelityResolution {\n+ let requested = edge\n+ .fidelity()\n+ .and_then(|value| value.parse().ok())\n+ .or_else(|| target_node.fidelity().and_then(|value| value.parse().ok()));\n+ let effective = requested\n+ .map(keys::Fidelity::degraded)\n+ .filter(|fidelity| *fidelity != parallel_fidelity);\n+\n+ ParallelBranchFidelityResolution {\n+ requested,\n+ effective,\n+ }\n+}\n+\n /// Resolve the context fidelity for a node, following the precedence:\n /// 1. Incoming edge `fidelity` attribute\n /// 2. Target node `fidelity` attribute\n@@ -263,11 +349,216 @@ fn resolve_thread_id(\n \n #[cfg(test)]\n mod tests {\n+ use std::path::Path;\n+ use std::time::Duration;\n+\n+ use fabro_core::graph::Graph as CoreGraph;\n use fabro_graphviz::graph::{AttrValue, Edge, Graph, Node};\n+ use fabro_store::Database;\n+ use fabro_types::fixtures;\n+ use object_store::memory::InMemory;\n \n use super::*;\n+ use crate::context::WorkflowContext;\n use crate::context::keys::Fidelity;\n \n+ fn fidelity_attr(value: &str) -> AttrValue {\n+ AttrValue::String(value.to_string())\n+ }\n+\n+ fn parallel_workflow_graph(\n+ fork_fidelity: Option<&str>,\n+ branch_a_fidelity: Option<&str>,\n+ ) -> WorkflowGraph {\n+ let mut graph = Graph::new(\"parallel-fidelity\");\n+ let mut start = Node::new(\"start\");\n+ start\n+ .attrs\n+ .insert(\"shape\".to_string(), fidelity_attr(\"Mdiamond\"));\n+ let mut fork = Node::new(\"fork\");\n+ fork.attrs\n+ .insert(\"shape\".to_string(), fidelity_attr(\"component\"));\n+ if let Some(fidelity) = fork_fidelity {\n+ fork.attrs\n+ .insert(\"fidelity\".to_string(), fidelity_attr(fidelity));\n+ }\n+ let mut branch_a = Node::new(\"branch_a\");\n+ if let Some(fidelity) = branch_a_fidelity {\n+ branch_a\n+ .attrs\n+ .insert(\"fidelity\".to_string(), fidelity_attr(fidelity));\n+ }\n+ let branch_b = Node::new(\"branch_b\");\n+ let mut work = Node::new(\"work\");\n+ work.attrs.insert(\"shape\".to_string(), fidelity_attr(\"box\"));\n+\n+ graph.nodes.insert(start.id.clone(), start);\n+ graph.nodes.insert(fork.id.clone(), fork);\n+ graph.nodes.insert(branch_a.id.clone(), branch_a);\n+ graph.nodes.insert(branch_b.id.clone(), branch_b);\n+ graph.nodes.insert(work.id.clone(), work);\n+ graph.edges.push(Edge::new(\"start\", \"fork\"));\n+ graph.edges.push(Edge::new(\"fork\", \"branch_a\"));\n+ graph.edges.push(Edge::new(\"fork\", \"branch_b\"));\n+\n+ WorkflowGraph(Arc::new(graph))\n+ }\n+\n+ async fn test_lifecycle(graph: &WorkflowGraph, run_dir: &Path) -> FidelityLifecycle {\n+ let store = Arc::new(Database::new(\n+ Arc::new(InMemory::new()),\n+ \"\",\n+ Duration::from_millis(1),\n+ None,\n+ ));\n+ let run_store = store.create_run(&fixtures::RUN_1).await.unwrap();\n+ let sandbox: Arc =\n+ Arc::new(fabro_agent::LocalSandbox::new(run_dir.to_path_buf()));\n+ FidelityLifecycle::new(\n+ graph.0.clone(),\n+ sandbox,\n+ RunStoreHandle::local(run_store),\n+ run_dir.to_path_buf(),\n+ )\n+ }\n+\n+ #[test]\n+ fn parallel_branch_fidelity_edge_overrides_node() {\n+ let mut node = Node::new(\"branch\");\n+ node.attrs\n+ .insert(\"fidelity\".to_string(), fidelity_attr(\"compact\"));\n+ let mut edge = Edge::new(\"fork\", \"branch\");\n+ edge.attrs\n+ .insert(\"fidelity\".to_string(), fidelity_attr(\"truncate\"));\n+\n+ let resolved = resolve_parallel_branch_fidelity(&edge, &node, Fidelity::SummaryHigh);\n+\n+ assert_eq!(resolved.requested(), Some(Fidelity::Truncate));\n+ assert_eq!(resolved.entry_fidelity(), Some(Fidelity::Truncate));\n+ }\n+\n+ #[test]\n+ fn parallel_branch_fidelity_without_attribute_inherits() {\n+ let node = Node::new(\"branch\");\n+ let edge = Edge::new(\"fork\", \"branch\");\n+\n+ let resolution = resolve_parallel_branch_fidelity(&edge, &node, Fidelity::Compact);\n+\n+ assert_eq!(resolution.requested(), None);\n+ assert_eq!(resolution.entry_fidelity(), None);\n+ }\n+\n+ #[test]\n+ fn parallel_branch_full_fidelity_degrades_to_summary_high() {\n+ let mut node = Node::new(\"branch\");\n+ node.attrs\n+ .insert(\"fidelity\".to_string(), fidelity_attr(\"full\"));\n+ let edge = Edge::new(\"fork\", \"branch\");\n+\n+ let resolved = resolve_parallel_branch_fidelity(&edge, &node, Fidelity::Compact);\n+\n+ assert_eq!(resolved.requested(), Some(Fidelity::Full));\n+ assert_eq!(resolved.entry_fidelity(), Some(Fidelity::SummaryHigh));\n+ }\n+\n+ #[test]\n+ fn parallel_branch_fidelity_equal_to_fork_inherits() {\n+ let mut node = Node::new(\"branch\");\n+ node.attrs\n+ .insert(\"fidelity\".to_string(), fidelity_attr(\"summary:high\"));\n+ let edge = Edge::new(\"fork\", \"branch\");\n+\n+ let resolution = resolve_parallel_branch_fidelity(&edge, &node, Fidelity::SummaryHigh);\n+\n+ assert_eq!(resolution.requested(), Some(Fidelity::SummaryHigh));\n+ assert_eq!(resolution.entry_fidelity(), None);\n+ }\n+\n+ #[test]\n+ fn explicit_full_branch_equal_to_degraded_fork_inherits() {\n+ let mut node = Node::new(\"branch\");\n+ node.attrs\n+ .insert(\"fidelity\".to_string(), fidelity_attr(\"full\"));\n+ let edge = Edge::new(\"fork\", \"branch\");\n+\n+ let resolution = resolve_parallel_branch_fidelity(&edge, &node, Fidelity::SummaryHigh);\n+\n+ assert_eq!(resolution.requested(), Some(Fidelity::Full));\n+ assert_eq!(resolution.entry_fidelity(), None);\n+ }\n+\n+ #[test]\n+ fn full_fork_without_branch_fidelity_does_not_create_entry() {\n+ let node = Node::new(\"branch\");\n+ let edge = Edge::new(\"fork\", \"branch\");\n+\n+ let resolution = resolve_parallel_branch_fidelity(&edge, &node, Fidelity::Full);\n+\n+ assert_eq!(resolution.requested(), None);\n+ assert_eq!(resolution.entry_fidelity(), None);\n+ }\n+\n+ #[tokio::test]\n+ async fn parallel_before_node_rebuilds_branch_preamble_stash() {\n+ let graph = parallel_workflow_graph(None, Some(\"truncate\"));\n+ let run_dir = tempfile::tempdir().unwrap();\n+ let lifecycle = test_lifecycle(&graph, run_dir.path()).await;\n+ let state: WfRunState = ExecutionState::new(&graph).unwrap();\n+ let fork = graph.get_node(\"fork\").unwrap();\n+\n+ lifecycle.before_node(&fork, &state).await.unwrap();\n+ state.context.set(\n+ keys::INTERNAL_PARALLEL_BRANCH_PREAMBLES,\n+ serde_json::json!([\"stale\", \"entries\", \"must disappear\"]),\n+ );\n+ lifecycle.before_node(&fork, &state).await.unwrap();\n+\n+ let stash = state\n+ .context\n+ .get(keys::INTERNAL_PARALLEL_BRANCH_PREAMBLES)\n+ .expect(\"parallel stash should be set\");\n+ let entries = stash.as_array().expect(\"parallel stash should be an array\");\n+ assert_eq!(entries.len(), 2);\n+ assert!(entries[0].is_object());\n+ assert!(entries[1].is_null());\n+ }\n+\n+ #[tokio::test]\n+ async fn non_parallel_before_node_overwrites_branch_preamble_stash_with_null() {\n+ let graph = parallel_workflow_graph(None, Some(\"truncate\"));\n+ let run_dir = tempfile::tempdir().unwrap();\n+ let lifecycle = test_lifecycle(&graph, run_dir.path()).await;\n+ let state: WfRunState = ExecutionState::new(&graph).unwrap();\n+ let fork = graph.get_node(\"fork\").unwrap();\n+ let work = graph.get_node(\"work\").unwrap();\n+\n+ lifecycle.before_node(&fork, &state).await.unwrap();\n+ lifecycle.before_node(&work, &state).await.unwrap();\n+\n+ assert_eq!(\n+ state.context.get(keys::INTERNAL_PARALLEL_BRANCH_PREAMBLES),\n+ Some(serde_json::Value::Null)\n+ );\n+ }\n+\n+ #[tokio::test]\n+ async fn resumed_full_fork_degrades_without_rendering_fallback_branches() {\n+ let graph = parallel_workflow_graph(Some(\"full\"), None);\n+ let run_dir = tempfile::tempdir().unwrap();\n+ let lifecycle = test_lifecycle(&graph, run_dir.path()).await;\n+ lifecycle.set_degrade_fidelity_on_resume(true);\n+ let state: WfRunState = ExecutionState::new(&graph).unwrap();\n+ let fork = graph.get_node(\"fork\").unwrap();\n+\n+ lifecycle.before_node(&fork, &state).await.unwrap();\n+\n+ assert_eq!(state.context.fidelity(), Fidelity::SummaryHigh);\n+ assert_eq!(\n+ state.context.get(keys::INTERNAL_PARALLEL_BRANCH_PREAMBLES),\n+ Some(serde_json::json!([null, null]))\n+ );\n+ }\n+\n #[test]\n fn fidelity_defaults_to_compact() {\n let node = Node::new(\"work\");\ndiff --git a/lib/crates/fabro-workflow/tests/it/integration.rs b/lib/crates/fabro-workflow/tests/it/integration.rs\nindex 87ebeee4d..88e1f1aa9 100644\n--- a/lib/crates/fabro-workflow/tests/it/integration.rs\n+++ b/lib/crates/fabro-workflow/tests/it/integration.rs\n@@ -4936,6 +4936,27 @@ struct FidelityCapturingHandler {\n captures: FidelityCaptures,\n }\n \n+struct ParallelFidelitySeedHandler;\n+\n+#[async_trait::async_trait]\n+impl Handler for ParallelFidelitySeedHandler {\n+ async fn execute(\n+ &self,\n+ _node: &Node,\n+ _context: &Context,\n+ _graph: &Graph,\n+ _run_dir: &Path,\n+ _services: &fabro_workflow::handler::EngineServices,\n+ ) -> Result {\n+ let mut outcome = Outcome::success();\n+ outcome.context_updates.insert(\n+ \"parallel_fidelity_marker\".to_string(),\n+ serde_json::json!(\"marker visible to inherited preambles\"),\n+ );\n+ Ok(outcome)\n+ }\n+}\n+\n #[async_trait::async_trait]\n impl Handler for FidelityCapturingHandler {\n async fn execute(\n@@ -9333,6 +9354,176 @@ async fn run_fidelity_prompt_pipeline(fidelity: &str) -> String {\n .expect(\"report prompt should exist\")\n }\n \n+async fn run_parallel_fidelity_capture(\n+ fork_fidelity: Option<&str>,\n+ branch_node_fidelity: Option<&str>,\n+ branch_edge_fidelity: Option<&str>,\n+) -> FidelityCaptures {\n+ use fabro_workflow::handler::fan_in::FanInHandler;\n+ use fabro_workflow::handler::parallel::ParallelHandler;\n+\n+ let mut graph = make_graph_with_start_exit(\"ParallelFidelityTest\");\n+ graph.attrs.insert(\n+ \"goal\".to_string(),\n+ AttrValue::String(\"Verify parallel branch context\".to_string()),\n+ );\n+\n+ let mut seed = Node::new(\"seed\");\n+ seed.attrs.insert(\n+ \"type\".to_string(),\n+ AttrValue::String(\"parallel_fidelity_seed\".to_string()),\n+ );\n+ let mut fork = Node::new(\"fork\");\n+ fork.attrs.insert(\n+ \"shape\".to_string(),\n+ AttrValue::String(\"component\".to_string()),\n+ );\n+ if let Some(fidelity) = fork_fidelity {\n+ fork.attrs.insert(\n+ \"fidelity\".to_string(),\n+ AttrValue::String(fidelity.to_string()),\n+ );\n+ }\n+ let mut branch_a = Node::new(\"branch_a\");\n+ branch_a.attrs.insert(\n+ \"type\".to_string(),\n+ AttrValue::String(\"fidelity_capture\".to_string()),\n+ );\n+ if let Some(fidelity) = branch_node_fidelity {\n+ branch_a.attrs.insert(\n+ \"fidelity\".to_string(),\n+ AttrValue::String(fidelity.to_string()),\n+ );\n+ }\n+ let mut branch_b = Node::new(\"branch_b\");\n+ branch_b.attrs.insert(\n+ \"type\".to_string(),\n+ AttrValue::String(\"fidelity_capture\".to_string()),\n+ );\n+ let mut fan_in = Node::new(\"fan_in\");\n+ fan_in.attrs.insert(\n+ \"shape\".to_string(),\n+ AttrValue::String(\"tripleoctagon\".to_string()),\n+ );\n+\n+ graph.nodes.insert(seed.id.clone(), seed);\n+ graph.nodes.insert(fork.id.clone(), fork);\n+ graph.nodes.insert(branch_a.id.clone(), branch_a);\n+ graph.nodes.insert(branch_b.id.clone(), branch_b);\n+ graph.nodes.insert(fan_in.id.clone(), fan_in);\n+ graph.edges.push(Edge::new(\"start\", \"seed\"));\n+ graph.edges.push(Edge::new(\"seed\", \"fork\"));\n+ let mut branch_a_edge = Edge::new(\"fork\", \"branch_a\");\n+ if let Some(fidelity) = branch_edge_fidelity {\n+ branch_a_edge.attrs.insert(\n+ \"fidelity\".to_string(),\n+ AttrValue::String(fidelity.to_string()),\n+ );\n+ }\n+ graph.edges.push(branch_a_edge);\n+ graph.edges.push(Edge::new(\"fork\", \"branch_b\"));\n+ graph.edges.push(Edge::new(\"branch_a\", \"fan_in\"));\n+ graph.edges.push(Edge::new(\"branch_b\", \"fan_in\"));\n+ graph.edges.push(Edge::new(\"fan_in\", \"exit\"));\n+\n+ let captures = FidelityCaptures::new();\n+ let mut registry = HandlerRegistry::new(Box::new(StartHandler));\n+ registry.register(\"start\", Box::new(StartHandler));\n+ registry.register(\"exit\", Box::new(ExitHandler));\n+ registry.register(\"parallel\", Box::new(ParallelHandler));\n+ registry.register(\n+ \"parallel.fan_in\",\n+ Box::new(FanInHandler::new(Some(Box::new(MockCodergenBackend)))),\n+ );\n+ registry.register(\n+ \"parallel_fidelity_seed\",\n+ Box::new(ParallelFidelitySeedHandler),\n+ );\n+ registry.register(\n+ \"fidelity_capture\",\n+ Box::new(FidelityCapturingHandler {\n+ captures: captures.clone(),\n+ }),\n+ );\n+\n+ let dir = tempfile::tempdir().expect(\"parallel fidelity run directory should be created\");\n+ let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env());\n+ let run_options = RunOptions {\n+ settings: WorkflowSettings::default(),\n+ run_dir: dir.path().to_path_buf(),\n+ cancel_token: CancellationToken::new(),\n+ run_id: test_run_id(\"parallel-fidelity\"),\n+ labels: std::collections::HashMap::new(),\n+ workflow_slug: None,\n+ github_app: None,\n+ base_branch: None,\n+ display_base_sha: None,\n+ pre_run_git: None,\n+ fork_source_ref: None,\n+ git: None,\n+ };\n+ let (outcome, _state) = engine\n+ .run_with_state(&graph, &run_options)\n+ .await\n+ .expect(\"parallel fidelity workflow should succeed\");\n+ assert_eq!(outcome.status, StageOutcome::Succeeded);\n+ captures\n+}\n+\n+fn captured_fidelity_preamble(captures: &FidelityCaptures, node_id: &str) -> (String, String) {\n+ let fidelity = captures\n+ .fidelities\n+ .lock()\n+ .unwrap()\n+ .iter()\n+ .find(|(captured_node_id, _)| captured_node_id == node_id)\n+ .map(|(_, fidelity)| fidelity.clone())\n+ .expect(\"branch fidelity should be captured\");\n+ let preamble = captures\n+ .preambles\n+ .lock()\n+ .unwrap()\n+ .iter()\n+ .find(|(captured_node_id, _)| captured_node_id == node_id)\n+ .map(|(_, preamble)| preamble.clone())\n+ .expect(\"branch preamble should be captured\");\n+ (fidelity, preamble)\n+}\n+\n+#[tokio::test]\n+async fn parallel_branches_get_per_branch_preambles_by_fidelity() {\n+ let captures = run_parallel_fidelity_capture(None, Some(\"truncate\"), None).await;\n+\n+ let (branch_a_fidelity, branch_a_preamble) = captured_fidelity_preamble(&captures, \"branch_a\");\n+ let (branch_b_fidelity, branch_b_preamble) = captured_fidelity_preamble(&captures, \"branch_b\");\n+\n+ assert_eq!(branch_a_fidelity, \"truncate\");\n+ assert!(!branch_a_preamble.contains(\"parallel_fidelity_marker\"));\n+ assert_eq!(branch_b_fidelity, \"compact\");\n+ assert!(branch_b_preamble.contains(\"parallel_fidelity_marker\"));\n+}\n+\n+#[tokio::test]\n+async fn parallel_fork_fidelity_still_applies_to_all_branches() {\n+ let captures = run_parallel_fidelity_capture(Some(\"truncate\"), None, None).await;\n+\n+ for branch_id in [\"branch_a\", \"branch_b\"] {\n+ let (fidelity, preamble) = captured_fidelity_preamble(&captures, branch_id);\n+ assert_eq!(fidelity, \"truncate\");\n+ assert!(!preamble.contains(\"parallel_fidelity_marker\"));\n+ }\n+}\n+\n+#[tokio::test]\n+async fn parallel_branch_edge_fidelity_overrides_node_fidelity() {\n+ let captures =\n+ run_parallel_fidelity_capture(None, Some(\"summary:high\"), Some(\"truncate\")).await;\n+\n+ let (fidelity, preamble) = captured_fidelity_preamble(&captures, \"branch_a\");\n+ assert_eq!(fidelity, \"truncate\");\n+ assert!(!preamble.contains(\"parallel_fidelity_marker\"));\n+}\n+\n #[tokio::test]\n async fn fidelity_prompt_compact() {\n let prompt = run_fidelity_prompt_pipeline(\"compact\").await;\n", + "summary": { + "files_changed": 13, + "additions": 1093, + "deletions": 77 + } + } + }, + { + "seq": 0, + "checkpoint": { + "timestamp": "2026-07-23T19:10:38.041601624Z", + "current_node": "simplify_fable", + "completed_nodes": [ + "start", + "toolchain", + "preflight_compile", + "preflight_lint", + "implement", + "simplify_fable" + ], + "node_retries": {}, + "context_values": { + "internal.fidelity": "compact", + "internal.thread_id": "implement", + "last_stage": "simplify_fable", "graph.goal": "# Per-Branch Fidelity for Parallel Branches — Implementation Plan\n\n## Context\n\nParallel branch nodes are dispatched via `dispatch_handler`, bypassing `FidelityLifecycle::before_node` (`lib/crates/fabro-workflow/src/lifecycle/fidelity.rs:76-180`) — the only place fidelity is resolved and preambles are built. Every branch therefore inherits the stale `current.preamble` copied at `context.fork()` (`handler/parallel.rs:226`), and `fidelity`/`thread_id` on branch nodes or `fork -> branch` edges are silently ignored. Confirmed live on the testing server (probes `01KY7KRA7E…`/`01KY7KRAAW…`, 2026-07-23): a `truncate` branch received the identical compact preamble as its default sibling; fork-level fidelity works and is the current workaround. Design reviewed via the Quarry doc \"Fix: Per-Branch Fidelity for Parallel Branches\".\n\nWhy not run the lifecycle per branch: it is a single-token state machine (one-slot `incoming_edge_data` baton, singleton context keys written to shared run state); concurrent invocation would corrupt run state. And `build_preamble` (`handler/llm/preamble.rs:24`, public and pure) needs `state.completed_nodes`/`state.node_outcomes`, which only the lifecycle sees. So: **pre-render per-branch preambles in the lifecycle, hand off to the handler via one context key.**\n\n## Semantics (final, after design pressure-test)\n\n- **Explicit-only resolution.** A branch's fidelity comes from the `fork -> branch` edge attr, else the branch node attr, else **no entry** — the branch inherits the fork's preamble via `fork()` exactly as today. The fork's own resolved fidelity is never re-applied per branch; this keeps the default path byte-identical even when the fork resolves `Full` (where re-derivation would have wrongly degraded every branch).\n- **`full` degrades to `summary:high`** (`Fidelity::degraded()`, `fabro-graphviz/src/fidelity.rs:35-40`) — applied only to *explicitly set* branch fidelity, with a log line (per `docs/internal/logging-strategy.md` — read before writing it).\n- **Equality skip**: if the branch's post-degradation fidelity equals the fork's post-degradation fidelity, store no entry (avoid redundant renders).\n- **`thread_id` stays inert in branches** (concurrent branches must never share an LLM session).\n- **`CURRENT_NODE` in branch contexts stays inherited (fork id).** The pressure-test showed changing it would re-attribute every branch-internal event's stage scope (`context.rs:185-190` → `StageScope::for_handler` used by all handlers) with a visit mismatch against `for_parallel_branch` scoping. The Quarry doc's \"bookkeeping keys describe the branch\" line is consciously deferred to a separate change with proper visit accounting.\n- **`simulate()` untouched.** No simulated handler reads preambles; partial mirroring would risk nested-parallel stash misreads. All-or-nothing → nothing.\n- **Stash shape**: `Value::Array`, length = branch count, `Null` = inherit, else `{\"fidelity\": \"...\", \"preamble\": \"...\"}`. Array length ≠ edge count → treat as absent (legacy). Keyed by edge index; `graph.outgoing_edges` is an ordered Vec filter (`fabro-types/src/graph.rs:393-395`) and lifecycle + handler share the same `Arc`, so indices align deterministically (including two edges to the same target).\n\n## Implementation steps (ordered; tree compiles at each step)\n\n1. **`lib/crates/fabro-workflow/src/context.rs`** — add `pub const INTERNAL_PARALLEL_BRANCH_PREAMBLES: &str = \"internal.parallel_branch_preambles\";` to `keys`. The `internal.` prefix already excludes it from preamble rendering (`preamble.rs:99-109`) and child→parent propagation (`context.rs:80-85`).\n\n2. **`lib/crates/fabro-workflow/src/artifact.rs`** — strip the new key in `durable_context_snapshot` (`:81`) and `normalize_checkpoint_for_resume` (`:101`), beside `CURRENT_PREAMBLE`. Without this, every post-parallel checkpoint and `CheckpointCompleted` event payload carries the full per-branch preamble map (a `summary:high` preamble embeds up to 50 lines of every command output — multiplied per branch).\n\n3. **`lib/crates/fabro-workflow/src/lifecycle/fidelity.rs`** — in `before_node`:\n - Set the stash key to `Null` on `state.context` **first**, before the two fallible `resolve_*` calls, so the always-overwritten invariant holds on every early-return path.\n - After the existing preamble build, when `gv_node.handler_type() == Some(\"parallel\")`: iterate `self.graph.outgoing_edges(node.id())` in order; per edge resolve explicit fidelity (edge attr → target-node attr → none); apply `degraded()` to explicit values (log when it was `full`); push `Null` for inherit/equal-to-fork, else render `build_preamble(final_fidelity, …)` reusing the already-resolved snapshot (blobs resolved once at `:113-128`) and push the entry. Set the array on the stash key.\n - Extract the per-branch resolution as a pure helper beside `resolve_fidelity` (`:206`, same module — no visibility change) for unit testing.\n\n4. **`lib/crates/fabro-workflow/src/handler/parallel.rs`** — in `execute()`'s branch-setup loop (insert after `:238`, where `INTERNAL_PARALLEL_BRANCH_ID` is set):\n - Read the stash from the parent context once before the loop; `None`, `Some(Null)`, or length-mismatch all mean strict legacy behavior (note: `Context::get` returns `Some(Null)` for a Nulled key — both must be treated as absent).\n - Per branch with an entry: `branch_context.set(CURRENT_PREAMBLE, preamble)` and `branch_context.set(INTERNAL_FIDELITY, fidelity)`. Downstream needs nothing: `agent.rs:244`/`prompt.rs:63` read `context.preamble()`.\n - In **every** branch fork, set the stash key to `Null` — load-bearing, not hygiene: a nested parallel branch target reads its fork's stash, and without the Null it would misinterpret the outer node's array as its own.\n - After the loop, set the stash key to `Null` on the handler's own context — the write-back diff (`node_handler.rs:99-105`) clears `state.context` so the post-parallel checkpoint carries Null even before the artifact strip.\n\n5. **`lib/crates/fabro-validate/src/rules/parallel_branch_inert_attribute.rs`** — drop `\"fidelity\"` from `BRANCH_IGNORED_ATTRS` and its `fix_message` arm; add a narrow diagnostic in its place: `fidelity=\"full\"` on a fork→branch edge or branch-only node warns \"parallel branches run at most at summary:high; full is degraded at runtime because branches cannot share a session\". Other fidelity values now lint clean. Update doc comment (the snapshot rationale now applies to `thread_id` only) and tests.\n\n6. **`lib/crates/fabro-validate/src/rules/thread_id_requires_fidelity_full.rs`** — skip fork→branch edges and branch-only nodes (factor the branch-only detection from rule 5 into a shared helper). Today it tells branch nodes with `thread_id` to *add* `fidelity=\"full\"` — advice that, post-change, would actively alter runtime behavior while the other rule says \"remove thread_id\". Defer to the inert-attribute rule's guidance on branches.\n\n7. **Docs** — `docs/public/execution/context.mdx` (fidelity precedence: branch edge → branch node → inherit fork; per-branch preamble rendering; `thread_id` inert in branches; `full` degradation), `docs/public/workflows/stages-and-nodes.mdx` (parallel fan-out section + fidelity attribute notes), `docs/public/reference/dot-language.mdx` (edge/node attr rows). Optional changelog entry via the changelog conventions.\n\n## Tests\n\nPer `docs/internal/testing-strategy.md`, preamble content is implementation-facing → `fabro-workflow`, not CLI layers.\n\n- **Pure unit tests** (`lifecycle/fidelity.rs` tests, beside `resolve_fidelity`'s at `:271-321`): explicit edge > node precedence; no-attr → inherit (no entry); explicit `full` → `summary:high` entry; branch fidelity equal to fork's (post-degradation) → no entry; fork resolved `Full` + no branch attrs → no entries at all.\n- **Lifecycle-level**: two consecutive `before_node` calls on the same parallel node rebuild (not merge) the stash; non-parallel node overwrites stash to Null; resume-degrade flag interaction (fork degrades, fallback branches still get no entry).\n- **`artifact.rs` tests** (`:519+` pattern): both snapshot functions strip the stash key.\n- **Parallel handler unit tests** (`handler/parallel.rs` tests module, `EngineServices::test_default()` + recording handler mirroring `PreambleEchoHandler`, `manager_loop.rs:973-1043`): entry applies `CURRENT_PREAMBLE`/`INTERNAL_FIDELITY` to the right branch by index; stash Null in every branch fork; `Some(Null)`/absent/length-mismatch → legacy; duplicate-target edges get distinct entries at indices 0/1 (no-git test — a pre-existing worktree-name collision exists for that topology, don't let it pollute the assertion); existing tests stay unmodified as the legacy guard.\n- **Engine-level regression** in `lib/crates/fabro-workflow/tests/it/integration.rs` beside the `fidelity_prompt_*` tests (`:9245-9479`), reusing `FidelityCapturingHandler` (`:4917-4974`) and the `end_to_end_parallel_fan_out_fan_in` scaffold (`:2441-2480`) via `WorkflowRunner::run_with_state`:\n - **Probe A analog** (`parallel_branches_get_per_branch_preambles_by_fidelity`): seed sets a context marker → fork → `branch_a` (`fidelity=\"truncate\"`) + `branch_b` (default) → fan-in. Assert branch_a's preamble is goal-only (no marker) while branch_b's contains the marker.\n - **Probe B analog**: `fidelity=\"truncate\"` on the fork only → both branches goal-only (compat guarantee, unchanged behavior).\n - Edge-attr-beats-node-attr variant.\n- **Lint tests**: no warning for non-full branch fidelity; warning for branch `fidelity=\"full\"`; `thread_id_requires_fidelity_full` silent on branch-only nodes, still firing elsewhere.\n\n## Verification\n\n- `cargo nextest run -p fabro-workflow -p fabro-validate`, then `ulimit -n 4096 && cargo nextest run --workspace` (do not export `FORCE_COLOR`).\n- `cargo +nightly-2026-04-14 fmt --check --all`; `cargo +nightly-2026-04-14 clippy --workspace --all-targets -- -D warnings`.\n- Live confirmation on the testing server: re-run the two probe workflows (session scratchpad `probes/isolation-a`, `probes/isolation-b`) against a locally built binary — probe A's `stage.prompt` events must now show differentiated branch preambles; probe B byte-identical to before.\n\n## Compatibility\n\n| Situation | Impact |\n|---|---|\n| No fidelity attrs near the parallel node | None — byte-identical (inherit path, no re-render) |\n| Fidelity on the fork node / its incoming edge | None — fork snapshot semantics unchanged |\n| Previously-dead attrs on branch nodes / fork→branch edges | Start working (the fix) |\n| `full` on a branch | Degrades to `summary:high` + log + lint warning |\n| `thread_id` on a branch | Still inert; lint still warns; the contradictory companion lint goes quiet on branches |\n\n## Decisions (user-confirmed 2026-07-23)\n\n1. **`CURRENT_NODE` in branch contexts stays inherited** — the branch-scoped bookkeeping change is deferred to a dedicated event-attribution change.\n2. **The narrow `fidelity=\"full\"` branch lint is in scope** (step 5 stands as written).\n3. **No changelog entry in this PR** — changelog handled in the usual batch.\n", "failure_class": "", "outcome": "succeeded", - "thread.preflight_compile.current_node": "preflight_lint", "thread.start.current_node": "toolchain", - "thread.preflight_lint.current_node": "implement", + "thread.implement.current_node": "simplify_fable", "graph.rankdir": "LR", "internal.node_visit_count": 1, "internal.work_dir": "/home/daytona/workspace/fabro", "internal.retry_count.start": 0, - "failure_signature": "", - "command.output": "blob://sha256/12ae32cb1ec02d01eda3581b127c1fee3b0dc53572ed6baf239721a03d82e126", "internal.retry_count.preflight_compile": 0, - "last_response": "Implemented the complete per-branch fidelity plan.\n\n### Highlights\n- Added ordered per-branch preamble stash generation and consumption.\n- Implemented edge → branch node → fork inheritance precede", "response.implement": "Implemented the complete per-branch fidelity plan.\n\n### Highlights\n- Added ordered per-branch preamble stash generation and consumption.\n- Implemented edge → branch node → fork inheritance precedence.\n- Explicit branch `full` degrades to `summary:high` with structured logging.\n- Preserved byte-identical fork inheritance for unspecified/equal fidelities.\n- Added strict legacy fallback for missing, null, malformed, non-array, and mismatched stashes.\n- Cleared branch stashes for nested-parallel safety.\n- Removed stash data from checkpoints, durable snapshots, and persisted event contexts.\n- Kept branch `thread_id` inert, `CURRENT_NODE` inherited, and `simulate()` unchanged.\n- Updated validation rules and all three requested documentation pages.\n- Added unit, lifecycle, handler, integration, persistence, and lint regression coverage.\n- No changelog added.\n\n### Verification\n- `cargo nextest run --workspace`: **7,063 passed, 193 skipped**\n- `cargo +nightly-2026-04-14 fmt --check --all`: passed\n- `cargo +nightly-2026-04-14 clippy --workspace --all-targets -- -D warnings`: passed\n- `git diff --check`: passed\n\nLive testing-server probes were not run because the probe definitions and testing-server access were not available in this checkout/session.", - "current_node": "implement", + "internal.retry_count.implement": 0, + "last_response": "Everything is green. Summary of the simplify stage:\n\n## Review findings applied\n\n**Wire-format root cause (reuse #1, quality #1/#2, efficiency #2/#3):**\n- Added `Serialize`/`Deserialize` to `fabro_gra", "thread.toolchain.current_node": "preflight_compile", "internal.retry_count.preflight_lint": 0, - "internal.retry_count.implement": 0 + "internal.retry_count.toolchain": 0, + "internal.run_id": "01KY7Y01REECZ24XXTMBZ3PPV9", + "thread.preflight_compile.current_node": "preflight_lint", + "response.simplify_fable": "Everything is green. Summary of the simplify stage:\n\n## Review findings applied\n\n**Wire-format root cause (reuse #1, quality #1/#2, efficiency #2/#3):**\n- Added `Serialize`/`Deserialize` to `fabro_graphviz::Fidelity` (serde renames aligned with strum per CLAUDE.md, plus `Hash` and a serde↔strum parity test; `serde_json` added as dev-dependency).\n- Replaced the hand-rolled JSON producer/parser pair with one shared `ParallelBranchPreamble` struct in `context.rs` (`deny_unknown_fields` replaces the brittle `entry.len() == 2` check). Producer uses `serde_json::to_value`, consumer uses `from_value` — a field rename can no longer silently split the contract. Documented the index-correlation invariant and the `Option>` semantics (quality #5/#8).\n\n**Duplication (reuse #2/#3, quality #6/#7):**\n- Extracted `explicit_fidelity()` — the edge→node precedence core is now shared between `resolve_fidelity` and `resolve_parallel_branch_fidelity`.\n- Added `keys::TRANSIENT_CONTEXT_KEYS` + `artifact::strip_transient_keys()`; both artifact strip sites use it. `stage_context_values` keeps its intentionally different behavior (retains `CURRENT_PREAMBLE`) with a WHY comment.\n- Unified the two copy-pasted full-fidelity diagnostic blocks into `full_fidelity_diagnostic()`; `full_fidelity_message()` → `const`; dead `fix_message` match → `const THREAD_ID_FIX`; capitalized the message for consistency; reworded the \"now honors\" changelog-style doc comment.\n\n**Structure & style (quality #3/#4/#9/#10/#14):**\n- Extracted the 38-line parallel stash block into `build_parallel_branch_preambles()`, restoring `before_node`'s numbered-step narrative (steps renumbered 5–9).\n- `ParallelBranchFidelityResolution`: dropped accessor ceremony and unused derives; plain documented fields.\n- Fixed the warn! to log `effective_fidelity` via `degraded()` instead of dressed-up constants; removed the redundant `fidelity` field.\n- `parallel.rs` uses `keys::`-scoped types; test helper `fidelity_attr` → `str_attr`.\n\n**Efficiency (#1, #4):**\n- Moved the parent stash clear to *before* the fork loop — branch forks no longer copy the full preamble array B times just to null it, and the per-branch null-set became unnecessary (forks inherit Null; nested-parallel safety preserved, verified by the existing `stash == Null` test assertions).\n- Memoized per-fidelity preamble renders — duplicate branch fidelities render once.\n\n**Validate helper (reuse #4, quality #13, efficiency #6):**\n- `ParallelBranches` now uses `Graph::incoming_edges`; `is_branch_only_node` is allocation-free; `branch_only_parents` replaced the dense peekable/`then`/`collect::>` chain with straightforward code. Added the cross-rule suppression WHY comment in `thread_id_requires_fidelity_full`.\n\n**Skipped (noted, not addressed):** test-fixture consolidation into `rules/test_support` and a shared in-memory-store test helper (pre-existing pattern across ~15 sites, out of scope); `\"parallel\"` string literal (codebase-wide convention); `make_run_options` parameterization (follows existing file precedent).\n\n## Verification\n- `cargo nextest run -p fabro-graphviz -p fabro-validate -p fabro-workflow` — all pass (302 + 1218)\n- `cargo nextest run --workspace` — **7064 passed, 0 failed**\n- `cargo +nightly-2026-04-14 fmt --check --all` — clean\n- `cargo +nightly-2026-04-14 clippy --workspace --all-targets -- -D warnings` — clean\n\nNo blockers.", + "internal.retry_count.simplify_fable": 0, + "thread.preflight_lint.current_node": "implement", + "command.output": "blob://sha256/12ae32cb1ec02d01eda3581b127c1fee3b0dc53572ed6baf239721a03d82e126", + "failure_signature": "", + "current_node": "simplify_fable" }, "node_outcomes": { "preflight_lint": { @@ -891,16 +1051,64 @@ "active_time_ms": 2816360 } }, + "simplify_fable": { + "status": "succeeded", + "context_updates": { + "last_response": "Everything is green. Summary of the simplify stage:\n\n## Review findings applied\n\n**Wire-format root cause (reuse #1, quality #1/#2, efficiency #2/#3):**\n- Added `Serialize`/`Deserialize` to `fabro_gra", + "response.simplify_fable": "Everything is green. Summary of the simplify stage:\n\n## Review findings applied\n\n**Wire-format root cause (reuse #1, quality #1/#2, efficiency #2/#3):**\n- Added `Serialize`/`Deserialize` to `fabro_graphviz::Fidelity` (serde renames aligned with strum per CLAUDE.md, plus `Hash` and a serde↔strum parity test; `serde_json` added as dev-dependency).\n- Replaced the hand-rolled JSON producer/parser pair with one shared `ParallelBranchPreamble` struct in `context.rs` (`deny_unknown_fields` replaces the brittle `entry.len() == 2` check). Producer uses `serde_json::to_value`, consumer uses `from_value` — a field rename can no longer silently split the contract. Documented the index-correlation invariant and the `Option>` semantics (quality #5/#8).\n\n**Duplication (reuse #2/#3, quality #6/#7):**\n- Extracted `explicit_fidelity()` — the edge→node precedence core is now shared between `resolve_fidelity` and `resolve_parallel_branch_fidelity`.\n- Added `keys::TRANSIENT_CONTEXT_KEYS` + `artifact::strip_transient_keys()`; both artifact strip sites use it. `stage_context_values` keeps its intentionally different behavior (retains `CURRENT_PREAMBLE`) with a WHY comment.\n- Unified the two copy-pasted full-fidelity diagnostic blocks into `full_fidelity_diagnostic()`; `full_fidelity_message()` → `const`; dead `fix_message` match → `const THREAD_ID_FIX`; capitalized the message for consistency; reworded the \"now honors\" changelog-style doc comment.\n\n**Structure & style (quality #3/#4/#9/#10/#14):**\n- Extracted the 38-line parallel stash block into `build_parallel_branch_preambles()`, restoring `before_node`'s numbered-step narrative (steps renumbered 5–9).\n- `ParallelBranchFidelityResolution`: dropped accessor ceremony and unused derives; plain documented fields.\n- Fixed the warn! to log `effective_fidelity` via `degraded()` instead of dressed-up constants; removed the redundant `fidelity` field.\n- `parallel.rs` uses `keys::`-scoped types; test helper `fidelity_attr` → `str_attr`.\n\n**Efficiency (#1, #4):**\n- Moved the parent stash clear to *before* the fork loop — branch forks no longer copy the full preamble array B times just to null it, and the per-branch null-set became unnecessary (forks inherit Null; nested-parallel safety preserved, verified by the existing `stash == Null` test assertions).\n- Memoized per-fidelity preamble renders — duplicate branch fidelities render once.\n\n**Validate helper (reuse #4, quality #13, efficiency #6):**\n- `ParallelBranches` now uses `Graph::incoming_edges`; `is_branch_only_node` is allocation-free; `branch_only_parents` replaced the dense peekable/`then`/`collect::>` chain with straightforward code. Added the cross-rule suppression WHY comment in `thread_id_requires_fidelity_full`.\n\n**Skipped (noted, not addressed):** test-fixture consolidation into `rules/test_support` and a shared in-memory-store test helper (pre-existing pattern across ~15 sites, out of scope); `\"parallel\"` string literal (codebase-wide convention); `make_run_options` parameterization (follows existing file precedent).\n\n## Verification\n- `cargo nextest run -p fabro-graphviz -p fabro-validate -p fabro-workflow` — all pass (302 + 1218)\n- `cargo nextest run --workspace` — **7064 passed, 0 failed**\n- `cargo +nightly-2026-04-14 fmt --check --all` — clean\n- `cargo +nightly-2026-04-14 clippy --workspace --all-targets -- -D warnings` — clean\n\nNo blockers.", + "last_stage": "simplify_fable" + }, + "notes": "Stage completed: simplify_fable", + "usage": { + "input": { + "usage": { + "model": { + "provider": "openrouter", + "model_id": "anthropic/claude-fable-5" + }, + "tokens": { + "input_tokens": 8653765, + "output_tokens": 127619, + "reasoning_tokens": 33321, + "cache_read_tokens": 0, + "cache_write_tokens": 0 + } + }, + "facts": { + "algorithm": "openai" + } + } + }, + "files_touched": [ + "/home/daytona/workspace/fabro/lib/crates/fabro-graphviz/Cargo.toml", + "/home/daytona/workspace/fabro/lib/crates/fabro-graphviz/src/fidelity.rs", + "/home/daytona/workspace/fabro/lib/crates/fabro-validate/src/rules/parallel_branch.rs", + "/home/daytona/workspace/fabro/lib/crates/fabro-validate/src/rules/parallel_branch_inert_attribute.rs", + "/home/daytona/workspace/fabro/lib/crates/fabro-validate/src/rules/thread_id_requires_fidelity_full.rs", + "/home/daytona/workspace/fabro/lib/crates/fabro-workflow/src/artifact.rs", + "/home/daytona/workspace/fabro/lib/crates/fabro-workflow/src/context.rs", + "/home/daytona/workspace/fabro/lib/crates/fabro-workflow/src/handler/parallel.rs", + "/home/daytona/workspace/fabro/lib/crates/fabro-workflow/src/lifecycle/event.rs", + "/home/daytona/workspace/fabro/lib/crates/fabro-workflow/src/lifecycle/fidelity.rs" + ], + "timing": { + "wall_time_ms": 0, + "inference_time_ms": 2153413, + "tool_time_ms": 3192086, + "active_time_ms": 5345499 + } + }, "start": { "status": "succeeded", "usage": null } }, - "next_node_id": "simplify_fable", + "next_node_id": "simplify_sol", "node_visits": { "preflight_lint": 1, "start": 1, "preflight_compile": 1, + "simplify_fable": 1, "implement": 1, "toolchain": 1 } @@ -1016,11 +1224,281 @@ }, "state": "succeeded" }, + "simplify_fable@1": { + "first_event_seq": 1351, + "prompt": null, + "response": null, + "completion": null, + "provider_used": { + "mode": "agent", + "provider": "openrouter", + "model": "anthropic/claude-fable-5", + "reasoning_effort": "xhigh" + }, + "diff": null, + "script_invocation": null, + "script_timing": null, + "parallel_results": null, + "output": null, + "started_at": "2026-07-23T17:41:31.847060646Z", + "handler": "agent", + "usage": { + "input_tokens": 8653765, + "output_tokens": 127619, + "total_tokens": 8814705, + "reasoning_tokens": 33321, + "cache_read_tokens": 0, + "cache_write_tokens": 0 + }, + "model": { + "provider": "openrouter", + "model_id": "anthropic/claude-fable-5" + }, + "subagents": [ + { + "agent_id": "d24c4e8d", + "depth": 1, + "task": "You are a CODE REUSE reviewer for the Rust repo at /home/daytona/workspace/fabro. A feature \"Per-Branch Fidelity for Parallel Branches\" was just implemented. The full diff is saved at /tmp/implement.diff (also viewable via `git diff 1879fe090..1a2bd7966`). Read the diff first.\n\nChanged files:\n- lib/crates/fabro-workflow/src/lifecycle/fidelity.rs (per-branch preamble stash rendering in before_node, resolve_parallel_branch_fidelity helper, tests)\n- lib/crates/fabro-workflow/src/handler/parallel.rs (parse_branch_preambles, branch context injection, tests)\n- lib/crates/fabro-workflow/src/lifecycle/event.rs (stage_context_values helper)\n- lib/crates/fabro-workflow/src/artifact.rs (stripping new context key)\n- lib/crates/fabro-workflow/src/context.rs (new key constant)\n- lib/crates/fabro-validate/src/rules/parallel_branch.rs (NEW shared helper ParallelBranches)\n- lib/crates/fabro-validate/src/rules/parallel_branch_inert_attribute.rs\n- lib/crates/fabro-validate/src/rules/thread_id_requires_fidelity_full.rs\n- lib/crates/fabro-workflow/tests/it/integration.rs\n- docs (mdx files)\n\nYour job — for each change:\n1. Search the codebase for existing utilities/helpers that could replace newly written code. Look in fabro-workflow, fabro-graphviz, fabro-types, fabro-validate, fabro-util. E.g.: is there an existing \"is parallel node\" / handler_type helper, an existing fidelity parse helper, an existing serde struct that could replace the hand-rolled JSON parsing in parse_branch_preambles, existing test fixtures/helpers (e.g. test graph builders, test lifecycles, test stores) that the new tests duplicate?\n2. Flag any new function duplicating existing functionality — name the existing function to use instead, with file:line.\n3. Flag inline logic that could use an existing utility (hand-rolled string manipulation, JSON handling, graph traversal like outgoing_edges, ad-hoc filters).\n\nPay special attention to:\n- resolve_parallel_branch_fidelity vs the existing resolve_fidelity in the same file\n- parse_branch_preambles hand-rolled JSON vs serde derive patterns used elsewhere in the crate\n- ParallelBranches in fabro-validate vs any existing graph query helpers\n- test fixtures in the new tests vs existing test_support / fixture helpers (e.g. `EngineServices::test_default`, existing lifecycle test scaffolding, integration.rs helpers)\n- stage_context_values in event.rs vs durable_context_snapshot in artifact.rs — is there duplication of \"strip internal keys from snapshot\" logic?\n\nDo NOT make edits. Output a numbered list of findings, each with: file:line, what the issue is, what existing code should be used instead, and severity (high/medium/low). If no issues, say so. Be precise; verify claims by reading actual code.", + "status": { + "kind": "completed", + "success": true, + "turns_used": 105 + } + }, + { + "agent_id": "6bcd05e4", + "depth": 1, + "task": "You are a CODE QUALITY reviewer for the Rust repo at /home/daytona/workspace/fabro. A feature \"Per-Branch Fidelity for Parallel Branches\" was just implemented. The full diff is saved at /tmp/implement.diff (also viewable via `git diff 1879fe090..1a2bd7966`). Read the diff first, then read the surrounding code in changed files for context.\n\nChanged files:\n- lib/crates/fabro-workflow/src/lifecycle/fidelity.rs\n- lib/crates/fabro-workflow/src/handler/parallel.rs\n- lib/crates/fabro-workflow/src/lifecycle/event.rs\n- lib/crates/fabro-workflow/src/artifact.rs\n- lib/crates/fabro-workflow/src/context.rs\n- lib/crates/fabro-validate/src/rules/parallel_branch.rs (new)\n- lib/crates/fabro-validate/src/rules/parallel_branch_inert_attribute.rs\n- lib/crates/fabro-validate/src/rules/thread_id_requires_fidelity_full.rs\n- lib/crates/fabro-workflow/tests/it/integration.rs\n- docs mdx files\n\nReview for hacky patterns:\n1. Redundant state: state duplicating existing state, cached values derivable elsewhere\n2. Parameter sprawl: new params where restructuring would be better\n3. Copy-paste with slight variation: near-duplicate blocks needing shared abstraction (e.g. the two diagnostic-push blocks in parallel_branch_inert_attribute.rs for edges vs nodes; the full-fidelity warn blocks; repeated attr-insert boilerplate in tests)\n4. Leaky abstractions: exposing internals that should be encapsulated. Look hard at the ParallelBranchFidelityResolution struct in fidelity.rs — it has `requested()` and `entry_fidelity()` accessor methods on a private struct with public-looking API ceremony; is this over-engineered for a module-private helper? Could it be a simpler return type?\n5. Stringly-typed code: raw strings where constants/enums exist. Check \"parallel\" handler_type literal, \"fidelity\"/\"thread_id\" attr strings, the {\"fidelity\",\"preamble\"} JSON object shape in parallel.rs vs a serde struct, `keys::Fidelity` usage.\n6. Unnecessary comments: comments explaining WHAT (delete) vs non-obvious WHY (keep)\n7. Naming/consistency with the codebase style (check CLAUDE.md conventions: strum for enum conversions, import style, no glob imports in prod code)\n\nAlso check:\n- In fidelity.rs before_node, the parallel stash block: is the structure clear, does the resolution struct pull its weight, is the tracing::warn! per docs/internal/logging-strategy.md conventions (read that doc)?\n- In parallel.rs parse_branch_preambles: the `entry.len() == 2` check — brittle? The Option> return — clear enough?\n- Test quality: do new tests follow existing patterns in the files they were added to?\n\nDo NOT make edits. Output a numbered list of findings with file:line, issue, suggested fix, and severity (high/medium/low). If clean, say so. Verify claims against actual code.", + "status": { + "kind": "completed", + "success": true, + "turns_used": 59 + } + }, + { + "agent_id": "46c8d151", + "depth": 1, + "task": "You are an EFFICIENCY reviewer for the Rust repo at /home/daytona/workspace/fabro. A feature \"Per-Branch Fidelity for Parallel Branches\" was just implemented. The full diff is saved at /tmp/implement.diff (also viewable via `git diff 1879fe090..1a2bd7966`). Read the diff first, then read the surrounding code in the changed files.\n\nChanged files:\n- lib/crates/fabro-workflow/src/lifecycle/fidelity.rs (before_node now sets a Null context key on EVERY node visit, and for parallel nodes renders per-branch preambles)\n- lib/crates/fabro-workflow/src/handler/parallel.rs (parse_branch_preambles + branch context injection)\n- lib/crates/fabro-workflow/src/lifecycle/event.rs (stage_context_values takes a full snapshot per stage-completion event)\n- lib/crates/fabro-workflow/src/artifact.rs\n- lib/crates/fabro-workflow/src/context.rs\n- lib/crates/fabro-validate/src/rules/parallel_branch.rs (new ParallelBranches helper — check its per-call complexity: branch_only_parents scans all edges per node; is_branch_only_node called per node in thread_id_requires_fidelity_full → O(nodes × edges)?)\n- lib/crates/fabro-validate/src/rules/parallel_branch_inert_attribute.rs\n- lib/crates/fabro-validate/src/rules/thread_id_requires_fidelity_full.rs\n- lib/crates/fabro-workflow/tests/it/integration.rs\n\nReview for:\n1. Unnecessary work: redundant computations, repeated snapshots/clones, rebuilding data per iteration. Check: does before_node's unconditional `state.context.set(..., Null)` cause churn (e.g. context write-back diffs, event emission, checkpoint dirty-marking) on every node even in workflows with no parallel nodes? Read context.rs set() and node_handler.rs write-back logic to verify whether setting Null over an already-Null/absent key is a no-op or triggers downstream work.\n2. Missed concurrency: none expected, but check.\n3. Hot-path bloat: stage_context_values clones the full context snapshot per event — was that already the case before? (compare to previous code in the diff; only flag regressions).\n4. Recurring no-op updates: the Null-set in every branch fork and after the loop in parallel.rs — necessary per design (load-bearing), but check whether context.set of Null on a key that's absent creates spurious diff entries in the parent write-back (node_handler.rs:99-105) and whether that pollutes checkpoints/events with \"internal.parallel_branch_preambles\": null entries. Read artifact.rs strip logic and event.rs stage_context_values to see if Null entries leak anywhere (e.g. progress.jsonl, stage completed events, checkpoints).\n5. Memory: preamble strings duplicated across stash + branch contexts — any unnecessary clones that could be moves? Check parse_branch_preambles (entry.get + to_string vs remove/take), and the json! macro usages cloning strings.\n6. Overly broad operations: snapshot() of whole context when only one key needed, scanning all graph edges repeatedly.\n7. In fidelity.rs: build_preamble called per branch — fine — but check resolved_context / resolved_outcomes are NOT re-resolved per branch (blobs resolved once). Verify by reading the code.\n\nDo NOT make edits. Output a numbered list of findings with file:line, issue, why it matters, suggested fix, severity (high/medium/low). Distinguish real regressions from pre-existing behavior. If clean, say so.", + "status": { + "kind": "completed", + "success": true, + "turns_used": 46 + } + } + ], + "skills": { + "available": [ + { + "name": "rust-style-guide", + "description": "Apply this Rust style guide when writing, reviewing, refactoring, or configuring Rust code for this project. Covers Rust 2024/MSRV, library vs application conventions, public API design, errors, panics, ownership and cloning, async/Tokio/concurrency, tracing, rustfmt/Clippy, testing with nextest, and unsafe/macro policy. Also use when setting up new Rust projects, investigating Rust performance, verifying library releases, or reviewing Rust code changes." + } + ], + "activated": [ + { + "name": "rust-style-guide", + "source": "tool" + } + ] + }, + "permission_level": "full", + "agent_tools": [ + { + "name": "close_agent", + "description": "Close a running subagent that is no longer needed.", + "source": { + "kind": "native" + }, + "category": "subagent", + "invoked": false + }, + { + "name": "edit_file", + "description": "Edit a file by replacing an exact string. The old_string must be an exact match and unique unless replace_all is true; include surrounding context when needed. Read the file first and preserve existing indentation.", + "source": { + "kind": "native" + }, + "category": "write", + "invoked": true + }, + { + "name": "glob", + "description": "Find files by file names using a glob pattern. Use path to choose the search root. Prefer this over shell find or ls when locating repository files.", + "source": { + "kind": "native" + }, + "category": "read", + "invoked": true + }, + { + "name": "grep", + "description": "Search file contents with a regex pattern. Use path to choose the search root, glob_filter to limit matching files, case_insensitive for case folding, and max_results to cap output.", + "source": { + "kind": "native" + }, + "category": "read", + "invoked": true + }, + { + "name": "read_file", + "description": "Read files before editing them. Returns line-numbered text and supports offset/limit for large files. Use this instead of shell cat, head, tail, or sed when inspecting repository files.", + "source": { + "kind": "native" + }, + "category": "read", + "invoked": true + }, + { + "name": "request_user_input", + "description": "Ask the human one or more questions and wait for their answers before continuing this stage.", + "source": { + "kind": "native" + }, + "category": "other", + "invoked": false + }, + { + "name": "send_input", + "description": "Send a follow-up message to a running subagent when new information or corrected instructions are needed.", + "source": { + "kind": "native" + }, + "category": "subagent", + "invoked": false + }, + { + "name": "shell", + "description": "Execute shell commands for terminal operations, package managers, tests and builds. Use dedicated tools for file reads, file edits, filename searches, and content searches. Provide timeout_ms for long-running commands.", + "source": { + "kind": "native" + }, + "category": "shell", + "invoked": true + }, + { + "name": "spawn_agent", + "description": "Spawn a subagent for independent work or context isolation. Use it for tasks that can proceed separately, and avoid duplicating the same work in the parent session.", + "source": { + "kind": "native" + }, + "category": "subagent", + "invoked": true + }, + { + "name": "update_plan", + "description": "Update the multi-step plan for the current task. Submit the entire plan; existing steps are reconciled by exact step text.", + "source": { + "kind": "native" + }, + "category": "other", + "invoked": false + }, + { + "name": "use_skill", + "description": "Load a skill's instructions by name. Call this when the user's request matches an available skill.", + "source": { + "kind": "skill" + }, + "category": "other", + "invoked": true + }, + { + "name": "wait", + "description": "Wait for a subagent to complete, then use the result to synthesize the outcome for the user.", + "source": { + "kind": "native" + }, + "category": "subagent", + "invoked": true + }, + { + "name": "web_fetch", + "description": "Fetch content from a URL that starts with http:// or https://. Pass a prompt to extract specific information or summarize the page; omit prompt to return the page content.", + "source": { + "kind": "native" + }, + "category": "other", + "invoked": false + }, + { + "name": "web_search", + "description": "Search the web using Brave Search when current external information is needed. Returns result titles, URLs, and descriptions; use web_fetch for a specific URL.", + "source": { + "kind": "native" + }, + "category": "other", + "invoked": false + }, + { + "name": "write_file", + "description": "Create new files, or overwrite an existing file only when replacement is explicitly intended. Prefer edit_file for targeted changes to existing files because write_file overwrites the full file content.", + "source": { + "kind": "native" + }, + "category": "write", + "invoked": true + } + ], + "context_window": { + "provider": "openrouter", + "model": "anthropic/claude-fable-5", + "context_window_tokens": 200000, + "input_tokens": 148278, + "usage_percent": 74.139, + "count_method": "response_usage_scaled_breakdown", + "staleness": "live", + "generated_at": "2026-07-23T19:10:37.948719737Z", + "event_seq": 2384, + "breakdown": [ + { + "category": "system_prompt", + "tokens": 1238, + "usage_percent": 0.619 + }, + { + "category": "tools", + "tokens": 1827, + "usage_percent": 0.9135 + }, + { + "category": "skills", + "tokens": 245, + "usage_percent": 0.1225 + }, + { + "category": "memory", + "tokens": 4490, + "usage_percent": 2.245 + }, + { + "category": "conversation", + "tokens": 140470, + "usage_percent": 70.235 + }, + { + "category": "other", + "tokens": 8, + "usage_percent": 0.004 + } + ], + "warnings": [ + { + "code": "activated_skill_context_counted_as_conversation", + "message": "Activated skill instructions are counted as conversation in this version." + } + ] + }, + "state": "running" + }, "implement@1": { "first_event_seq": 51, "prompt": null, "response": null, - "completion": null, + "completion": { + "outcome": "succeeded", + "notes": "Stage completed: implement", + "failure_reason": null, + "timestamp": "2026-07-23T17:41:28.062493494Z" + }, "provider_used": { "mode": "agent", "provider": "openrouter", @@ -1034,6 +1512,12 @@ "output": null, "started_at": "2026-07-23T16:52:54.792975774Z", "handler": "agent", + "timing": { + "wall_time_ms": 2913269, + "inference_time_ms": 1625877, + "tool_time_ms": 1190483, + "active_time_ms": 2816360 + }, "usage": { "input_tokens": 394190, "output_tokens": 32119, @@ -1379,7 +1863,7 @@ } ] }, - "state": "running" + "state": "succeeded" }, "preflight_compile@1": { "first_event_seq": 31, diff --git a/stages/005-implement@1/diff.patch b/stages/005-implement@1/diff.patch new file mode 100644 index 000000000..e31d660ed --- /dev/null +++ b/stages/005-implement@1/diff.patch @@ -0,0 +1,1558 @@ +diff --git a/docs/public/execution/context.mdx b/docs/public/execution/context.mdx +index c67012b3c..a08b2d960 100644 +--- a/docs/public/execution/context.mdx ++++ b/docs/public/execution/context.mdx +@@ -139,6 +139,16 @@ Fidelity can be set at three levels. The first match wins: + + If none of these are set, fidelity defaults to `compact`. + ++### Parallel branch fidelity ++ ++The first node in each parallel branch uses this precedence: ++ ++1. `fidelity` on the fork-to-branch edge ++2. `fidelity` on the branch node ++3. Otherwise, inherit the fork's preamble unchanged ++ ++Fabro renders any branch-specific preambles before fan-out from the fork's context snapshot, then places them into the isolated branch contexts. An explicit branch-level `full` degrades to `summary:high` because concurrent branches cannot share conversation sessions. `thread_id` on a branch node or fork-to-branch edge is inert. ++ + ### Full fidelity and threads + + `full` fidelity is typically used with `thread_id` to create a shared conversation across multiple nodes. Nodes with the same `thread_id` share a single LLM session, preserving full context continuity: +diff --git a/docs/public/reference/dot-language.mdx b/docs/public/reference/dot-language.mdx +index f01fbf2ed..3e407242b 100644 +--- a/docs/public/reference/dot-language.mdx ++++ b/docs/public/reference/dot-language.mdx +@@ -201,8 +201,8 @@ Start nodes can also be identified by ID (`start` or `Start`). Exit nodes can be + | `prompt` | String | Task instructions for the LLM. Supports file references with `@path/to/file.md` | + | `reasoning_effort` | String | `low`, `medium`, or `high` (default: `high`) | + | `max_tokens` | Integer | Maximum output tokens | +-| `fidelity` | String | How much prior context is passed: `compact`, `full`, `summary:high`, `summary:medium`, `summary:low`, `truncate` | +-| `thread_id` | String | Groups nodes into a shared conversation thread | ++| `fidelity` | String | How much prior context is passed: `compact`, `full`, `summary:high`, `summary:medium`, `summary:low`, `truncate`. On a node entered directly from a parallel fork, this is overridden by the fork-to-branch edge; explicit `full` degrades to `summary:high`. | ++| `thread_id` | String | Groups nodes into a shared conversation thread. Inert when the node is entered directly from a parallel fork. | + | `model` | String | Explicit model ID (overrides stylesheet) | + | `provider` | String | Explicit provider name (overrides stylesheet). Auto-inferred from the model catalog when omitted. | + | `project_memory` | Boolean | When `true` (default), prompt nodes discover and include project docs (`AGENTS.md`, `CLAUDE.md`, etc.) as a system prompt. Set to `false` to disable. | +@@ -252,6 +252,8 @@ audit [ + | `join_policy` | String | When the merge can proceed: `wait_all` (default), `first_success` | + | `max_parallel` | Integer | Maximum concurrent branches (default: 4) | + ++For the first node in each branch, `fidelity` resolves from the fork-to-branch edge, then the branch node; without either, the fork preamble is inherited unchanged. Branch-specific preambles are rendered before fan-out from the fork's context snapshot. Concurrent branches cannot share sessions, so explicit branch `full` becomes `summary:high`, and branch-level `thread_id` is inert. ++ + ### Wait nodes + + | Attribute | Type | Description | +@@ -282,8 +284,8 @@ audit [ + | `label` | String | Display text; also used for human gate option matching | + | `condition` | String | Boolean expression for conditional routing (see below) | + | `weight` | Integer | Priority for tiebreaking (higher wins, default: 0) | +-| `fidelity` | String | Override fidelity level for this transition | +-| `thread_id` | String | Override thread ID for this transition | ++| `fidelity` | String | Override fidelity level for this transition. On a fork-to-branch edge, takes precedence over the branch node; explicit `full` degrades to `summary:high`. | ++| `thread_id` | String | Override thread ID for this transition. Inert on fork-to-branch edges. | + | `loop_restart` | Boolean | Restart the workflow from this edge's target when taken: stage history and retry counts clear and the context resets to empty (visit counts are kept). Failed outcomes may only take it for `transient_infra` failures — see [Failures](/execution/failures#loop-restart-edges) | + | `freeform` | Boolean | When `true` on a human-gate edge, accept free-text input instead of fixed choices | + +diff --git a/docs/public/workflows/stages-and-nodes.mdx b/docs/public/workflows/stages-and-nodes.mdx +index 1dd0f71ac..1b8c252c2 100644 +--- a/docs/public/workflows/stages-and-nodes.mdx ++++ b/docs/public/workflows/stages-and-nodes.mdx +@@ -170,6 +170,8 @@ fork -> quality + | `join_policy` | When the merge can proceed (see table below) | + | `max_parallel` | Maximum concurrent branches (default: 4) | + ++For each branch's first node, fidelity resolves from the fork-to-branch edge, then the branch node; otherwise it inherits the fork preamble unchanged. Fabro renders branch-specific preambles before fan-out from the fork snapshot. Branch-level `full` degrades to `summary:high` because concurrent branches cannot share sessions, and `thread_id` on a branch node or fork-to-branch edge is inert. ++ + **Join policies:** + + | Policy | Behavior | +diff --git a/lib/crates/fabro-validate/src/rules/mod.rs b/lib/crates/fabro-validate/src/rules/mod.rs +index 428004240..87075bc17 100644 +--- a/lib/crates/fabro-validate/src/rules/mod.rs ++++ b/lib/crates/fabro-validate/src/rules/mod.rs +@@ -12,6 +12,7 @@ mod inert_attribute; + mod model_support; + mod node_model_known; + mod orphan_custom_outcome; ++mod parallel_branch; + mod parallel_branch_inert_attribute; + mod prompt_on_llm_nodes; + mod random_selection_no_conditions; +diff --git a/lib/crates/fabro-validate/src/rules/parallel_branch.rs b/lib/crates/fabro-validate/src/rules/parallel_branch.rs +new file mode 100644 +index 000000000..2f1cba2e7 +--- /dev/null ++++ b/lib/crates/fabro-validate/src/rules/parallel_branch.rs +@@ -0,0 +1,56 @@ ++use std::collections::BTreeSet; ++ ++use fabro_graphviz::graph::{Edge, Graph}; ++ ++pub(super) struct ParallelBranches<'a> { ++ graph: &'a Graph, ++ fork_ids: BTreeSet<&'a str>, ++} ++ ++impl<'a> ParallelBranches<'a> { ++ pub(super) fn new(graph: &'a Graph) -> Self { ++ let fork_ids = graph ++ .nodes ++ .values() ++ .filter(|node| node.handler_type() == Some("parallel")) ++ .map(|node| node.id.as_str()) ++ .collect(); ++ Self { graph, fork_ids } ++ } ++ ++ pub(super) fn is_empty(&self) -> bool { ++ self.fork_ids.is_empty() ++ } ++ ++ pub(super) fn is_fork_edge(&self, edge: &Edge) -> bool { ++ self.fork_ids.contains(edge.from.as_str()) ++ } ++ ++ pub(super) fn branch_targets(&self) -> BTreeSet<&str> { ++ self.graph ++ .edges ++ .iter() ++ .filter(|edge| self.is_fork_edge(edge)) ++ .map(|edge| edge.to.as_str()) ++ .collect() ++ } ++ ++ pub(super) fn is_branch_only_node(&self, node_id: &str) -> bool { ++ self.branch_only_parents(node_id).is_some() ++ } ++ ++ pub(super) fn branch_only_parents(&self, node_id: &str) -> Option> { ++ let mut incoming = self ++ .graph ++ .edges ++ .iter() ++ .filter(|edge| edge.to == node_id) ++ .peekable(); ++ incoming.peek()?; ++ ++ incoming ++ .map(|edge| self.is_fork_edge(edge).then(|| edge.from.clone())) ++ .collect::>>() ++ .map(|parents| parents.into_iter().collect()) ++ } ++} +diff --git a/lib/crates/fabro-validate/src/rules/parallel_branch_inert_attribute.rs b/lib/crates/fabro-validate/src/rules/parallel_branch_inert_attribute.rs +index eb051281e..da36dbbea 100644 +--- a/lib/crates/fabro-validate/src/rules/parallel_branch_inert_attribute.rs ++++ b/lib/crates/fabro-validate/src/rules/parallel_branch_inert_attribute.rs +@@ -1,18 +1,16 @@ +-use std::collections::BTreeSet; +- + use fabro_graphviz::graph::Graph; + ++use super::parallel_branch::ParallelBranches; + use crate::{Diagnostic, LintRule, Severity}; + + pub(super) fn rule() -> Box { + Box::new(Rule) + } + +-/// Attributes that parallel branch execution does not resolve. Branch nodes +-/// are dispatched with a snapshot of the context taken when the parallel node +-/// started, so per-branch `fidelity` never changes what a branch sees, and +-/// per-branch `thread_id` never replaces the thread inherited in that snapshot. +-const BRANCH_IGNORED_ATTRS: &[&str] = &["fidelity", "thread_id"]; ++/// Attributes that parallel branch execution does not resolve. Per-branch ++/// preambles now honor fidelity, while `thread_id` remains inert because ++/// concurrent branches cannot share an LLM session. ++const BRANCH_IGNORED_ATTRS: &[&str] = &["thread_id"]; + + struct Rule; + +@@ -24,21 +22,8 @@ fn quoted_list(ids: &[String]) -> String { + .join(", ") + } + +-fn fix_message(attr: &str, parallel_ids: &[String]) -> String { ++fn fix_message(attr: &str) -> String { + match attr { +- "fidelity" => { +- if parallel_ids.len() == 1 { +- format!( +- "Set fidelity on the parallel node {} (or its incoming edge) to control what every branch sees", +- quoted_list(parallel_ids), +- ) +- } else { +- format!( +- "Set fidelity on the parallel nodes {} (or their incoming edges) to control what every branch sees", +- quoted_list(parallel_ids), +- ) +- } +- } + "thread_id" => format!( + "Remove '{attr}': parallel branches inherit the thread resolved when the parallel node started" + ), +@@ -46,30 +31,49 @@ fn fix_message(attr: &str, parallel_ids: &[String]) -> String { + } + } + ++fn full_fidelity_message() -> String { ++ "parallel branches run at most at summary:high; full is degraded at runtime because branches cannot share a session".to_string() ++} ++ ++fn full_fidelity_fix(parallel_ids: &[String]) -> String { ++ let parent = if parallel_ids.len() == 1 { ++ format!("parallel node {}", quoted_list(parallel_ids)) ++ } else { ++ format!("parallel nodes {}", quoted_list(parallel_ids)) ++ }; ++ format!( ++ "Use fidelity=\"summary:high\" or another lower mode on this branch; to reuse a full session before fan-out, set fidelity=\"full\" on {parent} or its incoming edge" ++ ) ++} ++ + impl LintRule for Rule { + fn name(&self) -> &'static str { + "parallel_branch_inert_attribute" + } + + fn apply(&self, graph: &Graph) -> Vec { +- let parallel_ids: BTreeSet<&str> = graph +- .nodes +- .values() +- .filter(|n| n.handler_type() == Some("parallel")) +- .map(|n| n.id.as_str()) +- .collect(); +- if parallel_ids.is_empty() { ++ let branches = ParallelBranches::new(graph); ++ if branches.is_empty() { + return Vec::new(); + } + + let mut diagnostics = Vec::new(); + +- // Branch edges (parallel node -> branch target) carrying an attribute +- // that branch dispatch never reads. + for edge in &graph.edges { +- if !parallel_ids.contains(edge.from.as_str()) { ++ if !branches.is_fork_edge(edge) { + continue; + } ++ if edge.fidelity() == Some("full") { ++ diagnostics.push(Diagnostic { ++ rule: self.name().to_string(), ++ severity: Severity::Warning, ++ message: full_fidelity_message(), ++ node_id: None, ++ edge: Some((edge.from.clone(), edge.to.clone())), ++ fix: Some(full_fidelity_fix(std::slice::from_ref(&edge.from))), ++ ..Diagnostic::default() ++ }); ++ } + for attr in BRANCH_IGNORED_ATTRS { + if !edge.attrs.contains_key(*attr) { + continue; +@@ -83,42 +87,32 @@ impl LintRule for Rule { + ), + node_id: None, + edge: Some((edge.from.clone(), edge.to.clone())), +- fix: Some(fix_message(attr, std::slice::from_ref(&edge.from))), ++ fix: Some(fix_message(attr)), + ..Diagnostic::default() + }); + } + } + +- // Branch target nodes carrying such an attribute — but only when every +- // incoming edge comes from a parallel node. A node that is also +- // reachable through a normal edge resolves the attribute on that path, +- // so it is not inert there. +- let branch_targets: BTreeSet<&str> = graph +- .edges +- .iter() +- .filter(|e| parallel_ids.contains(e.from.as_str())) +- .map(|e| e.to.as_str()) +- .collect(); +- for target in branch_targets { +- let only_branch_entries = graph +- .edges +- .iter() +- .filter(|e| e.to == target) +- .all(|e| parallel_ids.contains(e.from.as_str())); +- if !only_branch_entries { ++ // A node with any normal incoming path still resolves its attributes on ++ // that path, so branch-only diagnostics do not apply to it. ++ for target in branches.branch_targets() { ++ let Some(parents) = branches.branch_only_parents(target) else { + continue; +- } ++ }; + let Some(node) = graph.nodes.get(target) else { + continue; + }; +- let parents: Vec = graph +- .edges +- .iter() +- .filter(|e| e.to == target && parallel_ids.contains(e.from.as_str())) +- .map(|e| e.from.clone()) +- .collect::>() +- .into_iter() +- .collect(); ++ if node.fidelity() == Some("full") { ++ diagnostics.push(Diagnostic { ++ rule: self.name().to_string(), ++ severity: Severity::Warning, ++ message: full_fidelity_message(), ++ node_id: Some(node.id.clone()), ++ edge: None, ++ fix: Some(full_fidelity_fix(&parents)), ++ ..Diagnostic::default() ++ }); ++ } + for attr in BRANCH_IGNORED_ATTRS { + if !node.attrs.contains_key(*attr) { + continue; +@@ -133,7 +127,7 @@ impl LintRule for Rule { + ), + node_id: Some(node.id.clone()), + edge: None, +- fix: Some(fix_message(attr, &parents)), ++ fix: Some(fix_message(attr)), + ..Diagnostic::default() + }); + } +@@ -181,7 +175,7 @@ mod tests { + } + + #[test] +- fn warns_on_fidelity_on_branch_node() { ++ fn accepts_non_full_fidelity_on_branch_node() { + let mut g = parallel_graph(); + g.nodes + .get_mut("branch_a") +@@ -191,14 +185,73 @@ mod tests { + "fidelity".to_string(), + AttrValue::String("truncate".to_string()), + ); ++ ++ assert!(Rule.apply(&g).is_empty()); ++ } ++ ++ #[test] ++ fn warns_when_full_fidelity_on_branch_node_degrades() { ++ let mut g = parallel_graph(); ++ g.nodes ++ .get_mut("branch_a") ++ .expect("graph has branch_a") ++ .attrs ++ .insert( ++ "fidelity".to_string(), ++ AttrValue::String("full".to_string()), ++ ); ++ + let d = Rule.apply(&g); ++ + assert_eq!(d.len(), 1); + assert_eq!(d[0].severity, Severity::Warning); + assert_eq!(d[0].node_id.as_deref(), Some("branch_a")); +- assert!(d[0].message.contains("'fidelity'")); ++ assert!(d[0].message.contains("full")); ++ assert!(d[0].message.contains("summary:high")); + assert!(d[0].fix.as_deref().is_some_and(|f| f.contains("'fork'"))); + } + ++ #[test] ++ fn accepts_every_non_full_fidelity_on_branch_edges() { ++ for fidelity in [ ++ "truncate", ++ "compact", ++ "summary:low", ++ "summary:medium", ++ "summary:high", ++ ] { ++ let mut g = parallel_graph(); ++ g.edges[1].attrs.insert( ++ "fidelity".to_string(), ++ AttrValue::String(fidelity.to_string()), ++ ); ++ ++ assert!( ++ Rule.apply(&g).is_empty(), ++ "{fidelity} should be accepted on a branch edge" ++ ); ++ } ++ } ++ ++ #[test] ++ fn warns_when_full_fidelity_on_branch_edge_degrades() { ++ let mut g = parallel_graph(); ++ g.edges[1].attrs.insert( ++ "fidelity".to_string(), ++ AttrValue::String("full".to_string()), ++ ); ++ ++ let d = Rule.apply(&g); ++ ++ assert_eq!(d.len(), 1); ++ assert_eq!( ++ d[0].edge, ++ Some(("fork".to_string(), "branch_a".to_string())) ++ ); ++ assert!(d[0].message.contains("full")); ++ assert!(d[0].message.contains("summary:high")); ++ } ++ + #[test] + fn warns_on_thread_id_on_branch_edge() { + let mut g = parallel_graph(); +@@ -221,6 +274,31 @@ mod tests { + ); + } + ++ #[test] ++ fn warns_on_thread_id_on_branch_only_node() { ++ let mut g = parallel_graph(); ++ g.nodes ++ .get_mut("branch_a") ++ .expect("graph has branch_a") ++ .attrs ++ .insert( ++ "thread_id".to_string(), ++ AttrValue::String("impl".to_string()), ++ ); ++ ++ let d = Rule.apply(&g); ++ ++ assert_eq!(d.len(), 1); ++ assert_eq!(d[0].node_id.as_deref(), Some("branch_a")); ++ assert!(d[0].message.contains("'thread_id'")); ++ assert_eq!( ++ d[0].fix.as_deref(), ++ Some( ++ "Remove 'thread_id': parallel branches inherit the thread resolved when the parallel node started" ++ ) ++ ); ++ } ++ + #[test] + fn accepts_fidelity_on_the_parallel_node_itself() { + let mut g = parallel_graph(); +@@ -265,11 +343,10 @@ mod tests { + .attrs + .insert( + "fidelity".to_string(), +- AttrValue::String("truncate".to_string()), ++ AttrValue::String("full".to_string()), + ); + let d = Rule.apply(&g); + assert_eq!(d.len(), 1); +- assert!(d[0].message.contains("'fork', 'fork2'")); + let fix = d[0].fix.as_deref().expect("diagnostic has a fix"); + assert!(fix.contains("'fork', 'fork2'")); + assert!(fix.contains("parallel nodes")); +diff --git a/lib/crates/fabro-validate/src/rules/thread_id_requires_fidelity_full.rs b/lib/crates/fabro-validate/src/rules/thread_id_requires_fidelity_full.rs +index 457553788..a73335da5 100644 +--- a/lib/crates/fabro-validate/src/rules/thread_id_requires_fidelity_full.rs ++++ b/lib/crates/fabro-validate/src/rules/thread_id_requires_fidelity_full.rs +@@ -1,5 +1,6 @@ + use fabro_graphviz::graph::Graph; + ++use super::parallel_branch::ParallelBranches; + use crate::{Diagnostic, LintRule, Severity}; + + pub(super) fn rule() -> Box { +@@ -20,9 +21,13 @@ impl LintRule for Rule { + fn apply(&self, graph: &Graph) -> Vec { + let mut diagnostics = Vec::new(); + let graph_default_full = graph.default_fidelity() == Some("full"); ++ let branches = ParallelBranches::new(graph); + + for node in graph.nodes.values() { +- if node.thread_id().is_some() && node.fidelity() != Some("full") && !graph_default_full ++ if node.thread_id().is_some() ++ && !branches.is_branch_only_node(&node.id) ++ && node.fidelity() != Some("full") ++ && !graph_default_full + { + diagnostics.push(Diagnostic { + rule: self.name().to_string(), +@@ -41,7 +46,7 @@ impl LintRule for Rule { + } + + for edge in &graph.edges { +- if edge.thread_id().is_some() { ++ if edge.thread_id().is_some() && !branches.is_fork_edge(edge) { + let edge_full = edge.fidelity() == Some("full"); + let target_full = + graph.nodes.get(&edge.to).and_then(|n| n.fidelity()) == Some("full"); +@@ -82,12 +87,29 @@ impl LintRule for Rule { + + #[cfg(test)] + mod tests { +- use fabro_graphviz::graph::{AttrValue, Edge, Node}; ++ use fabro_graphviz::graph::{AttrValue, Edge, Graph, Node}; + + use super::Rule; + use crate::rules::test_support::minimal_graph; + use crate::{LintRule, Severity}; + ++ fn parallel_graph() -> Graph { ++ let mut g = minimal_graph(); ++ let mut fork = Node::new("fork"); ++ fork.attrs.insert( ++ "shape".to_string(), ++ AttrValue::String("component".to_string()), ++ ); ++ g.nodes.insert("fork".to_string(), fork); ++ g.nodes.insert("branch".to_string(), Node::new("branch")); ++ g.edges = vec![ ++ Edge::new("start", "fork"), ++ Edge::new("fork", "branch"), ++ Edge::new("branch", "exit"), ++ ]; ++ g ++ } ++ + #[test] + fn thread_id_requires_fidelity_full_node_warns() { + let mut g = minimal_graph(); +@@ -206,6 +228,51 @@ mod tests { + assert!(d.is_empty()); + } + ++ #[test] ++ fn skips_thread_id_on_parallel_branch_edge() { ++ let mut g = parallel_graph(); ++ g.edges[1].attrs.insert( ++ "thread_id".to_string(), ++ AttrValue::String("branch-thread".to_string()), ++ ); ++ ++ assert!(Rule.apply(&g).is_empty()); ++ } ++ ++ #[test] ++ fn skips_thread_id_on_branch_only_node() { ++ let mut g = parallel_graph(); ++ g.nodes ++ .get_mut("branch") ++ .expect("graph has branch") ++ .attrs ++ .insert( ++ "thread_id".to_string(), ++ AttrValue::String("branch-thread".to_string()), ++ ); ++ ++ assert!(Rule.apply(&g).is_empty()); ++ } ++ ++ #[test] ++ fn checks_thread_id_on_branch_node_with_normal_entry() { ++ let mut g = parallel_graph(); ++ g.edges.push(Edge::new("start", "branch")); ++ g.nodes ++ .get_mut("branch") ++ .expect("graph has branch") ++ .attrs ++ .insert( ++ "thread_id".to_string(), ++ AttrValue::String("shared-thread".to_string()), ++ ); ++ ++ let d = Rule.apply(&g); ++ ++ assert_eq!(d.len(), 1); ++ assert_eq!(d[0].node_id.as_deref(), Some("branch")); ++ } ++ + #[test] + fn thread_id_requires_fidelity_full_graph_warns() { + let mut g = minimal_graph(); +diff --git a/lib/crates/fabro-workflow/src/artifact.rs b/lib/crates/fabro-workflow/src/artifact.rs +index 3f9f9eb7d..975fee26d 100644 +--- a/lib/crates/fabro-workflow/src/artifact.rs ++++ b/lib/crates/fabro-workflow/src/artifact.rs +@@ -79,6 +79,7 @@ pub fn format_artifact_reference(path: &str) -> String { + pub fn durable_context_snapshot(context: &Context) -> HashMap { + let mut snapshot = context.snapshot(); + snapshot.remove(context::keys::CURRENT_PREAMBLE); ++ snapshot.remove(context::keys::INTERNAL_PARALLEL_BRANCH_PREAMBLES); + normalize_durable_updates(&mut snapshot); + snapshot + } +@@ -99,6 +100,9 @@ pub fn normalize_checkpoint_for_resume(checkpoint: &mut Checkpoint) { + checkpoint + .context_values + .remove(context::keys::CURRENT_PREAMBLE); ++ checkpoint ++ .context_values ++ .remove(context::keys::INTERNAL_PARALLEL_BRANCH_PREAMBLES); + normalize_durable_updates(&mut checkpoint.context_values); + normalize_durable_outcomes(&mut checkpoint.node_outcomes); + } +@@ -515,6 +519,59 @@ mod tests { + ); + } + ++ #[test] ++ fn durable_context_snapshot_drops_parallel_branch_preambles() { ++ let context = Context::new(); ++ context.set( ++ context::keys::INTERNAL_PARALLEL_BRANCH_PREAMBLES, ++ serde_json::json!({"branch-a": "runtime only"}), ++ ); ++ context.set("response.work", serde_json::json!("durable")); ++ ++ let snapshot = durable_context_snapshot(&context); ++ ++ assert!(!snapshot.contains_key(context::keys::INTERNAL_PARALLEL_BRANCH_PREAMBLES)); ++ assert_eq!( ++ snapshot.get("response.work"), ++ Some(&serde_json::json!("durable")) ++ ); ++ } ++ ++ #[test] ++ fn normalize_checkpoint_for_resume_drops_parallel_branch_preambles() { ++ let mut checkpoint = crate::records::Checkpoint { ++ timestamp: chrono::Utc::now(), ++ current_node: "work".to_string(), ++ completed_nodes: vec!["work".to_string()], ++ node_retries: HashMap::new(), ++ context_values: HashMap::from([ ++ ( ++ context::keys::INTERNAL_PARALLEL_BRANCH_PREAMBLES.to_string(), ++ serde_json::json!({"branch-a": "runtime only"}), ++ ), ++ ("response.work".to_string(), serde_json::json!("durable")), ++ ]), ++ node_outcomes: HashMap::new(), ++ next_node_id: Some("exit".to_string()), ++ git_commit_sha: None, ++ loop_failure_signatures: HashMap::new(), ++ restart_failure_signatures: HashMap::new(), ++ node_visits: HashMap::new(), ++ }; ++ ++ normalize_checkpoint_for_resume(&mut checkpoint); ++ ++ assert!( ++ !checkpoint ++ .context_values ++ .contains_key(context::keys::INTERNAL_PARALLEL_BRANCH_PREAMBLES) ++ ); ++ assert_eq!( ++ checkpoint.context_values.get("response.work"), ++ Some(&serde_json::json!("durable")) ++ ); ++ } ++ + #[test] + fn normalize_checkpoint_for_resume_converts_managed_blob_file_refs_and_drops_preamble() { + let blob_id = fabro_types::RunBlobId::new(b"managed"); +diff --git a/lib/crates/fabro-workflow/src/context.rs b/lib/crates/fabro-workflow/src/context.rs +index af1233586..b911e2210 100644 +--- a/lib/crates/fabro-workflow/src/context.rs ++++ b/lib/crates/fabro-workflow/src/context.rs +@@ -25,6 +25,7 @@ pub mod keys { + pub const INTERNAL_PARENT_PREAMBLE: &str = "internal.parent_preamble"; + pub const INTERNAL_PARALLEL_GROUP_ID: &str = "internal.parallel_group_id"; + pub const INTERNAL_PARALLEL_BRANCH_ID: &str = "internal.parallel_branch_id"; ++ pub const INTERNAL_PARALLEL_BRANCH_PREAMBLES: &str = "internal.parallel_branch_preambles"; + + // --- current.* keys --- + pub const CURRENT_PREAMBLE: &str = "current.preamble"; +diff --git a/lib/crates/fabro-workflow/src/handler/parallel.rs b/lib/crates/fabro-workflow/src/handler/parallel.rs +index 22aa2aa9c..ac4e4f3e1 100644 +--- a/lib/crates/fabro-workflow/src/handler/parallel.rs ++++ b/lib/crates/fabro-workflow/src/handler/parallel.rs +@@ -4,6 +4,7 @@ use std::time::Instant; + + use async_trait::async_trait; + use fabro_agent::{Sandbox, WorktreeOptions, WorktreeSandbox}; ++use fabro_graphviz::Fidelity; + use fabro_graphviz::graph::{AttrValue, Graph, Node}; + use fabro_hooks::{HookContext, HookEvent}; + use fabro_types::{ParallelBranchId, RunId, StageId}; +@@ -56,6 +57,39 @@ struct BranchResult { + worktree_path: Option, + } + ++struct BranchPreamble { ++ fidelity: Fidelity, ++ preamble: String, ++} ++ ++fn parse_branch_preambles( ++ value: Option, ++ branch_count: usize, ++) -> Option>> { ++ let serde_json::Value::Array(entries) = value? else { ++ return None; ++ }; ++ if entries.len() != branch_count { ++ return None; ++ } ++ ++ entries ++ .into_iter() ++ .map(|entry| match entry { ++ serde_json::Value::Null => Some(None), ++ serde_json::Value::Object(entry) if entry.len() == 2 => { ++ let fidelity = entry.get("fidelity")?.as_str()?.parse().ok()?; ++ let preamble = entry.get("preamble")?.as_str()?; ++ Some(Some(BranchPreamble { ++ fidelity, ++ preamble: preamble.to_string(), ++ })) ++ } ++ _ => None, ++ }) ++ .collect() ++} ++ + #[async_trait] + impl Handler for ParallelHandler { + async fn simulate( +@@ -220,6 +254,10 @@ impl Handler for ParallelHandler { + None + }; + ++ let branch_preambles = parse_branch_preambles( ++ context.get(keys::INTERNAL_PARALLEL_BRANCH_PREAMBLES), ++ branches.len(), ++ ); + let mut branch_setups: Vec = Vec::new(); + for (branch_index, edge) in branches.iter().enumerate() { + let target_id = edge.to.clone(); +@@ -236,6 +274,21 @@ impl Handler for ParallelHandler { + keys::INTERNAL_PARALLEL_BRANCH_ID, + serde_json::Value::String(parallel_branch_id.to_string()), + ); ++ if let Some(entry) = branch_preambles ++ .as_ref() ++ .and_then(|entries| entries.get(branch_index)) ++ .and_then(Option::as_ref) ++ { ++ branch_context.set(keys::CURRENT_PREAMBLE, serde_json::json!(&entry.preamble)); ++ branch_context.set( ++ keys::INTERNAL_FIDELITY, ++ serde_json::json!(entry.fidelity.to_string()), ++ ); ++ } ++ branch_context.set( ++ keys::INTERNAL_PARALLEL_BRANCH_PREAMBLES, ++ serde_json::Value::Null, ++ ); + + let (branch_sandbox, worktree_path): (Arc, Option) = if let ( + Some(ref gs), +@@ -297,6 +350,10 @@ impl Handler for ParallelHandler { + worktree_path, + }); + } ++ context.set( ++ keys::INTERNAL_PARALLEL_BRANCH_PREAMBLES, ++ serde_json::Value::Null, ++ ); + + // --- Fan out: concurrent execution --- + let mut handles = Vec::new(); +@@ -693,7 +750,7 @@ fn parallel_branch_commit_cmd( + + #[cfg(test)] + mod tests { +- use std::sync::Arc; ++ use std::sync::{Arc, Mutex}; + use std::time::Duration; + + use fabro_graphviz::graph::{AttrValue, Edge}; +@@ -756,6 +813,185 @@ mod tests { + context + } + ++ #[derive(Clone, Debug, PartialEq)] ++ struct BranchContextCapture { ++ node_id: String, ++ preamble: String, ++ fidelity: String, ++ stash: Option, ++ } ++ ++ struct BranchContextRecordingHandler { ++ captures: Arc>>, ++ } ++ ++ #[async_trait] ++ impl Handler for BranchContextRecordingHandler { ++ async fn execute( ++ &self, ++ node: &Node, ++ context: &Context, ++ _graph: &Graph, ++ _run_dir: &Path, ++ _services: &EngineServices, ++ ) -> Result { ++ self.captures.lock().unwrap().push(BranchContextCapture { ++ node_id: node.id.clone(), ++ preamble: context.preamble(), ++ fidelity: context.fidelity().to_string(), ++ stash: context.get(keys::INTERNAL_PARALLEL_BRANCH_PREAMBLES), ++ }); ++ Ok(Outcome::success()) ++ } ++ } ++ ++ async fn execute_with_branch_stash( ++ stash: Option, ++ duplicate_target: bool, ++ ) -> (Context, Vec) { ++ let captures = Arc::new(Mutex::new(Vec::new())); ++ let recorder = BranchContextRecordingHandler { ++ captures: Arc::clone(&captures), ++ }; ++ let mut registry = super::super::HandlerRegistry::new(Box::new(recorder)); ++ registry.register( ++ "record", ++ Box::new(BranchContextRecordingHandler { ++ captures: Arc::clone(&captures), ++ }), ++ ); ++ let mut services = EngineServices::test_default(); ++ services.registry = Arc::new(registry); ++ ++ let mut node = Node::new("par"); ++ node.attrs.insert( ++ "shape".to_string(), ++ AttrValue::String("component".to_string()), ++ ); ++ let mut branch_a = Node::new("branch_a"); ++ branch_a ++ .attrs ++ .insert("type".to_string(), AttrValue::String("record".to_string())); ++ let mut branch_b = Node::new("branch_b"); ++ branch_b ++ .attrs ++ .insert("type".to_string(), AttrValue::String("record".to_string())); ++ ++ let mut graph = Graph::new("test"); ++ graph.nodes.insert(node.id.clone(), node.clone()); ++ graph.nodes.insert(branch_a.id.clone(), branch_a); ++ graph.nodes.insert(branch_b.id.clone(), branch_b); ++ graph.edges.push(Edge::new("par", "branch_a")); ++ graph.edges.push(Edge::new( ++ "par", ++ if duplicate_target { ++ "branch_a" ++ } else { ++ "branch_b" ++ }, ++ )); ++ ++ let context = test_context(); ++ context.set(keys::CURRENT_PREAMBLE, serde_json::json!("fork preamble")); ++ context.set(keys::INTERNAL_FIDELITY, serde_json::json!("compact")); ++ if let Some(stash) = stash { ++ context.set(keys::INTERNAL_PARALLEL_BRANCH_PREAMBLES, stash); ++ } ++ ++ let run_dir = tempfile::tempdir().unwrap(); ++ ParallelHandler ++ .execute(&node, &context, &graph, run_dir.path(), &services) ++ .await ++ .unwrap(); ++ ++ let captures = captures.lock().unwrap().clone(); ++ (context, captures) ++ } ++ ++ #[tokio::test] ++ async fn parallel_handler_applies_indexed_branch_preambles_and_clears_stash() { ++ let stash = serde_json::json!([ ++ {"fidelity": "truncate", "preamble": "branch zero"}, ++ {"fidelity": "summary:high", "preamble": "branch one"} ++ ]); ++ ++ let (context, mut captures) = execute_with_branch_stash(Some(stash), false).await; ++ captures.sort_by(|left, right| left.node_id.cmp(&right.node_id)); ++ ++ assert_eq!(captures.len(), 2); ++ assert_eq!(captures[0].node_id, "branch_a"); ++ assert_eq!(captures[0].preamble, "branch zero"); ++ assert_eq!(captures[0].fidelity, "truncate"); ++ assert_eq!(captures[0].stash, Some(serde_json::Value::Null)); ++ assert_eq!(captures[1].node_id, "branch_b"); ++ assert_eq!(captures[1].preamble, "branch one"); ++ assert_eq!(captures[1].fidelity, "summary:high"); ++ assert_eq!(captures[1].stash, Some(serde_json::Value::Null)); ++ assert_eq!( ++ context.get(keys::INTERNAL_PARALLEL_BRANCH_PREAMBLES), ++ Some(serde_json::Value::Null) ++ ); ++ } ++ ++ #[tokio::test] ++ async fn parallel_handler_uses_edge_index_for_duplicate_targets() { ++ let stash = serde_json::json!([ ++ {"fidelity": "truncate", "preamble": "first edge"}, ++ {"fidelity": "summary:low", "preamble": "second edge"} ++ ]); ++ ++ let (_context, captures) = execute_with_branch_stash(Some(stash), true).await; ++ let observed = captures ++ .iter() ++ .map(|capture| (capture.preamble.as_str(), capture.fidelity.as_str())) ++ .collect::>(); ++ ++ assert_eq!(observed.len(), 2); ++ assert!(observed.contains(&("first edge", "truncate"))); ++ assert!(observed.contains(&("second edge", "summary:low"))); ++ assert!( ++ captures ++ .iter() ++ .all(|capture| capture.stash == Some(serde_json::Value::Null)) ++ ); ++ } ++ ++ #[tokio::test] ++ async fn parallel_handler_legacy_stashes_inherit_fork_context() { ++ for stash in [ ++ None, ++ Some(serde_json::Value::Null), ++ Some(serde_json::json!({ ++ "fidelity": "truncate", ++ "preamble": "not an array" ++ })), ++ Some(serde_json::json!([ ++ {"fidelity": "truncate", "preamble": "wrong length"} ++ ])), ++ Some(serde_json::json!([ ++ {"fidelity": "truncate"}, ++ null ++ ])), ++ Some(serde_json::json!([ ++ {"fidelity": "not-a-fidelity", "preamble": "malformed fidelity"}, ++ null ++ ])), ++ ] { ++ let (context, captures) = execute_with_branch_stash(stash, false).await; ++ ++ assert_eq!(captures.len(), 2); ++ assert!(captures.iter().all(|capture| { ++ capture.preamble == "fork preamble" ++ && capture.fidelity == "compact" ++ && capture.stash == Some(serde_json::Value::Null) ++ })); ++ assert_eq!( ++ context.get(keys::INTERNAL_PARALLEL_BRANCH_PREAMBLES), ++ Some(serde_json::Value::Null) ++ ); ++ } ++ } ++ + #[tokio::test] + async fn parallel_handler_no_branches() { + let services = make_services(); +diff --git a/lib/crates/fabro-workflow/src/lifecycle/event.rs b/lib/crates/fabro-workflow/src/lifecycle/event.rs +index 5828dc86b..3965d7261 100644 +--- a/lib/crates/fabro-workflow/src/lifecycle/event.rs ++++ b/lib/crates/fabro-workflow/src/lifecycle/event.rs +@@ -14,7 +14,7 @@ use fabro_types::{Principal, RunId, StageTiming}; + + use super::circuit_breaker::CircuitBreakerLifecycle; + use super::git::GitCheckpointResult; +-use crate::context::WorkflowContext; ++use crate::context::{Context, WorkflowContext}; + use crate::event::{Emitter, Event, StageScope}; + use crate::graph::{WorkflowGraph, WorkflowNode}; + use crate::outcome::{BilledModelUsage, FailureCategory, FailureDetail, Outcome, StageOutcome}; +@@ -92,6 +92,12 @@ fn response_from_outcome(node_id: &str, outcome: &Outcome) -> Option { + .and_then(|value| value.as_str().map(ToOwned::to_owned)) + } + ++fn stage_context_values(workflow_context: &Context) -> Option> { ++ let mut snapshot = workflow_context.snapshot(); ++ snapshot.remove(context::keys::INTERNAL_PARALLEL_BRANCH_PREAMBLES); ++ (!snapshot.is_empty()).then(|| snapshot.into_iter().collect()) ++} ++ + pub(super) fn stage_visit(state: &WfRunState, node_id: &str) -> u32 { + let visits = state.node_visits.get(node_id).copied().unwrap_or(1); + u32::try_from(visits).unwrap_or(u32::MAX) +@@ -318,11 +324,7 @@ impl RunLifecycle for EventLifecycle { + .collect::>() + }), + jump_to_node: outcome.jump_to_node.clone(), +- context_values: { +- let snapshot = state.context.snapshot(); +- (!snapshot.is_empty()) +- .then(|| snapshot.into_iter().collect::>()) +- }, ++ context_values: stage_context_values(&state.context), + node_visits: (!state.node_visits.is_empty()).then(|| { + state + .node_visits +@@ -446,3 +448,26 @@ impl RunLifecycle for EventLifecycle { + Ok(()) + } + } ++ ++#[cfg(test)] ++mod tests { ++ use super::*; ++ ++ #[test] ++ fn stage_context_values_drops_parallel_branch_preambles() { ++ let workflow_context = Context::new(); ++ workflow_context.set( ++ context::keys::INTERNAL_PARALLEL_BRANCH_PREAMBLES, ++ serde_json::json!([{"fidelity": "summary:high", "preamble": "runtime only"}]), ++ ); ++ workflow_context.set("response.work", serde_json::json!("durable")); ++ ++ let values = stage_context_values(&workflow_context).expect("snapshot should not be empty"); ++ ++ assert!(!values.contains_key(context::keys::INTERNAL_PARALLEL_BRANCH_PREAMBLES)); ++ assert_eq!( ++ values.get("response.work"), ++ Some(&serde_json::json!("durable")) ++ ); ++ } ++} +diff --git a/lib/crates/fabro-workflow/src/lifecycle/fidelity.rs b/lib/crates/fabro-workflow/src/lifecycle/fidelity.rs +index 3f45c8142..3f0b24228 100644 +--- a/lib/crates/fabro-workflow/src/lifecycle/fidelity.rs ++++ b/lib/crates/fabro-workflow/src/lifecycle/fidelity.rs +@@ -78,6 +78,11 @@ impl RunLifecycle for FidelityLifecycle { + node: &WorkflowNode, + state: &WfRunState, + ) -> CoreResult { ++ state.context.set( ++ keys::INTERNAL_PARALLEL_BRANCH_PREAMBLES, ++ serde_json::Value::Null, ++ ); ++ + let incoming = self + .incoming_edge_data + .lock() +@@ -138,6 +143,46 @@ impl RunLifecycle for FidelityLifecycle { + .context + .set(keys::CURRENT_PREAMBLE, serde_json::json!(preamble)); + ++ if gv_node.handler_type() == Some("parallel") { ++ let mut branch_preambles = Vec::new(); ++ for (branch_index, edge) in self.graph.outgoing_edges(node.id()).iter().enumerate() { ++ let Some(target_node) = self.graph.nodes.get(&edge.to) else { ++ branch_preambles.push(serde_json::Value::Null); ++ continue; ++ }; ++ let resolution = resolve_parallel_branch_fidelity(edge, target_node, fidelity); ++ if resolution.requested() == Some(keys::Fidelity::Full) { ++ tracing::warn!( ++ parallel_node = %node.id(), ++ branch = %edge.to, ++ branch_index, ++ fidelity = %keys::Fidelity::Full, ++ effective_fidelity = %keys::Fidelity::SummaryHigh, ++ "Parallel branch fidelity degraded" ++ ); ++ } ++ let Some(branch_fidelity) = resolution.entry_fidelity() else { ++ branch_preambles.push(serde_json::Value::Null); ++ continue; ++ }; ++ let branch_preamble = build_preamble( ++ branch_fidelity, ++ &resolved_context, ++ &self.graph, ++ &state.completed_nodes, ++ &resolved_outcomes, ++ ); ++ branch_preambles.push(serde_json::json!({ ++ "fidelity": branch_fidelity.to_string(), ++ "preamble": branch_preamble, ++ })); ++ } ++ state.context.set( ++ keys::INTERNAL_PARALLEL_BRANCH_PREAMBLES, ++ serde_json::Value::Array(branch_preambles), ++ ); ++ } ++ + // 5. Thread ID resolution via resolve_thread_id: edge → node → graph default → + // class → previous + let thread_id = resolve_thread_id( +@@ -198,6 +243,47 @@ impl RunLifecycle for FidelityLifecycle { + } + } + ++#[derive(Debug, Clone, Copy, PartialEq, Eq)] ++struct ParallelBranchFidelityResolution { ++ requested: Option, ++ effective: Option, ++} ++ ++impl ParallelBranchFidelityResolution { ++ fn requested(self) -> Option { ++ self.requested ++ } ++ ++ fn entry_fidelity(self) -> Option { ++ self.effective ++ } ++} ++ ++/// Resolve explicit branch fidelity with edge-over-node precedence. ++/// ++/// Branches with no explicit fidelity inherit the parallel node's preamble. ++/// Explicit full fidelity is degraded because concurrent branches cannot share ++/// an LLM session. An effective fidelity equal to the parallel node also ++/// inherits, avoiding a redundant preamble render. ++fn resolve_parallel_branch_fidelity( ++ edge: &GvEdge, ++ target_node: &GvNode, ++ parallel_fidelity: keys::Fidelity, ++) -> ParallelBranchFidelityResolution { ++ let requested = edge ++ .fidelity() ++ .and_then(|value| value.parse().ok()) ++ .or_else(|| target_node.fidelity().and_then(|value| value.parse().ok())); ++ let effective = requested ++ .map(keys::Fidelity::degraded) ++ .filter(|fidelity| *fidelity != parallel_fidelity); ++ ++ ParallelBranchFidelityResolution { ++ requested, ++ effective, ++ } ++} ++ + /// Resolve the context fidelity for a node, following the precedence: + /// 1. Incoming edge `fidelity` attribute + /// 2. Target node `fidelity` attribute +@@ -263,11 +349,216 @@ fn resolve_thread_id( + + #[cfg(test)] + mod tests { ++ use std::path::Path; ++ use std::time::Duration; ++ ++ use fabro_core::graph::Graph as CoreGraph; + use fabro_graphviz::graph::{AttrValue, Edge, Graph, Node}; ++ use fabro_store::Database; ++ use fabro_types::fixtures; ++ use object_store::memory::InMemory; + + use super::*; ++ use crate::context::WorkflowContext; + use crate::context::keys::Fidelity; + ++ fn fidelity_attr(value: &str) -> AttrValue { ++ AttrValue::String(value.to_string()) ++ } ++ ++ fn parallel_workflow_graph( ++ fork_fidelity: Option<&str>, ++ branch_a_fidelity: Option<&str>, ++ ) -> WorkflowGraph { ++ let mut graph = Graph::new("parallel-fidelity"); ++ let mut start = Node::new("start"); ++ start ++ .attrs ++ .insert("shape".to_string(), fidelity_attr("Mdiamond")); ++ let mut fork = Node::new("fork"); ++ fork.attrs ++ .insert("shape".to_string(), fidelity_attr("component")); ++ if let Some(fidelity) = fork_fidelity { ++ fork.attrs ++ .insert("fidelity".to_string(), fidelity_attr(fidelity)); ++ } ++ let mut branch_a = Node::new("branch_a"); ++ if let Some(fidelity) = branch_a_fidelity { ++ branch_a ++ .attrs ++ .insert("fidelity".to_string(), fidelity_attr(fidelity)); ++ } ++ let branch_b = Node::new("branch_b"); ++ let mut work = Node::new("work"); ++ work.attrs.insert("shape".to_string(), fidelity_attr("box")); ++ ++ graph.nodes.insert(start.id.clone(), start); ++ graph.nodes.insert(fork.id.clone(), fork); ++ graph.nodes.insert(branch_a.id.clone(), branch_a); ++ graph.nodes.insert(branch_b.id.clone(), branch_b); ++ graph.nodes.insert(work.id.clone(), work); ++ graph.edges.push(Edge::new("start", "fork")); ++ graph.edges.push(Edge::new("fork", "branch_a")); ++ graph.edges.push(Edge::new("fork", "branch_b")); ++ ++ WorkflowGraph(Arc::new(graph)) ++ } ++ ++ async fn test_lifecycle(graph: &WorkflowGraph, run_dir: &Path) -> FidelityLifecycle { ++ let store = Arc::new(Database::new( ++ Arc::new(InMemory::new()), ++ "", ++ Duration::from_millis(1), ++ None, ++ )); ++ let run_store = store.create_run(&fixtures::RUN_1).await.unwrap(); ++ let sandbox: Arc = ++ Arc::new(fabro_agent::LocalSandbox::new(run_dir.to_path_buf())); ++ FidelityLifecycle::new( ++ graph.0.clone(), ++ sandbox, ++ RunStoreHandle::local(run_store), ++ run_dir.to_path_buf(), ++ ) ++ } ++ ++ #[test] ++ fn parallel_branch_fidelity_edge_overrides_node() { ++ let mut node = Node::new("branch"); ++ node.attrs ++ .insert("fidelity".to_string(), fidelity_attr("compact")); ++ let mut edge = Edge::new("fork", "branch"); ++ edge.attrs ++ .insert("fidelity".to_string(), fidelity_attr("truncate")); ++ ++ let resolved = resolve_parallel_branch_fidelity(&edge, &node, Fidelity::SummaryHigh); ++ ++ assert_eq!(resolved.requested(), Some(Fidelity::Truncate)); ++ assert_eq!(resolved.entry_fidelity(), Some(Fidelity::Truncate)); ++ } ++ ++ #[test] ++ fn parallel_branch_fidelity_without_attribute_inherits() { ++ let node = Node::new("branch"); ++ let edge = Edge::new("fork", "branch"); ++ ++ let resolution = resolve_parallel_branch_fidelity(&edge, &node, Fidelity::Compact); ++ ++ assert_eq!(resolution.requested(), None); ++ assert_eq!(resolution.entry_fidelity(), None); ++ } ++ ++ #[test] ++ fn parallel_branch_full_fidelity_degrades_to_summary_high() { ++ let mut node = Node::new("branch"); ++ node.attrs ++ .insert("fidelity".to_string(), fidelity_attr("full")); ++ let edge = Edge::new("fork", "branch"); ++ ++ let resolved = resolve_parallel_branch_fidelity(&edge, &node, Fidelity::Compact); ++ ++ assert_eq!(resolved.requested(), Some(Fidelity::Full)); ++ assert_eq!(resolved.entry_fidelity(), Some(Fidelity::SummaryHigh)); ++ } ++ ++ #[test] ++ fn parallel_branch_fidelity_equal_to_fork_inherits() { ++ let mut node = Node::new("branch"); ++ node.attrs ++ .insert("fidelity".to_string(), fidelity_attr("summary:high")); ++ let edge = Edge::new("fork", "branch"); ++ ++ let resolution = resolve_parallel_branch_fidelity(&edge, &node, Fidelity::SummaryHigh); ++ ++ assert_eq!(resolution.requested(), Some(Fidelity::SummaryHigh)); ++ assert_eq!(resolution.entry_fidelity(), None); ++ } ++ ++ #[test] ++ fn explicit_full_branch_equal_to_degraded_fork_inherits() { ++ let mut node = Node::new("branch"); ++ node.attrs ++ .insert("fidelity".to_string(), fidelity_attr("full")); ++ let edge = Edge::new("fork", "branch"); ++ ++ let resolution = resolve_parallel_branch_fidelity(&edge, &node, Fidelity::SummaryHigh); ++ ++ assert_eq!(resolution.requested(), Some(Fidelity::Full)); ++ assert_eq!(resolution.entry_fidelity(), None); ++ } ++ ++ #[test] ++ fn full_fork_without_branch_fidelity_does_not_create_entry() { ++ let node = Node::new("branch"); ++ let edge = Edge::new("fork", "branch"); ++ ++ let resolution = resolve_parallel_branch_fidelity(&edge, &node, Fidelity::Full); ++ ++ assert_eq!(resolution.requested(), None); ++ assert_eq!(resolution.entry_fidelity(), None); ++ } ++ ++ #[tokio::test] ++ async fn parallel_before_node_rebuilds_branch_preamble_stash() { ++ let graph = parallel_workflow_graph(None, Some("truncate")); ++ let run_dir = tempfile::tempdir().unwrap(); ++ let lifecycle = test_lifecycle(&graph, run_dir.path()).await; ++ let state: WfRunState = ExecutionState::new(&graph).unwrap(); ++ let fork = graph.get_node("fork").unwrap(); ++ ++ lifecycle.before_node(&fork, &state).await.unwrap(); ++ state.context.set( ++ keys::INTERNAL_PARALLEL_BRANCH_PREAMBLES, ++ serde_json::json!(["stale", "entries", "must disappear"]), ++ ); ++ lifecycle.before_node(&fork, &state).await.unwrap(); ++ ++ let stash = state ++ .context ++ .get(keys::INTERNAL_PARALLEL_BRANCH_PREAMBLES) ++ .expect("parallel stash should be set"); ++ let entries = stash.as_array().expect("parallel stash should be an array"); ++ assert_eq!(entries.len(), 2); ++ assert!(entries[0].is_object()); ++ assert!(entries[1].is_null()); ++ } ++ ++ #[tokio::test] ++ async fn non_parallel_before_node_overwrites_branch_preamble_stash_with_null() { ++ let graph = parallel_workflow_graph(None, Some("truncate")); ++ let run_dir = tempfile::tempdir().unwrap(); ++ let lifecycle = test_lifecycle(&graph, run_dir.path()).await; ++ let state: WfRunState = ExecutionState::new(&graph).unwrap(); ++ let fork = graph.get_node("fork").unwrap(); ++ let work = graph.get_node("work").unwrap(); ++ ++ lifecycle.before_node(&fork, &state).await.unwrap(); ++ lifecycle.before_node(&work, &state).await.unwrap(); ++ ++ assert_eq!( ++ state.context.get(keys::INTERNAL_PARALLEL_BRANCH_PREAMBLES), ++ Some(serde_json::Value::Null) ++ ); ++ } ++ ++ #[tokio::test] ++ async fn resumed_full_fork_degrades_without_rendering_fallback_branches() { ++ let graph = parallel_workflow_graph(Some("full"), None); ++ let run_dir = tempfile::tempdir().unwrap(); ++ let lifecycle = test_lifecycle(&graph, run_dir.path()).await; ++ lifecycle.set_degrade_fidelity_on_resume(true); ++ let state: WfRunState = ExecutionState::new(&graph).unwrap(); ++ let fork = graph.get_node("fork").unwrap(); ++ ++ lifecycle.before_node(&fork, &state).await.unwrap(); ++ ++ assert_eq!(state.context.fidelity(), Fidelity::SummaryHigh); ++ assert_eq!( ++ state.context.get(keys::INTERNAL_PARALLEL_BRANCH_PREAMBLES), ++ Some(serde_json::json!([null, null])) ++ ); ++ } ++ + #[test] + fn fidelity_defaults_to_compact() { + let node = Node::new("work"); +diff --git a/lib/crates/fabro-workflow/tests/it/integration.rs b/lib/crates/fabro-workflow/tests/it/integration.rs +index 87ebeee4d..88e1f1aa9 100644 +--- a/lib/crates/fabro-workflow/tests/it/integration.rs ++++ b/lib/crates/fabro-workflow/tests/it/integration.rs +@@ -4936,6 +4936,27 @@ struct FidelityCapturingHandler { + captures: FidelityCaptures, + } + ++struct ParallelFidelitySeedHandler; ++ ++#[async_trait::async_trait] ++impl Handler for ParallelFidelitySeedHandler { ++ async fn execute( ++ &self, ++ _node: &Node, ++ _context: &Context, ++ _graph: &Graph, ++ _run_dir: &Path, ++ _services: &fabro_workflow::handler::EngineServices, ++ ) -> Result { ++ let mut outcome = Outcome::success(); ++ outcome.context_updates.insert( ++ "parallel_fidelity_marker".to_string(), ++ serde_json::json!("marker visible to inherited preambles"), ++ ); ++ Ok(outcome) ++ } ++} ++ + #[async_trait::async_trait] + impl Handler for FidelityCapturingHandler { + async fn execute( +@@ -9333,6 +9354,176 @@ async fn run_fidelity_prompt_pipeline(fidelity: &str) -> String { + .expect("report prompt should exist") + } + ++async fn run_parallel_fidelity_capture( ++ fork_fidelity: Option<&str>, ++ branch_node_fidelity: Option<&str>, ++ branch_edge_fidelity: Option<&str>, ++) -> FidelityCaptures { ++ use fabro_workflow::handler::fan_in::FanInHandler; ++ use fabro_workflow::handler::parallel::ParallelHandler; ++ ++ let mut graph = make_graph_with_start_exit("ParallelFidelityTest"); ++ graph.attrs.insert( ++ "goal".to_string(), ++ AttrValue::String("Verify parallel branch context".to_string()), ++ ); ++ ++ let mut seed = Node::new("seed"); ++ seed.attrs.insert( ++ "type".to_string(), ++ AttrValue::String("parallel_fidelity_seed".to_string()), ++ ); ++ let mut fork = Node::new("fork"); ++ fork.attrs.insert( ++ "shape".to_string(), ++ AttrValue::String("component".to_string()), ++ ); ++ if let Some(fidelity) = fork_fidelity { ++ fork.attrs.insert( ++ "fidelity".to_string(), ++ AttrValue::String(fidelity.to_string()), ++ ); ++ } ++ let mut branch_a = Node::new("branch_a"); ++ branch_a.attrs.insert( ++ "type".to_string(), ++ AttrValue::String("fidelity_capture".to_string()), ++ ); ++ if let Some(fidelity) = branch_node_fidelity { ++ branch_a.attrs.insert( ++ "fidelity".to_string(), ++ AttrValue::String(fidelity.to_string()), ++ ); ++ } ++ let mut branch_b = Node::new("branch_b"); ++ branch_b.attrs.insert( ++ "type".to_string(), ++ AttrValue::String("fidelity_capture".to_string()), ++ ); ++ let mut fan_in = Node::new("fan_in"); ++ fan_in.attrs.insert( ++ "shape".to_string(), ++ AttrValue::String("tripleoctagon".to_string()), ++ ); ++ ++ graph.nodes.insert(seed.id.clone(), seed); ++ graph.nodes.insert(fork.id.clone(), fork); ++ graph.nodes.insert(branch_a.id.clone(), branch_a); ++ graph.nodes.insert(branch_b.id.clone(), branch_b); ++ graph.nodes.insert(fan_in.id.clone(), fan_in); ++ graph.edges.push(Edge::new("start", "seed")); ++ graph.edges.push(Edge::new("seed", "fork")); ++ let mut branch_a_edge = Edge::new("fork", "branch_a"); ++ if let Some(fidelity) = branch_edge_fidelity { ++ branch_a_edge.attrs.insert( ++ "fidelity".to_string(), ++ AttrValue::String(fidelity.to_string()), ++ ); ++ } ++ graph.edges.push(branch_a_edge); ++ graph.edges.push(Edge::new("fork", "branch_b")); ++ graph.edges.push(Edge::new("branch_a", "fan_in")); ++ graph.edges.push(Edge::new("branch_b", "fan_in")); ++ graph.edges.push(Edge::new("fan_in", "exit")); ++ ++ let captures = FidelityCaptures::new(); ++ let mut registry = HandlerRegistry::new(Box::new(StartHandler)); ++ registry.register("start", Box::new(StartHandler)); ++ registry.register("exit", Box::new(ExitHandler)); ++ registry.register("parallel", Box::new(ParallelHandler)); ++ registry.register( ++ "parallel.fan_in", ++ Box::new(FanInHandler::new(Some(Box::new(MockCodergenBackend)))), ++ ); ++ registry.register( ++ "parallel_fidelity_seed", ++ Box::new(ParallelFidelitySeedHandler), ++ ); ++ registry.register( ++ "fidelity_capture", ++ Box::new(FidelityCapturingHandler { ++ captures: captures.clone(), ++ }), ++ ); ++ ++ let dir = tempfile::tempdir().expect("parallel fidelity run directory should be created"); ++ let engine = WorkflowRunner::new(registry, Arc::new(Emitter::default()), local_env()); ++ let run_options = RunOptions { ++ settings: WorkflowSettings::default(), ++ run_dir: dir.path().to_path_buf(), ++ cancel_token: CancellationToken::new(), ++ run_id: test_run_id("parallel-fidelity"), ++ labels: std::collections::HashMap::new(), ++ workflow_slug: None, ++ github_app: None, ++ base_branch: None, ++ display_base_sha: None, ++ pre_run_git: None, ++ fork_source_ref: None, ++ git: None, ++ }; ++ let (outcome, _state) = engine ++ .run_with_state(&graph, &run_options) ++ .await ++ .expect("parallel fidelity workflow should succeed"); ++ assert_eq!(outcome.status, StageOutcome::Succeeded); ++ captures ++} ++ ++fn captured_fidelity_preamble(captures: &FidelityCaptures, node_id: &str) -> (String, String) { ++ let fidelity = captures ++ .fidelities ++ .lock() ++ .unwrap() ++ .iter() ++ .find(|(captured_node_id, _)| captured_node_id == node_id) ++ .map(|(_, fidelity)| fidelity.clone()) ++ .expect("branch fidelity should be captured"); ++ let preamble = captures ++ .preambles ++ .lock() ++ .unwrap() ++ .iter() ++ .find(|(captured_node_id, _)| captured_node_id == node_id) ++ .map(|(_, preamble)| preamble.clone()) ++ .expect("branch preamble should be captured"); ++ (fidelity, preamble) ++} ++ ++#[tokio::test] ++async fn parallel_branches_get_per_branch_preambles_by_fidelity() { ++ let captures = run_parallel_fidelity_capture(None, Some("truncate"), None).await; ++ ++ let (branch_a_fidelity, branch_a_preamble) = captured_fidelity_preamble(&captures, "branch_a"); ++ let (branch_b_fidelity, branch_b_preamble) = captured_fidelity_preamble(&captures, "branch_b"); ++ ++ assert_eq!(branch_a_fidelity, "truncate"); ++ assert!(!branch_a_preamble.contains("parallel_fidelity_marker")); ++ assert_eq!(branch_b_fidelity, "compact"); ++ assert!(branch_b_preamble.contains("parallel_fidelity_marker")); ++} ++ ++#[tokio::test] ++async fn parallel_fork_fidelity_still_applies_to_all_branches() { ++ let captures = run_parallel_fidelity_capture(Some("truncate"), None, None).await; ++ ++ for branch_id in ["branch_a", "branch_b"] { ++ let (fidelity, preamble) = captured_fidelity_preamble(&captures, branch_id); ++ assert_eq!(fidelity, "truncate"); ++ assert!(!preamble.contains("parallel_fidelity_marker")); ++ } ++} ++ ++#[tokio::test] ++async fn parallel_branch_edge_fidelity_overrides_node_fidelity() { ++ let captures = ++ run_parallel_fidelity_capture(None, Some("summary:high"), Some("truncate")).await; ++ ++ let (fidelity, preamble) = captured_fidelity_preamble(&captures, "branch_a"); ++ assert_eq!(fidelity, "truncate"); ++ assert!(!preamble.contains("parallel_fidelity_marker")); ++} ++ + #[tokio::test] + async fn fidelity_prompt_compact() { + let prompt = run_fidelity_prompt_pipeline("compact").await; diff --git a/stages/005-implement@1/status.json b/stages/005-implement@1/status.json new file mode 100644 index 000000000..f26a06dbf --- /dev/null +++ b/stages/005-implement@1/status.json @@ -0,0 +1,6 @@ +{ + "outcome": "succeeded", + "notes": "Stage completed: implement", + "failure_reason": null, + "timestamp": "2026-07-23T17:41:28.062493494Z" +} \ No newline at end of file diff --git a/stages/006-simplify_fable@1/prompt.md b/stages/006-simplify_fable@1/prompt.md new file mode 100644 index 000000000..15f7c707a --- /dev/null +++ b/stages/006-simplify_fable@1/prompt.md @@ -0,0 +1,145 @@ +Goal: # Per-Branch Fidelity for Parallel Branches — Implementation Plan + +## Context + +Parallel branch nodes are dispatched via `dispatch_handler`, bypassing `FidelityLifecycle::before_node` (`lib/crates/fabro-workflow/src/lifecycle/fidelity.rs:76-180`) — the only place fidelity is resolved and preambles are built. Every branch therefore inherits the stale `current.preamble` copied at `context.fork()` (`handler/parallel.rs:226`), and `fidelity`/`thread_id` on branch nodes or `fork -> branch` edges are silently ignored. Confirmed live on the testing server (probes `01KY7KRA7E…`/`01KY7KRAAW…`, 2026-07-23): a `truncate` branch received the identical compact preamble as its default sibling; fork-level fidelity works and is the current workaround. Design reviewed via the Quarry doc "Fix: Per-Branch Fidelity for Parallel Branches". + +Why not run the lifecycle per branch: it is a single-token state machine (one-slot `incoming_edge_data` baton, singleton context keys written to shared run state); concurrent invocation would corrupt run state. And `build_preamble` (`handler/llm/preamble.rs:24`, public and pure) needs `state.completed_nodes`/`state.node_outcomes`, which only the lifecycle sees. So: **pre-render per-branch preambles in the lifecycle, hand off to the handler via one context key.** + +## Semantics (final, after design pressure-test) + +- **Explicit-only resolution.** A branch's fidelity comes from the `fork -> branch` edge attr, else the branch node attr, else **no entry** — the branch inherits the fork's preamble via `fork()` exactly as today. The fork's own resolved fidelity is never re-applied per branch; this keeps the default path byte-identical even when the fork resolves `Full` (where re-derivation would have wrongly degraded every branch). +- **`full` degrades to `summary:high`** (`Fidelity::degraded()`, `fabro-graphviz/src/fidelity.rs:35-40`) — applied only to *explicitly set* branch fidelity, with a log line (per `docs/internal/logging-strategy.md` — read before writing it). +- **Equality skip**: if the branch's post-degradation fidelity equals the fork's post-degradation fidelity, store no entry (avoid redundant renders). +- **`thread_id` stays inert in branches** (concurrent branches must never share an LLM session). +- **`CURRENT_NODE` in branch contexts stays inherited (fork id).** The pressure-test showed changing it would re-attribute every branch-internal event's stage scope (`context.rs:185-190` → `StageScope::for_handler` used by all handlers) with a visit mismatch against `for_parallel_branch` scoping. The Quarry doc's "bookkeeping keys describe the branch" line is consciously deferred to a separate change with proper visit accounting. +- **`simulate()` untouched.** No simulated handler reads preambles; partial mirroring would risk nested-parallel stash misreads. All-or-nothing → nothing. +- **Stash shape**: `Value::Array`, length = branch count, `Null` = inherit, else `{"fidelity": "...", "preamble": "..."}`. Array length ≠ edge count → treat as absent (legacy). Keyed by edge index; `graph.outgoing_edges` is an ordered Vec filter (`fabro-types/src/graph.rs:393-395`) and lifecycle + handler share the same `Arc`, so indices align deterministically (including two edges to the same target). + +## Implementation steps (ordered; tree compiles at each step) + +1. **`lib/crates/fabro-workflow/src/context.rs`** — add `pub const INTERNAL_PARALLEL_BRANCH_PREAMBLES: &str = "internal.parallel_branch_preambles";` to `keys`. The `internal.` prefix already excludes it from preamble rendering (`preamble.rs:99-109`) and child→parent propagation (`context.rs:80-85`). + +2. **`lib/crates/fabro-workflow/src/artifact.rs`** — strip the new key in `durable_context_snapshot` (`:81`) and `normalize_checkpoint_for_resume` (`:101`), beside `CURRENT_PREAMBLE`. Without this, every post-parallel checkpoint and `CheckpointCompleted` event payload carries the full per-branch preamble map (a `summary:high` preamble embeds up to 50 lines of every command output — multiplied per branch). + +3. **`lib/crates/fabro-workflow/src/lifecycle/fidelity.rs`** — in `before_node`: + - Set the stash key to `Null` on `state.context` **first**, before the two fallible `resolve_*` calls, so the always-overwritten invariant holds on every early-return path. + - After the existing preamble build, when `gv_node.handler_type() == Some("parallel")`: iterate `self.graph.outgoing_edges(node.id())` in order; per edge resolve explicit fidelity (edge attr → target-node attr → none); apply `degraded()` to explicit values (log when it was `full`); push `Null` for inherit/equal-to-fork, else render `build_preamble(final_fidelity, …)` reusing the already-resolved snapshot (blobs resolved once at `:113-128`) and push the entry. Set the array on the stash key. + - Extract the per-branch resolution as a pure helper beside `resolve_fidelity` (`:206`, same module — no visibility change) for unit testing. + +4. **`lib/crates/fabro-workflow/src/handler/parallel.rs`** — in `execute()`'s branch-setup loop (insert after `:238`, where `INTERNAL_PARALLEL_BRANCH_ID` is set): + - Read the stash from the parent context once before the loop; `None`, `Some(Null)`, or length-mismatch all mean strict legacy behavior (note: `Context::get` returns `Some(Null)` for a Nulled key — both must be treated as absent). + - Per branch with an entry: `branch_context.set(CURRENT_PREAMBLE, preamble)` and `branch_context.set(INTERNAL_FIDELITY, fidelity)`. Downstream needs nothing: `agent.rs:244`/`prompt.rs:63` read `context.preamble()`. + - In **every** branch fork, set the stash key to `Null` — load-bearing, not hygiene: a nested parallel branch target reads its fork's stash, and without the Null it would misinterpret the outer node's array as its own. + - After the loop, set the stash key to `Null` on the handler's own context — the write-back diff (`node_handler.rs:99-105`) clears `state.context` so the post-parallel checkpoint carries Null even before the artifact strip. + +5. **`lib/crates/fabro-validate/src/rules/parallel_branch_inert_attribute.rs`** — drop `"fidelity"` from `BRANCH_IGNORED_ATTRS` and its `fix_message` arm; add a narrow diagnostic in its place: `fidelity="full"` on a fork→branch edge or branch-only node warns "parallel branches run at most at summary:high; full is degraded at runtime because branches cannot share a session". Other fidelity values now lint clean. Update doc comment (the snapshot rationale now applies to `thread_id` only) and tests. + +6. **`lib/crates/fabro-validate/src/rules/thread_id_requires_fidelity_full.rs`** — skip fork→branch edges and branch-only nodes (factor the branch-only detection from rule 5 into a shared helper). Today it tells branch nodes with `thread_id` to *add* `fidelity="full"` — advice that, post-change, would actively alter runtime behavior while the other rule says "remove thread_id". Defer to the inert-attribute rule's guidance on branches. + +7. **Docs** — `docs/public/execution/context.mdx` (fidelity precedence: branch edge → branch node → inherit fork; per-branch preamble rendering; `thread_id` inert in branches; `full` degradation), `docs/public/workflows/stages-and-nodes.mdx` (parallel fan-out section + fidelity attribute notes), `docs/public/reference/dot-language.mdx` (edge/node attr rows). Optional changelog entry via the changelog conventions. + +## Tests + +Per `docs/internal/testing-strategy.md`, preamble content is implementation-facing → `fabro-workflow`, not CLI layers. + +- **Pure unit tests** (`lifecycle/fidelity.rs` tests, beside `resolve_fidelity`'s at `:271-321`): explicit edge > node precedence; no-attr → inherit (no entry); explicit `full` → `summary:high` entry; branch fidelity equal to fork's (post-degradation) → no entry; fork resolved `Full` + no branch attrs → no entries at all. +- **Lifecycle-level**: two consecutive `before_node` calls on the same parallel node rebuild (not merge) the stash; non-parallel node overwrites stash to Null; resume-degrade flag interaction (fork degrades, fallback branches still get no entry). +- **`artifact.rs` tests** (`:519+` pattern): both snapshot functions strip the stash key. +- **Parallel handler unit tests** (`handler/parallel.rs` tests module, `EngineServices::test_default()` + recording handler mirroring `PreambleEchoHandler`, `manager_loop.rs:973-1043`): entry applies `CURRENT_PREAMBLE`/`INTERNAL_FIDELITY` to the right branch by index; stash Null in every branch fork; `Some(Null)`/absent/length-mismatch → legacy; duplicate-target edges get distinct entries at indices 0/1 (no-git test — a pre-existing worktree-name collision exists for that topology, don't let it pollute the assertion); existing tests stay unmodified as the legacy guard. +- **Engine-level regression** in `lib/crates/fabro-workflow/tests/it/integration.rs` beside the `fidelity_prompt_*` tests (`:9245-9479`), reusing `FidelityCapturingHandler` (`:4917-4974`) and the `end_to_end_parallel_fan_out_fan_in` scaffold (`:2441-2480`) via `WorkflowRunner::run_with_state`: + - **Probe A analog** (`parallel_branches_get_per_branch_preambles_by_fidelity`): seed sets a context marker → fork → `branch_a` (`fidelity="truncate"`) + `branch_b` (default) → fan-in. Assert branch_a's preamble is goal-only (no marker) while branch_b's contains the marker. + - **Probe B analog**: `fidelity="truncate"` on the fork only → both branches goal-only (compat guarantee, unchanged behavior). + - Edge-attr-beats-node-attr variant. +- **Lint tests**: no warning for non-full branch fidelity; warning for branch `fidelity="full"`; `thread_id_requires_fidelity_full` silent on branch-only nodes, still firing elsewhere. + +## Verification + +- `cargo nextest run -p fabro-workflow -p fabro-validate`, then `ulimit -n 4096 && cargo nextest run --workspace` (do not export `FORCE_COLOR`). +- `cargo +nightly-2026-04-14 fmt --check --all`; `cargo +nightly-2026-04-14 clippy --workspace --all-targets -- -D warnings`. +- Live confirmation on the testing server: re-run the two probe workflows (session scratchpad `probes/isolation-a`, `probes/isolation-b`) against a locally built binary — probe A's `stage.prompt` events must now show differentiated branch preambles; probe B byte-identical to before. + +## Compatibility + +| Situation | Impact | +|---|---| +| No fidelity attrs near the parallel node | None — byte-identical (inherit path, no re-render) | +| Fidelity on the fork node / its incoming edge | None — fork snapshot semantics unchanged | +| Previously-dead attrs on branch nodes / fork→branch edges | Start working (the fix) | +| `full` on a branch | Degrades to `summary:high` + log + lint warning | +| `thread_id` on a branch | Still inert; lint still warns; the contradictory companion lint goes quiet on branches | + +## Decisions (user-confirmed 2026-07-23) + +1. **`CURRENT_NODE` in branch contexts stays inherited** — the branch-scoped bookkeeping change is deferred to a dedicated event-attribution change. +2. **The narrow `fidelity="full"` branch lint is in scope** (step 5 stands as written). +3. **No changelog entry in this PR** — changelog handled in the usual batch. + + +## Completed stages +- **toolchain**: succeeded + - Script: `command -v cargo >/dev/null || { curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y && sudo ln -sf $HOME/.cargo/bin/* /usr/local/bin/; }; cargo --version 2>&1` + - Output: + ``` + cargo 1.95.0 (f2d3ce0bd 2026-03-21) + ``` +- **preflight_compile**: succeeded + - Script: `cargo check -q --workspace 2>&1` + - Output: (empty) +- **preflight_lint**: succeeded + - Script: `cargo +nightly-2026-04-14 clippy -q --workspace --all-targets -- -D warnings 2>&1` + - Output: (empty) +- **implement**: succeeded + - Model: openai/gpt-5.6-sol, 394.2k tokens in / 64.5k out + - Files: /home/daytona/workspace/fabro/docs/public/execution/context.mdx, /home/daytona/workspace/fabro/docs/public/reference/dot-language.mdx, /home/daytona/workspace/fabro/docs/public/workflows/stages-and-nodes.mdx, /home/daytona/workspace/fabro/lib/crates/fabro-validate/src/rules/mod.rs, /home/daytona/workspace/fabro/lib/crates/fabro-validate/src/rules/parallel_branch.rs, /home/daytona/workspace/fabro/lib/crates/fabro-validate/src/rules/parallel_branch_inert_attribute.rs, /home/daytona/workspace/fabro/lib/crates/fabro-validate/src/rules/thread_id_requires_fidelity_full.rs, /home/daytona/workspace/fabro/lib/crates/fabro-workflow/src/artifact.rs, /home/daytona/workspace/fabro/lib/crates/fabro-workflow/src/context.rs, /home/daytona/workspace/fabro/lib/crates/fabro-workflow/src/handler/parallel.rs, /home/daytona/workspace/fabro/lib/crates/fabro-workflow/src/lifecycle/event.rs, /home/daytona/workspace/fabro/lib/crates/fabro-workflow/src/lifecycle/fidelity.rs, /home/daytona/workspace/fabro/lib/crates/fabro-workflow/tests/it/integration.rs + + +# Simplify: Code Review and Cleanup + +Review all changed files for reuse, quality, and efficiency. Fix any issues found. + +## Phase 1: Identify Changes + +Run \`git diff\` (or \`git diff HEAD\` if there are staged changes) to see what changed. If there are no git changes, review the most recently modified files that the user mentioned or that you edited earlier in this conversation. + +## Phase 2: Launch Three Review Agents in Parallel + +Use the ${AGENT_TOOL_NAME} tool to launch all three agents concurrently in a single message. Pass each agent the full diff so it has the complete context. + +### Agent 1: Code Reuse Review + +For each change: + +1. **Search for existing utilities and helpers** that could replace newly written code. Look for similar patterns elsewhere in the codebase — common locations are utility directories, shared modules, and files adjacent to the changed ones. +2. **Flag any new function that duplicates existing functionality.** Suggest the existing function to use instead. +3. **Flag any inline logic that could use an existing utility** — hand-rolled string manipulation, manual path handling, custom environment checks, ad-hoc type guards, and similar patterns are common candidates. + +### Agent 2: Code Quality Review + +Review the same changes for hacky patterns: + +1. **Redundant state**: state that duplicates existing state, cached values that could be derived, observers/effects that could be direct calls +2. **Parameter sprawl**: adding new parameters to a function instead of generalizing or restructuring existing ones +3. **Copy-paste with slight variation**: near-duplicate code blocks that should be unified with a shared abstraction +4. **Leaky abstractions**: exposing internal details that should be encapsulated, or breaking existing abstraction boundaries +5. **Stringly-typed code**: using raw strings where constants, enums (string unions), or branded types already exist in the codebase +6. **Unnecessary JSX nesting**: wrapper Boxes/elements that add no layout value — check if inner component props (flexShrink, alignItems, etc.) already provide the needed behavior +7. **Unnecessary comments**: comments explaining WHAT the code does (well-named identifiers already do that), narrating the change, or referencing the task/caller — delete; keep only non-obvious WHY (hidden constraints, subtle invariants, workarounds) + +### Agent 3: Efficiency Review + +Review the same changes for efficiency: + +1. **Unnecessary work**: redundant computations, repeated file reads, duplicate network/API calls, N+1 patterns +2. **Missed concurrency**: independent operations run sequentially when they could run in parallel +3. **Hot-path bloat**: new blocking work added to startup or per-request/per-render hot paths +4. **Recurring no-op updates**: state/store updates inside polling loops, intervals, or event handlers that fire unconditionally — add a change-detection guard so downstream consumers aren't notified when nothing changed. Also: if a wrapper function takes an updater/reducer callback, verify it honors same-reference returns (or whatever the "no change" signal is) — otherwise callers' early-return no-ops are silently defeated +5. **Unnecessary existence checks**: pre-checking file/resource existence before operating (TOCTOU anti-pattern) — operate directly and handle the error +6. **Memory**: unbounded data structures, missing cleanup, event listener leaks +7. **Overly broad operations**: reading entire files when only a portion is needed, loading all items when filtering for one + +## Phase 3: Fix Issues + +Wait for all three agents to complete. Aggregate their findings and fix each issue directly. If a finding is a false positive or not worth addressing, note it and move on — do not argue with the finding, just skip it. + +When done, briefly summarize what was fixed (or confirm the code was already clean). diff --git a/stages/006-simplify_fable@1/provider_used.json b/stages/006-simplify_fable@1/provider_used.json new file mode 100644 index 000000000..84b808d90 --- /dev/null +++ b/stages/006-simplify_fable@1/provider_used.json @@ -0,0 +1,6 @@ +{ + "mode": "agent", + "provider": "openrouter", + "model": "anthropic/claude-fable-5", + "reasoning_effort": "xhigh" +} \ No newline at end of file diff --git a/stages/006-simplify_fable@1/response.md b/stages/006-simplify_fable@1/response.md new file mode 100644 index 000000000..4320c75f0 --- /dev/null +++ b/stages/006-simplify_fable@1/response.md @@ -0,0 +1,35 @@ +Everything is green. Summary of the simplify stage: + +## Review findings applied + +**Wire-format root cause (reuse #1, quality #1/#2, efficiency #2/#3):** +- Added `Serialize`/`Deserialize` to `fabro_graphviz::Fidelity` (serde renames aligned with strum per CLAUDE.md, plus `Hash` and a serde↔strum parity test; `serde_json` added as dev-dependency). +- Replaced the hand-rolled JSON producer/parser pair with one shared `ParallelBranchPreamble` struct in `context.rs` (`deny_unknown_fields` replaces the brittle `entry.len() == 2` check). Producer uses `serde_json::to_value`, consumer uses `from_value` — a field rename can no longer silently split the contract. Documented the index-correlation invariant and the `Option>` semantics (quality #5/#8). + +**Duplication (reuse #2/#3, quality #6/#7):** +- Extracted `explicit_fidelity()` — the edge→node precedence core is now shared between `resolve_fidelity` and `resolve_parallel_branch_fidelity`. +- Added `keys::TRANSIENT_CONTEXT_KEYS` + `artifact::strip_transient_keys()`; both artifact strip sites use it. `stage_context_values` keeps its intentionally different behavior (retains `CURRENT_PREAMBLE`) with a WHY comment. +- Unified the two copy-pasted full-fidelity diagnostic blocks into `full_fidelity_diagnostic()`; `full_fidelity_message()` → `const`; dead `fix_message` match → `const THREAD_ID_FIX`; capitalized the message for consistency; reworded the "now honors" changelog-style doc comment. + +**Structure & style (quality #3/#4/#9/#10/#14):** +- Extracted the 38-line parallel stash block into `build_parallel_branch_preambles()`, restoring `before_node`'s numbered-step narrative (steps renumbered 5–9). +- `ParallelBranchFidelityResolution`: dropped accessor ceremony and unused derives; plain documented fields. +- Fixed the warn! to log `effective_fidelity` via `degraded()` instead of dressed-up constants; removed the redundant `fidelity` field. +- `parallel.rs` uses `keys::`-scoped types; test helper `fidelity_attr` → `str_attr`. + +**Efficiency (#1, #4):** +- Moved the parent stash clear to *before* the fork loop — branch forks no longer copy the full preamble array B times just to null it, and the per-branch null-set became unnecessary (forks inherit Null; nested-parallel safety preserved, verified by the existing `stash == Null` test assertions). +- Memoized per-fidelity preamble renders — duplicate branch fidelities render once. + +**Validate helper (reuse #4, quality #13, efficiency #6):** +- `ParallelBranches` now uses `Graph::incoming_edges`; `is_branch_only_node` is allocation-free; `branch_only_parents` replaced the dense peekable/`then`/`collect::>` chain with straightforward code. Added the cross-rule suppression WHY comment in `thread_id_requires_fidelity_full`. + +**Skipped (noted, not addressed):** test-fixture consolidation into `rules/test_support` and a shared in-memory-store test helper (pre-existing pattern across ~15 sites, out of scope); `"parallel"` string literal (codebase-wide convention); `make_run_options` parameterization (follows existing file precedent). + +## Verification +- `cargo nextest run -p fabro-graphviz -p fabro-validate -p fabro-workflow` — all pass (302 + 1218) +- `cargo nextest run --workspace` — **7064 passed, 0 failed** +- `cargo +nightly-2026-04-14 fmt --check --all` — clean +- `cargo +nightly-2026-04-14 clippy --workspace --all-targets -- -D warnings` — clean + +No blockers. \ No newline at end of file