From 25e43fd1794e43ba6dd01e469a4a8ffd83a2aafb Mon Sep 17 00:00:00 2001 From: Fabro Date: Fri, 24 Jul 2026 10:14:37 +0000 Subject: [PATCH] =?UTF-8?q?finalize=20run=20=E2=9A=92=EF=B8=8F=20Generated?= =?UTF-8?q?=20with=20[Fabro](https://fabro.sh)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- run.json | 741 +- stages/006-simplify_fable@1/diff.patch | 8178 ++++++++++++++++++ stages/006-simplify_fable@1/response.md | 32 + stages/006-simplify_fable@1/status.json | 6 + stages/007-simplify_sol@1/prompt.md | 188 + stages/007-simplify_sol@1/provider_used.json | 6 + 6 files changed, 8996 insertions(+), 155 deletions(-) create mode 100644 stages/006-simplify_fable@1/diff.patch create mode 100644 stages/006-simplify_fable@1/response.md create mode 100644 stages/006-simplify_fable@1/status.json create mode 100644 stages/007-simplify_sol@1/prompt.md create mode 100644 stages/007-simplify_sol@1/provider_used.json diff --git a/run.json b/run.json index 0050bf597..4c7d5bbe9 100644 --- a/run.json +++ b/run.json @@ -478,7 +478,7 @@ "kind": "running" }, "status_updated_at": "2026-07-24T03:21:27.102803308Z", - "last_event_at": "2026-07-24T06:44:55.406982182Z", + "last_event_at": "2026-07-24T10:14:35.586817833Z", "pending_control": null, "checkpoints": [ { @@ -964,9 +964,9 @@ } }, { - "seq": 0, + "seq": 11712, "checkpoint": { - "timestamp": "2026-07-24T06:45:17.451168078Z", + "timestamp": "2026-07-24T06:45:20.257501511Z", "current_node": "simplify_fable", "completed_nodes": [ "start", @@ -978,145 +978,34 @@ ], "node_retries": {}, "context_values": { - "internal.fidelity": "compact", - "command.output": "blob://sha256/12ae32cb1ec02d01eda3581b127c1fee3b0dc53572ed6baf239721a03d82e126", "thread.toolchain.current_node": "preflight_compile", - "internal.retry_count.preflight_compile": 0, - "outcome": "succeeded", + "internal.run_id": "01KY7YH7RYCJ1BDVTTP96ZA4HV", "internal.retry_count.start": 0, - "internal.retry_count.implement": 0, - "thread.preflight_lint.current_node": "implement", + "internal.work_dir": "/home/daytona/workspace/fabro", + "current_node": "simplify_fable", "internal.node_visit_count": 1, - "response.implement": "Workflow complete. All required implementation and verification steps passed; no blockers remain.", - "internal.thread_id": null, + "internal.retry_count.simplify_fable": 0, "thread.start.current_node": "toolchain", - "graph.rankdir": "LR", + "graph.goal": "\n# Shared-Checkout Parallel Execution\n\n## Summary\n\nSimplify parallel execution so every branch:\n\n- Receives an isolated context fork.\n- Executes concurrently against the same sandbox and Git checkout.\n- Creates no Git branch, worktree, checkpoint, or branch commit.\n- Always waits for every branch; remove `first_success` and `join_policy`.\n- Produces a collision-free result containing its context updates.\n\nFan-in remains an explicit join node. Without a prompt it is a no-op barrier; with a prompt it behaves like a standard prompt stage that reduces the aggregated results without selecting a branch.\n\n## Implementation Changes\n\n### Parallel execution\n\n- Remove all parallel-specific Git setup, checkpointing, committing, cleanup, and fast-forwarding.\n- Give each branch the same `Arc` and inherited `internal.work_dir`.\n- Retain `max_parallel`, branch-scoped contexts, IDs, hooks, and lifecycle events.\n- Preserve outgoing-edge order in the final results regardless of completion order.\n- Define the parallel outcome as:\n - `succeeded` when every branch succeeds.\n - `failed` when every branch fails.\n - `partially_succeeded` for every mixed or partial result.\n- Preserve branch ID/index when handler or task execution fails.\n- Keep the current single-node-per-branch topology.\n\n### Results and fan-in\n\nIntroduce a shared typed result:\n\n```rust\nParallelBranchResult {\n id: String,\n status: String,\n context_updates: BTreeMap,\n}\n```\n\n- Store the ordered array in `parallel.results` and keep `parallel.branch_count`.\n- Include updates from successful and failed branches; never merge them into the parent context at top level.\n- Offload large leaf values using existing artifact storage while keeping `parallel.results` itself a structured array.\n- Resolve nested textual `response.*` and `command.output` blob references when building downstream prompt context.\n- Emit the same typed results through `parallel.completed` and project them into `StageProjection.parallel_results`.\n- A promptless fan-in verifies results exist and succeeds with a joined-branches note.\n- A prompted fan-in uses standard prompt execution and records `response.`, `last_response`, usage, and prompt events. It performs no ranking or selection.\n\nRemove:\n\n- Heuristic and LLM branch-selection code.\n- `parallel.fan_in.best_id`, `best_outcome`, and `best_head_sha`.\n- Per-result `head_sha` and `score`.\n- The special fan-in model-usage mode.\n- Fan-in selected-branch notes and UI.\n\n### Delete obsolete Git/worktree infrastructure\n\n- Delete the parallel-only `WorktreeSandbox`, its options/callbacks, exports, tests, and sandbox trait path helpers.\n- Remove `EngineServices`’ parallel-only `GitState` and pipeline wiring.\n- Remove now-unused branch/worktree/fast-forward Git helpers and parallel ref sanitization.\n- Remove the parallel-base-checkpoint notice code.\n- Remove the now-unemitted `git.branch`, `git.worktree.added`, and `git.worktree.removed` event variants and property types.\n- Leave the server’s independent Git checkout/worktree implementation unchanged.\n- Continue normal run-level checkpointing after the parallel node; any shared workspace changes are captured together.\n\n## Public Interfaces and Documentation\n\n- Remove `join_policy` from DOT documentation, examples, fixtures, events, and UI.\n- Add a validation error directing users to remove any `join_policy` attribute.\n- Update `parallel.started` to contain only visit and branch count.\n- Update `parallel.branch.completed` to remove `head_sha`.\n- Define `ParallelBranchResult` in OpenAPI and change `StageProjection.parallel_results` from untyped objects to that typed array; regenerate Rust and TypeScript clients.\n- Update the parallel UI to show branch status and links without commit SHAs or a join-policy badge.\n- Replace the fan-in trophy/selection UI with joined-state information and the optional standard reducer transcript.\n- Rewrite the active parallel strategy and public docs around shared-checkout concurrency:\n - Read-only behavior is best effort.\n - Concurrent writes are allowed but entirely user-managed.\n - Fabro performs no write enforcement, detection, or warnings.\n - Results are available through `parallel.results`, not a workspace `parallel_results.json`.\n - A fan-in prompt synthesizes results but never selects workspace state.\n- Remove `join_policy` from all checked-in tutorial/demo workflows and generated documentation fixtures.\n\n## Test Plan\n\n- Unit-test that every branch receives the same sandbox working directory while retaining independent contexts.\n- Verify deterministic result ordering and complete per-branch context updates, including failed branches and structured/command outputs.\n- Cover all-success, mixed, partial, all-failed, zero-branch, `max_parallel`, dry-run, and run-cancellation behavior.\n- In a temporary Git repository, run parallel branches that write distinct files and assert:\n - Both files remain in the shared checkout.\n - No `fabro/run/parallel/*` refs exist.\n - No parallel worktrees, branch commits, worktree events, or fast-forward commands occur.\n- Test promptless fan-in as a no-op join and prompted fan-in as a standard reducer that sees every branch result and emits a normal response.\n- Update event serialization, store projection, API round-trip, web parser, and renderer tests for the new typed payloads and removed fields.\n- Remove the obsolete host and Daytona parallel-Git-selection tests; retain provider-independent shared-sandbox coverage.\n- Verify with:\n - `cargo build -p fabro-api`\n - `cd lib/packages/fabro-api-client && bun run generate && bun run typecheck`\n - `cargo build --workspace`\n - `ulimit -n 4096 && cargo nextest run --workspace`\n - `cargo +nightly-2026-04-14 fmt --check --all`\n - `cargo +nightly-2026-04-14 clippy --workspace --all-targets -- -D warnings`\n - `cd apps/fabro-web && bun test && bun run typecheck && bun run build`\n - `cargo --locked dev docs check`\n\n## Assumptions and Unresolved Questions\n\n- The application is greenfield; removed fields, events, and attributes receive no compatibility layer.\n- Parallel branches remain single-node executions.\n- `max_parallel` remains supported.\n- Shared-workspace write races are explicitly the user’s responsibility.\n- No Git workspace warning or read-only enforcement will be added.\n- Unresolved questions: none.\n\n", + "internal.fidelity": "compact", + "internal.retry_count.toolchain": 0, + "thread.preflight_compile.current_node": "preflight_lint", + "command.output": "blob://sha256/12ae32cb1ec02d01eda3581b127c1fee3b0dc53572ed6baf239721a03d82e126", + "last_stage": "simplify_fable", "internal.retry_count.preflight_lint": 0, "response.simplify_fable": "Review complete. Three parallel review agents (reuse, quality, efficiency) analyzed the shared-checkout parallel execution diff, and I fixed the verified findings.\n\n## Fixed\n\n**Correctness (high)**\n- **Old stored events failed to deserialize**: `ParallelBranchResult.context_updates` was a required serde field, so pre-change `parallel.completed` events (which lacked it) would hard-fail event replay. Added `#[serde(default)]`.\n\n**Stringly-typed status → `StageOutcome` (medium)**\n- `ParallelBranchResult.status`, `ParallelBranchCompletedProps.status`, the workflow `Event` variant, and the CLI `ProgressEvent` all carried `String` where the existing `StageOutcome` enum (same serde strings, existing OpenAPI schema + build.rs replacement) applied. Typed them end-to-end: OpenAPI now `$ref`s `StageOutcome`, regenerated the TS client, and `run_state.rs` dropped its parse-back `from_str`. This also eliminated the mixed `result.status == \"failed\"` / `failure_category()` predicate in the reap loop.\n\n**Payload lifecycle efficiency (high/medium)**\n- **Offload before emit**: `parallel.completed` previously carried raw (un-offloaded) branch results into the event log, projection, and per-event projection-cache clones. The handler now runs the new `artifact::offload_parallel_branch_updates` on the typed results before emitting/returning them.\n- **Removed the double-apply**: dropped the handler's direct `context.apply_updates(...)` (updates flow only through `outcome.context_updates`, like every other handler), which also kept raw results out of `StageCompleted.context_values`.\n- **Reduced per-branch copies**: parent snapshot taken once and shared via `Arc` (was 3 full context clones per branch), `graph` cloned once into an `Arc`, results moved instead of cloned into the event, removed the no-op `sort_by_key`, and `materialize_blob_ref` now checks file existence before reading the blob (content-addressed, so safe). Added a cheap size fast-path to `offload_value` for short strings.\n\n**Code reuse / structure**\n- `EngineServices` now derives `Clone` — deleted the field-by-field reconstruction in the branch task.\n- Extracted shared `context_diff` into `context.rs` (was duplicated in `manager_loop` and reimplemented in `parallel.rs`), taking `after` by value to move entries.\n- Added `WorkflowContext::parsed_run_id()` replacing identical 4-line parse blocks in `parallel.rs` and `agent.rs`.\n- Replaced the 5-tuple `handles` collection with a named `BranchDispatch` struct; unified simulated/normal notes with a prefix variable.\n- Unified `FanInHandler::simulate`/`execute` copy-paste twins into `run_join(..., simulated)`; fan-in now validates result shape via a slim deserialize instead of cloning the full hydrated payload it never used.\n- Fixed the stale `stage_scope.rs` doc referencing removed worktree/`GitCommit` behavior and renamed the change-narrating `..._omits_git_state_...` test.\n\n**Web**\n- Deleted dead `extractStageNotes` and `asUnknownRecord` (production callers removed by this change) plus their tests.\n- `parallel-children.tsx` now reuses shared `stageStatusTone`/`stageStatusLabel` from `stage-sidebar` (dropping its parallel tone/label maps with the impossible `cancelled` key) via a local `BranchRow` view type; `parseParallelOverview` validates statuses against the typed `StageOutcome` enum.\n- New tests use the existing `setupReactTestEnv` (with teardown) and a new shared `makeEventEnvelope` fixture builder in `test-utils`.\n\n## Validated\n`cargo build -p fabro-api` + API round-trip tests, TS client regen + typecheck, `cargo nextest run --workspace` (7,007 passed), `fmt --check`, `clippy -D warnings`, web `bun test` (679 passed) + typecheck + build, and `cargo dev docs check` — all clean.\n\nNoted but intentionally skipped: per-run blob memoization across resolution passes and concurrent blob resolution (worthwhile but a larger cross-cutting change beyond this cleanup's scope), and the negative string-absence assertions in new tests (harmless short-lived regression guards).", - "internal.retry_count.simplify_fable": 0, - "internal.retry_count.toolchain": 0, - "graph.goal": "\n# Shared-Checkout Parallel Execution\n\n## Summary\n\nSimplify parallel execution so every branch:\n\n- Receives an isolated context fork.\n- Executes concurrently against the same sandbox and Git checkout.\n- Creates no Git branch, worktree, checkpoint, or branch commit.\n- Always waits for every branch; remove `first_success` and `join_policy`.\n- Produces a collision-free result containing its context updates.\n\nFan-in remains an explicit join node. Without a prompt it is a no-op barrier; with a prompt it behaves like a standard prompt stage that reduces the aggregated results without selecting a branch.\n\n## Implementation Changes\n\n### Parallel execution\n\n- Remove all parallel-specific Git setup, checkpointing, committing, cleanup, and fast-forwarding.\n- Give each branch the same `Arc` and inherited `internal.work_dir`.\n- Retain `max_parallel`, branch-scoped contexts, IDs, hooks, and lifecycle events.\n- Preserve outgoing-edge order in the final results regardless of completion order.\n- Define the parallel outcome as:\n - `succeeded` when every branch succeeds.\n - `failed` when every branch fails.\n - `partially_succeeded` for every mixed or partial result.\n- Preserve branch ID/index when handler or task execution fails.\n- Keep the current single-node-per-branch topology.\n\n### Results and fan-in\n\nIntroduce a shared typed result:\n\n```rust\nParallelBranchResult {\n id: String,\n status: String,\n context_updates: BTreeMap,\n}\n```\n\n- Store the ordered array in `parallel.results` and keep `parallel.branch_count`.\n- Include updates from successful and failed branches; never merge them into the parent context at top level.\n- Offload large leaf values using existing artifact storage while keeping `parallel.results` itself a structured array.\n- Resolve nested textual `response.*` and `command.output` blob references when building downstream prompt context.\n- Emit the same typed results through `parallel.completed` and project them into `StageProjection.parallel_results`.\n- A promptless fan-in verifies results exist and succeeds with a joined-branches note.\n- A prompted fan-in uses standard prompt execution and records `response.`, `last_response`, usage, and prompt events. It performs no ranking or selection.\n\nRemove:\n\n- Heuristic and LLM branch-selection code.\n- `parallel.fan_in.best_id`, `best_outcome`, and `best_head_sha`.\n- Per-result `head_sha` and `score`.\n- The special fan-in model-usage mode.\n- Fan-in selected-branch notes and UI.\n\n### Delete obsolete Git/worktree infrastructure\n\n- Delete the parallel-only `WorktreeSandbox`, its options/callbacks, exports, tests, and sandbox trait path helpers.\n- Remove `EngineServices`’ parallel-only `GitState` and pipeline wiring.\n- Remove now-unused branch/worktree/fast-forward Git helpers and parallel ref sanitization.\n- Remove the parallel-base-checkpoint notice code.\n- Remove the now-unemitted `git.branch`, `git.worktree.added`, and `git.worktree.removed` event variants and property types.\n- Leave the server’s independent Git checkout/worktree implementation unchanged.\n- Continue normal run-level checkpointing after the parallel node; any shared workspace changes are captured together.\n\n## Public Interfaces and Documentation\n\n- Remove `join_policy` from DOT documentation, examples, fixtures, events, and UI.\n- Add a validation error directing users to remove any `join_policy` attribute.\n- Update `parallel.started` to contain only visit and branch count.\n- Update `parallel.branch.completed` to remove `head_sha`.\n- Define `ParallelBranchResult` in OpenAPI and change `StageProjection.parallel_results` from untyped objects to that typed array; regenerate Rust and TypeScript clients.\n- Update the parallel UI to show branch status and links without commit SHAs or a join-policy badge.\n- Replace the fan-in trophy/selection UI with joined-state information and the optional standard reducer transcript.\n- Rewrite the active parallel strategy and public docs around shared-checkout concurrency:\n - Read-only behavior is best effort.\n - Concurrent writes are allowed but entirely user-managed.\n - Fabro performs no write enforcement, detection, or warnings.\n - Results are available through `parallel.results`, not a workspace `parallel_results.json`.\n - A fan-in prompt synthesizes results but never selects workspace state.\n- Remove `join_policy` from all checked-in tutorial/demo workflows and generated documentation fixtures.\n\n## Test Plan\n\n- Unit-test that every branch receives the same sandbox working directory while retaining independent contexts.\n- Verify deterministic result ordering and complete per-branch context updates, including failed branches and structured/command outputs.\n- Cover all-success, mixed, partial, all-failed, zero-branch, `max_parallel`, dry-run, and run-cancellation behavior.\n- In a temporary Git repository, run parallel branches that write distinct files and assert:\n - Both files remain in the shared checkout.\n - No `fabro/run/parallel/*` refs exist.\n - No parallel worktrees, branch commits, worktree events, or fast-forward commands occur.\n- Test promptless fan-in as a no-op join and prompted fan-in as a standard reducer that sees every branch result and emits a normal response.\n- Update event serialization, store projection, API round-trip, web parser, and renderer tests for the new typed payloads and removed fields.\n- Remove the obsolete host and Daytona parallel-Git-selection tests; retain provider-independent shared-sandbox coverage.\n- Verify with:\n - `cargo build -p fabro-api`\n - `cd lib/packages/fabro-api-client && bun run generate && bun run typecheck`\n - `cargo build --workspace`\n - `ulimit -n 4096 && cargo nextest run --workspace`\n - `cargo +nightly-2026-04-14 fmt --check --all`\n - `cargo +nightly-2026-04-14 clippy --workspace --all-targets -- -D warnings`\n - `cd apps/fabro-web && bun test && bun run typecheck && bun run build`\n - `cargo --locked dev docs check`\n\n## Assumptions and Unresolved Questions\n\n- The application is greenfield; removed fields, events, and attributes receive no compatibility layer.\n- Parallel branches remain single-node executions.\n- `max_parallel` remains supported.\n- Shared-workspace write races are explicitly the user’s responsibility.\n- No Git workspace warning or read-only enforcement will be added.\n- Unresolved questions: none.\n\n", - "internal.work_dir": "/home/daytona/workspace/fabro", - "last_stage": "simplify_fable", - "thread.preflight_compile.current_node": "preflight_lint", - "failure_class": "", - "current_node": "simplify_fable", - "last_response": "Review complete. Three parallel review agents (reuse, quality, efficiency) analyzed the shared-checkout parallel execution diff, and I fixed the verified findings.\n\n## Fixed\n\n**Correctness (high)**\n- ", + "internal.retry_count.preflight_compile": 0, + "internal.thread_id": null, + "graph.rankdir": "LR", + "outcome": "succeeded", + "response.implement": "Workflow complete. All required implementation and verification steps passed; no blockers remain.", "failure_signature": "", - "internal.run_id": "01KY7YH7RYCJ1BDVTTP96ZA4HV" + "last_response": "Review complete. Three parallel review agents (reuse, quality, efficiency) analyzed the shared-checkout parallel execution diff, and I fixed the verified findings.\n\n## Fixed\n\n**Correctness (high)**\n- ", + "thread.preflight_lint.current_node": "implement", + "internal.retry_count.implement": 0, + "failure_class": "" }, "node_outcomes": { - "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": 137028, - "active_time_ms": 137028 - } - }, - "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": 165282, - "active_time_ms": 165282 - } - }, - "simplify_fable": { - "status": "succeeded", - "context_updates": { - "last_stage": "simplify_fable", - "last_response": "Review complete. Three parallel review agents (reuse, quality, efficiency) analyzed the shared-checkout parallel execution diff, and I fixed the verified findings.\n\n## Fixed\n\n**Correctness (high)**\n- ", - "response.simplify_fable": "Review complete. Three parallel review agents (reuse, quality, efficiency) analyzed the shared-checkout parallel execution diff, and I fixed the verified findings.\n\n## Fixed\n\n**Correctness (high)**\n- **Old stored events failed to deserialize**: `ParallelBranchResult.context_updates` was a required serde field, so pre-change `parallel.completed` events (which lacked it) would hard-fail event replay. Added `#[serde(default)]`.\n\n**Stringly-typed status → `StageOutcome` (medium)**\n- `ParallelBranchResult.status`, `ParallelBranchCompletedProps.status`, the workflow `Event` variant, and the CLI `ProgressEvent` all carried `String` where the existing `StageOutcome` enum (same serde strings, existing OpenAPI schema + build.rs replacement) applied. Typed them end-to-end: OpenAPI now `$ref`s `StageOutcome`, regenerated the TS client, and `run_state.rs` dropped its parse-back `from_str`. This also eliminated the mixed `result.status == \"failed\"` / `failure_category()` predicate in the reap loop.\n\n**Payload lifecycle efficiency (high/medium)**\n- **Offload before emit**: `parallel.completed` previously carried raw (un-offloaded) branch results into the event log, projection, and per-event projection-cache clones. The handler now runs the new `artifact::offload_parallel_branch_updates` on the typed results before emitting/returning them.\n- **Removed the double-apply**: dropped the handler's direct `context.apply_updates(...)` (updates flow only through `outcome.context_updates`, like every other handler), which also kept raw results out of `StageCompleted.context_values`.\n- **Reduced per-branch copies**: parent snapshot taken once and shared via `Arc` (was 3 full context clones per branch), `graph` cloned once into an `Arc`, results moved instead of cloned into the event, removed the no-op `sort_by_key`, and `materialize_blob_ref` now checks file existence before reading the blob (content-addressed, so safe). Added a cheap size fast-path to `offload_value` for short strings.\n\n**Code reuse / structure**\n- `EngineServices` now derives `Clone` — deleted the field-by-field reconstruction in the branch task.\n- Extracted shared `context_diff` into `context.rs` (was duplicated in `manager_loop` and reimplemented in `parallel.rs`), taking `after` by value to move entries.\n- Added `WorkflowContext::parsed_run_id()` replacing identical 4-line parse blocks in `parallel.rs` and `agent.rs`.\n- Replaced the 5-tuple `handles` collection with a named `BranchDispatch` struct; unified simulated/normal notes with a prefix variable.\n- Unified `FanInHandler::simulate`/`execute` copy-paste twins into `run_join(..., simulated)`; fan-in now validates result shape via a slim deserialize instead of cloning the full hydrated payload it never used.\n- Fixed the stale `stage_scope.rs` doc referencing removed worktree/`GitCommit` behavior and renamed the change-narrating `..._omits_git_state_...` test.\n\n**Web**\n- Deleted dead `extractStageNotes` and `asUnknownRecord` (production callers removed by this change) plus their tests.\n- `parallel-children.tsx` now reuses shared `stageStatusTone`/`stageStatusLabel` from `stage-sidebar` (dropping its parallel tone/label maps with the impossible `cancelled` key) via a local `BranchRow` view type; `parseParallelOverview` validates statuses against the typed `StageOutcome` enum.\n- New tests use the existing `setupReactTestEnv` (with teardown) and a new shared `makeEventEnvelope` fixture builder in `test-utils`.\n\n## Validated\n`cargo build -p fabro-api` + API round-trip tests, TS client regen + typecheck, `cargo nextest run --workspace` (7,007 passed), `fmt --check`, `clippy -D warnings`, web `bun test` (679 passed) + typecheck + build, and `cargo dev docs check` — all clean.\n\nNoted but intentionally skipped: per-run blob memoization across resolution passes and concurrent blob resolution (worthwhile but a larger cross-cutting change beyond this cleanup's scope), and the negative string-absence assertions in new tests (harmless short-lived regression guards)." - }, - "notes": "Stage completed: simplify_fable", - "usage": { - "input": { - "usage": { - "model": { - "provider": "openrouter", - "model_id": "anthropic/claude-fable-5" - }, - "tokens": { - "input_tokens": 166817, - "output_tokens": 344509, - "reasoning_tokens": 117120, - "cache_read_tokens": 30620801, - "cache_write_tokens": 382636 - } - }, - "facts": { - "algorithm": "anthropic", - "cache_write_5m_tokens": 382636, - "cache_write_1h_tokens": 0 - } - }, - "total_usd_micros": 60153414 - }, - "files_touched": [ - "/home/daytona/workspace/fabro/apps/fabro-web/app/components/stage-renderers/fan-in-results.test.tsx", - "/home/daytona/workspace/fabro/apps/fabro-web/app/components/stage-renderers/helpers.test.ts", - "/home/daytona/workspace/fabro/apps/fabro-web/app/components/stage-renderers/helpers.ts", - "/home/daytona/workspace/fabro/apps/fabro-web/app/components/stage-renderers/parallel-children.test.tsx", - "/home/daytona/workspace/fabro/apps/fabro-web/app/components/stage-renderers/parallel-children.tsx", - "/home/daytona/workspace/fabro/apps/fabro-web/app/lib/test-utils.tsx", - "/home/daytona/workspace/fabro/docs/public/api-reference/fabro-api.yaml", - "/home/daytona/workspace/fabro/lib/crates/fabro-cli/src/commands/run/run_progress/event.rs", - "/home/daytona/workspace/fabro/lib/crates/fabro-cli/src/commands/run/run_progress/mod.rs", - "/home/daytona/workspace/fabro/lib/crates/fabro-cli/src/commands/run/run_progress/stage_display.rs", - "/home/daytona/workspace/fabro/lib/crates/fabro-store/src/run_state.rs", - "/home/daytona/workspace/fabro/lib/crates/fabro-types/src/parallel.rs", - "/home/daytona/workspace/fabro/lib/crates/fabro-types/src/run_event/misc.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/event/convert.rs", - "/home/daytona/workspace/fabro/lib/crates/fabro-workflow/src/event/events.rs", - "/home/daytona/workspace/fabro/lib/crates/fabro-workflow/src/git.rs", - "/home/daytona/workspace/fabro/lib/crates/fabro-workflow/src/handler/agent.rs", - "/home/daytona/workspace/fabro/lib/crates/fabro-workflow/src/handler/fan_in.rs", - "/home/daytona/workspace/fabro/lib/crates/fabro-workflow/src/handler/manager_loop.rs", - "/home/daytona/workspace/fabro/lib/crates/fabro-workflow/src/handler/parallel.rs", - "/home/daytona/workspace/fabro/lib/crates/fabro-workflow/src/services.rs", - "/home/daytona/workspace/fabro/lib/crates/fabro-workflow/src/stage_scope.rs", - "/home/daytona/workspace/fabro/lib/crates/fabro-workflow/tests/it/integration.rs" - ], - "timing": { - "wall_time_ms": 0, - "inference_time_ms": 6293405, - "tool_time_ms": 5933474, - "active_time_ms": 12226879 - } - }, - "start": { - "status": "succeeded", - "usage": null - }, - "toolchain": { - "status": "succeeded", - "context_updates": { - "command.output": "blob://sha256/20eeffec02497fbda7b51f51b06fe29c1d639551eee4d5ea9845fc1f86bd77e1" - }, - "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": 1285, - "active_time_ms": 1285 - } - }, "implement": { "status": "succeeded", "context_updates": { @@ -1216,22 +1105,238 @@ "tool_time_ms": 2880638, "active_time_ms": 6286420 } + }, + "simplify_fable": { + "status": "succeeded", + "context_updates": { + "last_stage": "simplify_fable", + "last_response": "Review complete. Three parallel review agents (reuse, quality, efficiency) analyzed the shared-checkout parallel execution diff, and I fixed the verified findings.\n\n## Fixed\n\n**Correctness (high)**\n- ", + "response.simplify_fable": "Review complete. Three parallel review agents (reuse, quality, efficiency) analyzed the shared-checkout parallel execution diff, and I fixed the verified findings.\n\n## Fixed\n\n**Correctness (high)**\n- **Old stored events failed to deserialize**: `ParallelBranchResult.context_updates` was a required serde field, so pre-change `parallel.completed` events (which lacked it) would hard-fail event replay. Added `#[serde(default)]`.\n\n**Stringly-typed status → `StageOutcome` (medium)**\n- `ParallelBranchResult.status`, `ParallelBranchCompletedProps.status`, the workflow `Event` variant, and the CLI `ProgressEvent` all carried `String` where the existing `StageOutcome` enum (same serde strings, existing OpenAPI schema + build.rs replacement) applied. Typed them end-to-end: OpenAPI now `$ref`s `StageOutcome`, regenerated the TS client, and `run_state.rs` dropped its parse-back `from_str`. This also eliminated the mixed `result.status == \"failed\"` / `failure_category()` predicate in the reap loop.\n\n**Payload lifecycle efficiency (high/medium)**\n- **Offload before emit**: `parallel.completed` previously carried raw (un-offloaded) branch results into the event log, projection, and per-event projection-cache clones. The handler now runs the new `artifact::offload_parallel_branch_updates` on the typed results before emitting/returning them.\n- **Removed the double-apply**: dropped the handler's direct `context.apply_updates(...)` (updates flow only through `outcome.context_updates`, like every other handler), which also kept raw results out of `StageCompleted.context_values`.\n- **Reduced per-branch copies**: parent snapshot taken once and shared via `Arc` (was 3 full context clones per branch), `graph` cloned once into an `Arc`, results moved instead of cloned into the event, removed the no-op `sort_by_key`, and `materialize_blob_ref` now checks file existence before reading the blob (content-addressed, so safe). Added a cheap size fast-path to `offload_value` for short strings.\n\n**Code reuse / structure**\n- `EngineServices` now derives `Clone` — deleted the field-by-field reconstruction in the branch task.\n- Extracted shared `context_diff` into `context.rs` (was duplicated in `manager_loop` and reimplemented in `parallel.rs`), taking `after` by value to move entries.\n- Added `WorkflowContext::parsed_run_id()` replacing identical 4-line parse blocks in `parallel.rs` and `agent.rs`.\n- Replaced the 5-tuple `handles` collection with a named `BranchDispatch` struct; unified simulated/normal notes with a prefix variable.\n- Unified `FanInHandler::simulate`/`execute` copy-paste twins into `run_join(..., simulated)`; fan-in now validates result shape via a slim deserialize instead of cloning the full hydrated payload it never used.\n- Fixed the stale `stage_scope.rs` doc referencing removed worktree/`GitCommit` behavior and renamed the change-narrating `..._omits_git_state_...` test.\n\n**Web**\n- Deleted dead `extractStageNotes` and `asUnknownRecord` (production callers removed by this change) plus their tests.\n- `parallel-children.tsx` now reuses shared `stageStatusTone`/`stageStatusLabel` from `stage-sidebar` (dropping its parallel tone/label maps with the impossible `cancelled` key) via a local `BranchRow` view type; `parseParallelOverview` validates statuses against the typed `StageOutcome` enum.\n- New tests use the existing `setupReactTestEnv` (with teardown) and a new shared `makeEventEnvelope` fixture builder in `test-utils`.\n\n## Validated\n`cargo build -p fabro-api` + API round-trip tests, TS client regen + typecheck, `cargo nextest run --workspace` (7,007 passed), `fmt --check`, `clippy -D warnings`, web `bun test` (679 passed) + typecheck + build, and `cargo dev docs check` — all clean.\n\nNoted but intentionally skipped: per-run blob memoization across resolution passes and concurrent blob resolution (worthwhile but a larger cross-cutting change beyond this cleanup's scope), and the negative string-absence assertions in new tests (harmless short-lived regression guards)." + }, + "notes": "Stage completed: simplify_fable", + "usage": { + "input": { + "usage": { + "model": { + "provider": "openrouter", + "model_id": "anthropic/claude-fable-5" + }, + "tokens": { + "input_tokens": 166817, + "output_tokens": 344509, + "reasoning_tokens": 117120, + "cache_read_tokens": 30620801, + "cache_write_tokens": 382636 + } + }, + "facts": { + "algorithm": "anthropic", + "cache_write_5m_tokens": 382636, + "cache_write_1h_tokens": 0 + } + }, + "total_usd_micros": 60153414 + }, + "files_touched": [ + "/home/daytona/workspace/fabro/apps/fabro-web/app/components/stage-renderers/fan-in-results.test.tsx", + "/home/daytona/workspace/fabro/apps/fabro-web/app/components/stage-renderers/helpers.test.ts", + "/home/daytona/workspace/fabro/apps/fabro-web/app/components/stage-renderers/helpers.ts", + "/home/daytona/workspace/fabro/apps/fabro-web/app/components/stage-renderers/parallel-children.test.tsx", + "/home/daytona/workspace/fabro/apps/fabro-web/app/components/stage-renderers/parallel-children.tsx", + "/home/daytona/workspace/fabro/apps/fabro-web/app/lib/test-utils.tsx", + "/home/daytona/workspace/fabro/docs/public/api-reference/fabro-api.yaml", + "/home/daytona/workspace/fabro/lib/crates/fabro-cli/src/commands/run/run_progress/event.rs", + "/home/daytona/workspace/fabro/lib/crates/fabro-cli/src/commands/run/run_progress/mod.rs", + "/home/daytona/workspace/fabro/lib/crates/fabro-cli/src/commands/run/run_progress/stage_display.rs", + "/home/daytona/workspace/fabro/lib/crates/fabro-store/src/run_state.rs", + "/home/daytona/workspace/fabro/lib/crates/fabro-types/src/parallel.rs", + "/home/daytona/workspace/fabro/lib/crates/fabro-types/src/run_event/misc.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/event/convert.rs", + "/home/daytona/workspace/fabro/lib/crates/fabro-workflow/src/event/events.rs", + "/home/daytona/workspace/fabro/lib/crates/fabro-workflow/src/git.rs", + "/home/daytona/workspace/fabro/lib/crates/fabro-workflow/src/handler/agent.rs", + "/home/daytona/workspace/fabro/lib/crates/fabro-workflow/src/handler/fan_in.rs", + "/home/daytona/workspace/fabro/lib/crates/fabro-workflow/src/handler/manager_loop.rs", + "/home/daytona/workspace/fabro/lib/crates/fabro-workflow/src/handler/parallel.rs", + "/home/daytona/workspace/fabro/lib/crates/fabro-workflow/src/services.rs", + "/home/daytona/workspace/fabro/lib/crates/fabro-workflow/src/stage_scope.rs", + "/home/daytona/workspace/fabro/lib/crates/fabro-workflow/tests/it/integration.rs" + ], + "timing": { + "wall_time_ms": 0, + "inference_time_ms": 6293405, + "tool_time_ms": 5933474, + "active_time_ms": 12226879 + } + }, + "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": 165282, + "active_time_ms": 165282 + } + }, + "start": { + "status": "succeeded", + "usage": null + }, + "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": 137028, + "active_time_ms": 137028 + } + }, + "toolchain": { + "status": "succeeded", + "context_updates": { + "command.output": "blob://sha256/20eeffec02497fbda7b51f51b06fe29c1d639551eee4d5ea9845fc1f86bd77e1" + }, + "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": 1285, + "active_time_ms": 1285 + } } }, "next_node_id": "simplify_sol", + "git_commit_sha": "b013ffb709e7d064b2164a81116ad63510b7bd0b", "node_visits": { - "implement": 1, - "toolchain": 1, "start": 1, - "simplify_fable": 1, "preflight_compile": 1, - "preflight_lint": 1 + "implement": 1, + "preflight_lint": 1, + "toolchain": 1, + "simplify_fable": 1 } }, - "diff": {} + "diff": { + "patch": "diff --git a/apps/fabro-web/app/components/stage-renderers/fan-in-results.test.tsx b/apps/fabro-web/app/components/stage-renderers/fan-in-results.test.tsx\nnew file mode 100644\nindex 000000000..26115eaee\n--- /dev/null\n+++ b/apps/fabro-web/app/components/stage-renderers/fan-in-results.test.tsx\n@@ -0,0 +1,77 @@\n+import { afterEach, beforeEach, describe, expect, test } from \"bun:test\";\n+import type { EventEnvelope } from \"@qltysh/fabro-api-client\";\n+import TestRenderer, { act } from \"react-test-renderer\";\n+\n+import { makeEventEnvelope, setupReactTestEnv } from \"../../lib/test-utils\";\n+import type { Stage } from \"../stage-sidebar\";\n+import { FanInResults } from \"./fan-in-results\";\n+\n+let teardown: () => void;\n+beforeEach(() => {\n+ teardown = setupReactTestEnv();\n+});\n+afterEach(() => teardown());\n+\n+const fanInStage: Stage = {\n+ id: \"join@1\",\n+ name: \"join\",\n+ handler: \"parallel.fan_in\",\n+ status: \"succeeded\",\n+ duration: \"1s\",\n+ nodeId: \"join\",\n+ visit: 1,\n+ startedAt: \"2026-04-09T12:00:00Z\",\n+ providerUsed: null,\n+};\n+\n+function event(seq: number, partial: Partial): EventEnvelope {\n+ return makeEventEnvelope(seq, { stage_id: \"join@1\", ...partial });\n+}\n+\n+function renderFanIn(events: EventEnvelope[]): string {\n+ (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;\n+ let renderer!: TestRenderer.ReactTestRenderer;\n+ act(() => {\n+ renderer = TestRenderer.create();\n+ });\n+ return JSON.stringify(renderer.toJSON());\n+}\n+\n+describe(\"FanInResults\", () => {\n+ test(\"renders a neutral joined state without best-branch selection UI\", () => {\n+ const rendered = renderFanIn([]);\n+\n+ expect(rendered).toContain(\"Joined\");\n+ expect(rendered).not.toContain(\"Selected branch\");\n+ expect(rendered).not.toContain(\"Selected by\");\n+ expect(rendered).not.toContain(\"TrophyIcon\");\n+ });\n+\n+ test(\"optionally renders the standard reducer transcript\", () => {\n+ const rendered = renderFanIn([\n+ event(1, {\n+ event: \"stage.prompt\",\n+ properties: {\n+ mode: \"prompt\",\n+ text: \"Combine the useful findings.\",\n+ model: \"claude-sonnet-4-6\",\n+ },\n+ }),\n+ event(2, {\n+ event: \"prompt.completed\",\n+ properties: {\n+ response: \"All branch findings are now available.\",\n+ billing: { input_tokens: 1200, output_tokens: 340 },\n+ },\n+ }),\n+ ]);\n+\n+ expect(rendered).toContain(\"Reducer transcript\");\n+ expect(rendered).toContain(\"Combine the useful findings.\");\n+ expect(rendered).toContain(\"All branch findings are now available.\");\n+ expect(rendered).toContain(\"claude-sonnet-4-6\");\n+ expect(rendered).toContain(\"1k\");\n+ expect(rendered).toContain(\"340\");\n+ expect(rendered).toContain(\"tokens\");\n+ });\n+});\ndiff --git a/apps/fabro-web/app/components/stage-renderers/fan-in-results.tsx b/apps/fabro-web/app/components/stage-renderers/fan-in-results.tsx\nindex f9126d2a4..0f977ce7b 100644\n--- a/apps/fabro-web/app/components/stage-renderers/fan-in-results.tsx\n+++ b/apps/fabro-web/app/components/stage-renderers/fan-in-results.tsx\n@@ -1,143 +1,53 @@\n import { useMemo } from \"react\";\n import {\n+ CheckCircleIcon,\n CpuChipIcon,\n- SparklesIcon,\n- TrophyIcon,\n } from \"@heroicons/react/20/solid\";\n import type { EventEnvelope } from \"@qltysh/fabro-api-client\";\n \n import type { Stage } from \"../stage-sidebar\";\n import { formatTokenCount } from \"../../lib/format\";\n-import { getString } from \"../../lib/unknown\";\n import { Markdown } from \"./primitives\";\n-import { prettyJson } from \"./pretty-json\";\n import { StageMetaBar } from \"./meta-bar\";\n-import { parseFanInOutcome } from \"./helpers\";\n-\n-interface ReducerTurn {\n- prompt: string;\n- response: string;\n- model: string | null;\n- inputTokens: number;\n- outputTokens: number;\n-}\n-\n-function extractReducerTurn(events: EventEnvelope[]): ReducerTurn | null {\n- let prompt = \"\";\n- let response = \"\";\n- let model: string | null = null;\n- let inputTokens = 0;\n- let outputTokens = 0;\n- let hasReducer = false;\n-\n- for (const event of events) {\n- const props = event.properties ?? {};\n- if (event.event === \"stage.prompt\" && getString(props, \"mode\") === \"fan_in\") {\n- prompt = getString(props, \"text\") ?? prompt;\n- hasReducer = true;\n- } else if (event.event === \"prompt.completed\") {\n- response = getString(props, \"response\") ?? response;\n- model = getString(props, \"model\") ?? model;\n- const billing = props.billing as Record | undefined;\n- if (billing) {\n- const it = billing.input_tokens;\n- const ot = billing.output_tokens;\n- if (typeof it === \"number\") inputTokens = it;\n- if (typeof ot === \"number\") outputTokens = ot;\n- }\n- hasReducer = true;\n- }\n- }\n-\n- return hasReducer ? { prompt, response, model, inputTokens, outputTokens } : null;\n-}\n-\n-/**\n- * The fan-in `stage.prompt.text` is built by the handler as\n- * \"\\n\\n\". Split it for nicer display so the JSON candidate set\n- * lands in a code block rather than fighting markdown rendering.\n- */\n-function splitPromptAndCandidates(text: string): { prompt: string; candidatesJson: string } {\n- const trimmed = text.trim();\n- // Find the start of the JSON envelope. The handler always uses array results\n- // so look for the first opening bracket that begins a balanced array/object.\n- const idx = (() => {\n- for (let i = 0; i < trimmed.length; i += 1) {\n- const ch = trimmed[i];\n- if (ch === \"[\" || ch === \"{\") return i;\n- }\n- return -1;\n- })();\n- if (idx < 0) return { prompt: trimmed, candidatesJson: \"\" };\n- const promptPart = trimmed.slice(0, idx).trim();\n- const jsonPart = trimmed.slice(idx).trim();\n- const pretty = prettyJson(jsonPart);\n- return {\n- prompt: promptPart,\n- candidatesJson: pretty.isJson ? pretty.text : jsonPart,\n- };\n-}\n+import { parseReducerTranscript } from \"./helpers\";\n \n export function FanInResults({\n stage,\n events,\n- notes,\n }: {\n stage: Stage;\n events: EventEnvelope[];\n- notes: string | null;\n }) {\n- const outcome = useMemo(() => parseFanInOutcome(events, notes), [events, notes]);\n- const reducer = useMemo(() => extractReducerTurn(events), [events]);\n- const promptParts = useMemo(\n- () => (reducer ? splitPromptAndCandidates(reducer.prompt) : null),\n- [reducer],\n- );\n+ const reducer = useMemo(() => parseReducerTranscript(events), [events]);\n \n return (\n
\n \n- {outcome.reducerModel ? (\n+ {reducer?.model ? (\n \n \n- {outcome.reducerModel}\n+ {reducer.model}\n \n ) : null}\n \n \n-
\n+
\n
\n-
\n- \n+
\n+ \n
\n
\n-
\n- Selected branch\n+
\n+ Joined\n
\n- {outcome.selectedId ? (\n-

\n- {outcome.selectedId}\n-

\n- ) : (\n-

\n- Awaiting fan-in completion\n-

\n- )}\n-

\n- {outcome.hasReducerTranscript ? (\n- <>\n- \n- Selected by LLM reducer\n- \n- ) : (\n- <>Selected by heuristic (status · score · id)\n- )}\n+

\n+ Parallel branches rejoined the workflow.\n

\n
\n
\n
\n \n- {reducer && promptParts && (\n+ {reducer && (\n
\n

\n Reducer transcript\n@@ -149,18 +59,10 @@ export function FanInResults({\n Prompt\n \n \n- {promptParts.prompt && (\n- \n- )}\n- {promptParts.candidatesJson && (\n-
\n- \n- Candidates JSON\n- \n-
\n-                  {promptParts.candidatesJson}\n-                
\n-
\n+ {reducer.prompt ? (\n+ \n+ ) : (\n+

No prompt recorded.

\n )}\n \n \ndiff --git a/apps/fabro-web/app/components/stage-renderers/helpers.test.ts b/apps/fabro-web/app/components/stage-renderers/helpers.test.ts\nindex d5603d8bf..4fcf47e95 100644\n--- a/apps/fabro-web/app/components/stage-renderers/helpers.test.ts\n+++ b/apps/fabro-web/app/components/stage-renderers/helpers.test.ts\n@@ -3,10 +3,9 @@ import type { EventEnvelope } from \"@qltysh/fabro-api-client\";\n \n import {\n extractStageContext,\n- extractStageNotes,\n- parseFanInOutcome,\n parseHumanInterviewPairs,\n parseParallelOverview,\n+ parseReducerTranscript,\n } from \"./helpers\";\n \n function envelope(seq: number, partial: Partial): EventEnvelope {\n@@ -144,45 +143,70 @@ describe(\"parseHumanInterviewPairs\", () => {\n });\n \n describe(\"parseParallelOverview\", () => {\n- test(\"rolls up branch_count from started and results from completed\", () => {\n+ test(\"rolls up branch_count and status-only results\", () => {\n const events: EventEnvelope[] = [\n envelope(1, {\n event: \"parallel.started\",\n- properties: { branch_count: 3, join_policy: \"wait_all\" },\n+ properties: { branch_count: 3 },\n }),\n envelope(2, {\n event: \"parallel.completed\",\n properties: {\n+ duration_ms: 12000,\n success_count: 2,\n failure_count: 1,\n results: [\n- { id: \"branch-a\", status: \"succeeded\", head_sha: \"abc1234567890\" },\n- { id: \"branch-b\", status: \"succeeded\" },\n- { id: \"branch-c\", status: \"failed\" },\n+ {\n+ id: \"branch-a\",\n+ status: \"succeeded\",\n+ context_updates: { \"response.branch-a\": \"A\" },\n+ },\n+ {\n+ id: \"branch-b\",\n+ status: \"succeeded\",\n+ context_updates: { \"command.output\": { stdout: \"B\" } },\n+ },\n+ {\n+ id: \"branch-c\",\n+ status: \"failed\",\n+ context_updates: { \"response.branch-c\": \"C\" },\n+ },\n ],\n },\n }),\n ];\n const overview = parseParallelOverview(events);\n- expect(overview).toMatchObject({\n+ expect(overview).toEqual({\n branchCount: 3,\n- joinPolicy: \"wait_all\",\n successCount: 2,\n failureCount: 1,\n+ durationMs: 12000,\n+ results: [\n+ {\n+ id: \"branch-a\",\n+ status: \"succeeded\",\n+ context_updates: { \"response.branch-a\": \"A\" },\n+ },\n+ {\n+ id: \"branch-b\",\n+ status: \"succeeded\",\n+ context_updates: { \"command.output\": { stdout: \"B\" } },\n+ },\n+ {\n+ id: \"branch-c\",\n+ status: \"failed\",\n+ context_updates: { \"response.branch-c\": \"C\" },\n+ },\n+ ],\n isComplete: true,\n });\n- expect(overview.results).toEqual([\n- { id: \"branch-a\", status: \"succeeded\", headSha: \"abc1234567890\" },\n- { id: \"branch-b\", status: \"succeeded\", headSha: null },\n- { id: \"branch-c\", status: \"failed\", headSha: null },\n- ]);\n });\n \n test(\"reports in-flight when only the started event is present\", () => {\n const events: EventEnvelope[] = [\n envelope(1, {\n event: \"parallel.started\",\n- properties: { branch_count: 4, join_policy: \"first_success\" },\n+ properties: { branch_count: 4 },\n }),\n ];\n const overview = parseParallelOverview(events);\n@@ -192,49 +216,52 @@ describe(\"parseParallelOverview\", () => {\n });\n });\n \n-describe(\"parseFanInOutcome\", () => {\n- test(\"extracts the selected branch id from notes\", () => {\n- const outcome = parseFanInOutcome([], \"Selected best candidate: branch-42\");\n- expect(outcome.selectedId).toBe(\"branch-42\");\n- expect(outcome.hasReducerTranscript).toBe(false);\n+describe(\"parseReducerTranscript\", () => {\n+ test(\"returns null when fan-in joins without a reducer\", () => {\n+ expect(parseReducerTranscript([])).toBeNull();\n });\n \n- test(\"flags reducer presence when fan-in prompt events exist\", () => {\n+ test(\"parses the standard prompt transcript when a reducer ran\", () => {\n const events: EventEnvelope[] = [\n envelope(1, {\n event: \"stage.prompt\",\n- properties: { mode: \"fan_in\", text: \"rank these\", model: \"claude-sonnet-4-6\" },\n+ properties: {\n+ mode: \"prompt\",\n+ text: \"Combine the branch results.\",\n+ model: \"claude-sonnet-4-6\",\n+ },\n }),\n envelope(2, {\n event: \"prompt.completed\",\n- properties: { response: \"branch-a wins\", model: \"ignored-downstream-model\" },\n+ properties: {\n+ response: \"The branch results are joined.\",\n+ billing: { input_tokens: 1200, output_tokens: 340 },\n+ },\n }),\n ];\n- const outcome = parseFanInOutcome(events, \"Selected best candidate: branch-a\");\n- expect(outcome.hasReducerTranscript).toBe(true);\n- expect(outcome.reducerModel).toBe(\"claude-sonnet-4-6\");\n- expect(outcome.selectedId).toBe(\"branch-a\");\n- });\n \n- test(\"returns null selection when notes lack the selected line\", () => {\n- const outcome = parseFanInOutcome([], \"all candidates failed\");\n- expect(outcome.selectedId).toBeNull();\n+ expect(parseReducerTranscript(events)).toEqual({\n+ prompt: \"Combine the branch results.\",\n+ response: \"The branch results are joined.\",\n+ model: \"claude-sonnet-4-6\",\n+ inputTokens: 1200,\n+ outputTokens: 340,\n+ });\n });\n-});\n \n-describe(\"extractStageNotes\", () => {\n- test(\"returns notes from the stage.completed event\", () => {\n+ test(\"uses normal prompt mode for the reducer transcript\", () => {\n const events: EventEnvelope[] = [\n envelope(1, {\n- event: \"stage.completed\",\n- properties: { notes: \"Stop condition satisfied at cycle 7\" },\n+ event: \"stage.prompt\",\n+ properties: { mode: \"prompt\", text: \"Standard reducer\" },\n+ }),\n+ envelope(2, {\n+ event: \"prompt.completed\",\n+ properties: { response: \"Standard response\" },\n }),\n ];\n- expect(extractStageNotes(events)).toBe(\"Stop condition satisfied at cycle 7\");\n- });\n \n- test(\"returns null when there is no stage.completed event\", () => {\n- expect(extractStageNotes([])).toBeNull();\n+ expect(parseReducerTranscript(events)?.response).toBe(\"Standard response\");\n });\n });\n \ndiff --git a/apps/fabro-web/app/components/stage-renderers/helpers.ts b/apps/fabro-web/app/components/stage-renderers/helpers.ts\nindex f7f6fa10d..3c631a9e2 100644\n--- a/apps/fabro-web/app/components/stage-renderers/helpers.ts\n+++ b/apps/fabro-web/app/components/stage-renderers/helpers.ts\n@@ -1,7 +1,16 @@\n-import type { EventEnvelope } from \"@qltysh/fabro-api-client\";\n+import { StageOutcome } from \"@qltysh/fabro-api-client\";\n+import type { EventEnvelope, ParallelBranchResult } from \"@qltysh/fabro-api-client\";\n+\n+export type { ParallelBranchResult };\n \n import { getArray, getNumber, getObject, getString, type UnknownRecord } from \"../../lib/unknown\";\n \n+const STAGE_OUTCOMES: ReadonlySet = new Set(Object.values(StageOutcome));\n+\n+function asStageOutcome(value: string | undefined): StageOutcome | null {\n+ return value !== undefined && STAGE_OUTCOMES.has(value) ? (value as StageOutcome) : null;\n+}\n+\n export interface InterviewOption {\n key: string;\n label: string;\n@@ -137,15 +146,8 @@ export function parseHumanInterviewPairs(events: EventEnvelope[]): HumanIntervie\n return Array.from(pairs.values()).sort((a, b) => a.question.ts.localeCompare(b.question.ts));\n }\n \n-export interface ParallelBranchResult {\n- id: string;\n- status: string;\n- headSha: string | null;\n-}\n-\n export interface ParallelOverview {\n branchCount: number | null;\n- joinPolicy: string | null;\n successCount: number | null;\n failureCount: number | null;\n durationMs: number | null;\n@@ -160,7 +162,6 @@ export interface ParallelOverview {\n */\n export function parseParallelOverview(events: EventEnvelope[]): ParallelOverview {\n let branchCount: number | null = null;\n- let joinPolicy: string | null = null;\n let successCount: number | null = null;\n let failureCount: number | null = null;\n let durationMs: number | null = null;\n@@ -171,7 +172,6 @@ export function parseParallelOverview(events: EventEnvelope[]): ParallelOverview\n const props: UnknownRecord = event.properties ?? {};\n if (event.event === \"parallel.started\") {\n branchCount = getNumber(props, \"branch_count\") ?? branchCount;\n- joinPolicy = getString(props, \"join_policy\") ?? joinPolicy;\n } else if (event.event === \"parallel.completed\") {\n isComplete = true;\n successCount = getNumber(props, \"success_count\") ?? successCount;\n@@ -182,20 +182,23 @@ export function parseParallelOverview(events: EventEnvelope[]): ParallelOverview\n .map((entry) => {\n const record = entry && typeof entry === \"object\" ? (entry as UnknownRecord) : null;\n if (!record) return null;\n+ const id = getString(record, \"id\");\n+ const status = asStageOutcome(getString(record, \"status\"));\n+ const contextUpdates = getObject(record, \"context_updates\");\n+ if (!id || !status || !contextUpdates) return null;\n return {\n- id: getString(record, \"id\") ?? \"\",\n- status: getString(record, \"status\") ?? \"unknown\",\n- headSha: getString(record, \"head_sha\") ?? null,\n+ id,\n+ status,\n+ context_updates: contextUpdates,\n } satisfies ParallelBranchResult;\n })\n- .filter((r): r is ParallelBranchResult => r != null && r.id !== \"\");\n+ .filter((r): r is ParallelBranchResult => r != null);\n if (branchCount == null) branchCount = results.length;\n }\n }\n \n return {\n branchCount,\n- joinPolicy,\n successCount,\n failureCount,\n durationMs,\n@@ -204,59 +207,39 @@ export function parseParallelOverview(events: EventEnvelope[]): ParallelOverview\n };\n }\n \n-export interface FanInOutcome {\n- selectedId: string | null;\n- hasReducerTranscript: boolean;\n- reducerModel: string | null;\n+export interface ReducerTranscript {\n+ prompt: string;\n+ response: string;\n+ model: string | null;\n+ inputTokens: number;\n+ outputTokens: number;\n }\n \n-const FAN_IN_NOTES_RE = /Selected best candidate:\\s*(.+?)\\s*$/;\n+/** Extract the standard prompt/response transcript emitted by an optional fan-in reducer. */\n+export function parseReducerTranscript(events: EventEnvelope[]): ReducerTranscript | null {\n+ let prompt = \"\";\n+ let response = \"\";\n+ let model: string | null = null;\n+ let inputTokens = 0;\n+ let outputTokens = 0;\n+ let hasReducer = false;\n \n-/**\n- * Derive the fan-in winner from the `parallel.completed`-style notes string,\n- * and report whether reducer LLM events were emitted (so the UI knows to show\n- * the embedded transcript).\n- */\n-export function parseFanInOutcome(events: EventEnvelope[], notes: string | null): FanInOutcome {\n- const match = notes ? FAN_IN_NOTES_RE.exec(notes) : null;\n- let hasReducerTranscript = false;\n- let reducerModel: string | null = null;\n for (const event of events) {\n+ const props: UnknownRecord = event.properties ?? {};\n if (event.event === \"stage.prompt\") {\n- const mode = getString(event.properties ?? {}, \"mode\");\n- if (mode === \"fan_in\") {\n- hasReducerTranscript = true;\n- const model = getString(event.properties ?? {}, \"model\");\n- if (model) reducerModel = model;\n- }\n- }\n- if (event.event === \"prompt.completed\") {\n- hasReducerTranscript = true;\n+ prompt = getString(props, \"text\") ?? prompt;\n+ model = getString(props, \"model\") ?? model;\n+ hasReducer = true;\n+ } else if (event.event === \"prompt.completed\" && hasReducer) {\n+ response = getString(props, \"response\") ?? response;\n+ model = getString(props, \"model\") ?? model;\n+ const billing = getObject(props, \"billing\") ?? {};\n+ inputTokens = getNumber(billing, \"input_tokens\") ?? inputTokens;\n+ outputTokens = getNumber(billing, \"output_tokens\") ?? outputTokens;\n }\n }\n- return {\n- selectedId: match ? match[1].trim() : null,\n- hasReducerTranscript,\n- reducerModel,\n- };\n-}\n \n-function asUnknownRecord(value: unknown): UnknownRecord | null {\n- if (!value || typeof value !== \"object\" || Array.isArray(value)) return null;\n- return value as UnknownRecord;\n-}\n-\n-/**\n- * Extract `notes` from the `stage.completed` event scoped to this stage.\n- * Returns null when the stage hasn't finished yet or when notes are absent.\n- */\n-export function extractStageNotes(events: EventEnvelope[]): string | null {\n- for (const event of events) {\n- if (event.event !== \"stage.completed\") continue;\n- const notes = getString(event.properties ?? {}, \"notes\");\n- if (notes) return notes;\n- }\n- return null;\n+ return hasReducer ? { prompt, response, model, inputTokens, outputTokens } : null;\n }\n \n export interface StageContextData {\ndiff --git a/apps/fabro-web/app/components/stage-renderers/parallel-children.test.tsx b/apps/fabro-web/app/components/stage-renderers/parallel-children.test.tsx\nnew file mode 100644\nindex 000000000..f1313b780\n--- /dev/null\n+++ b/apps/fabro-web/app/components/stage-renderers/parallel-children.test.tsx\n@@ -0,0 +1,85 @@\n+import { afterEach, beforeEach, describe, expect, test } from \"bun:test\";\n+import type { EventEnvelope } from \"@qltysh/fabro-api-client\";\n+import TestRenderer, { act } from \"react-test-renderer\";\n+import { MemoryRouter } from \"react-router\";\n+\n+import { makeEventEnvelope, setupReactTestEnv } from \"../../lib/test-utils\";\n+import type { Stage } from \"../stage-sidebar\";\n+import { ParallelChildren } from \"./parallel-children\";\n+\n+let teardown: () => void;\n+beforeEach(() => {\n+ teardown = setupReactTestEnv();\n+});\n+afterEach(() => teardown());\n+\n+const parallelStage: Stage = {\n+ id: \"fork@1\",\n+ name: \"fork\",\n+ handler: \"parallel\",\n+ status: \"succeeded\",\n+ duration: \"12s\",\n+ nodeId: \"fork\",\n+ visit: 1,\n+ startedAt: \"2026-04-09T12:00:00Z\",\n+ providerUsed: null,\n+};\n+\n+function event(partial: Partial): EventEnvelope {\n+ return makeEventEnvelope(partial.seq ?? 1, { event: \"parallel.completed\", ...partial });\n+}\n+\n+function renderParallel(events: EventEnvelope[]): TestRenderer.ReactTestRenderer {\n+ let renderer!: TestRenderer.ReactTestRenderer;\n+ act(() => {\n+ renderer = TestRenderer.create(\n+ \n+ \n+ ,\n+ );\n+ });\n+ return renderer;\n+}\n+\n+describe(\"ParallelChildren\", () => {\n+ test(\"renders branch status and stage links without checkout metadata\", () => {\n+ const renderer = renderParallel([\n+ event({\n+ event: \"parallel.started\",\n+ properties: { branch_count: 2 },\n+ }),\n+ event({\n+ seq: 2,\n+ event: \"parallel.completed\",\n+ properties: {\n+ duration_ms: 12000,\n+ success_count: 1,\n+ failure_count: 1,\n+ results: [\n+ { id: \"branch-a\", status: \"succeeded\", context_updates: {} },\n+ { id: \"branch-b\", status: \"failed\", context_updates: {} },\n+ ],\n+ },\n+ }),\n+ ]);\n+\n+ const rendered = JSON.stringify(renderer.toJSON());\n+ expect(rendered).toContain(\"branch-a\");\n+ expect(rendered).toContain(\"Succeeded\");\n+ expect(rendered).toContain(\"branch-b\");\n+ expect(rendered).toContain(\"Failed\");\n+ const hrefs = renderer.root.findAllByType(\"a\").map((link) => link.props.href);\n+ expect(hrefs).toEqual([\n+ \"/runs/run-1/stages/branch-a@1\",\n+ \"/runs/run-1/stages/branch-b@1\",\n+ ]);\n+ });\n+});\ndiff --git a/apps/fabro-web/app/components/stage-renderers/parallel-children.tsx b/apps/fabro-web/app/components/stage-renderers/parallel-children.tsx\nindex 0b9e4b951..30cb6169c 100644\n--- a/apps/fabro-web/app/components/stage-renderers/parallel-children.tsx\n+++ b/apps/fabro-web/app/components/stage-renderers/parallel-children.tsx\n@@ -1,34 +1,19 @@\n import { useMemo } from \"react\";\n import { Link } from \"react-router\";\n import { ArrowTopRightOnSquareIcon } from \"@heroicons/react/20/solid\";\n+import { StageState } from \"@qltysh/fabro-api-client\";\n import type { EventEnvelope } from \"@qltysh/fabro-api-client\";\n \n import type { Stage } from \"../stage-sidebar\";\n-import { CopyButton } from \"../ui\";\n+import { stageStatusLabel, stageStatusTone } from \"../../lib/stage-sidebar\";\n import { formatDurationMs } from \"../../lib/format\";\n import { StageMetaBar } from \"./meta-bar\";\n-import { parseParallelOverview, type ParallelBranchResult } from \"./helpers\";\n+import { parseParallelOverview } from \"./helpers\";\n \n-const RESULT_STATUS_TONE: Record = {\n- succeeded: \"bg-mint/15 text-mint\",\n- partially_succeeded: \"bg-amber/15 text-amber\",\n- failed: \"bg-coral/15 text-coral\",\n- cancelled: \"bg-overlay-strong text-fg-muted\",\n- skipped: \"bg-overlay-strong text-fg-muted\",\n-};\n-\n-function statusTone(status: string): string {\n- return RESULT_STATUS_TONE[status] ?? \"bg-overlay-strong text-fg-muted\";\n-}\n-\n-function statusLabel(status: string): string {\n- if (!status) return \"—\";\n- return status.charAt(0).toUpperCase() + status.slice(1).replace(/_/g, \" \");\n-}\n-\n-function shortSha(sha: string | null): string | null {\n- if (!sha) return null;\n- return sha.length > 8 ? sha.slice(0, 8) : sha;\n+/** Branch row view state: completed outcomes plus a synthesized in-flight row. */\n+interface BranchRow {\n+ id: string;\n+ status: StageState;\n }\n \n function StatItem({\n@@ -56,27 +41,21 @@ function ChildRow({\n result,\n stageHref,\n }: {\n- result: ParallelBranchResult;\n+ result: BranchRow;\n stageHref: string | null;\n }) {\n- const sha = shortSha(result.headSha);\n- const tone = statusTone(result.status);\n+ const tone = stageStatusTone(result.status);\n \n const inner = (\n <>\n \n- {statusLabel(result.status)}\n+ {stageStatusLabel(result.status)}\n \n \n {result.id}\n \n- {sha && (\n- \n- {sha}\n- \n- )}\n {stageHref && (\n {inner}\n )}\n- {result.headSha && (\n- \n- )}\n \n );\n }\n@@ -128,25 +104,18 @@ export function ParallelChildren({\n return new Map(Array.from(latest.entries()).map(([nodeId, s]) => [nodeId, s.id]));\n }, [allStages]);\n \n- const items = overview.results.length > 0\n+ const items: BranchRow[] = overview.results.length > 0\n ? overview.results\n : overview.branchCount && overview.branchCount > 0\n ? Array.from({ length: overview.branchCount }, (_, i) => ({\n id: `branch ${i + 1}`,\n- status: \"running\",\n- headSha: null,\n+ status: StageState.RUNNING,\n }))\n : [];\n \n return (\n
\n- \n- {overview.joinPolicy ? (\n- \n- {overview.joinPolicy.replace(/_/g, \" \")}\n- \n- ) : null}\n- \n+ \n \n
\n \ndiff --git a/apps/fabro-web/app/lib/test-utils.tsx b/apps/fabro-web/app/lib/test-utils.tsx\nindex f881952de..995245aa5 100644\n--- a/apps/fabro-web/app/lib/test-utils.tsx\n+++ b/apps/fabro-web/app/lib/test-utils.tsx\n@@ -1,4 +1,5 @@\n import { createElement, type ReactNode } from \"react\";\n+import type { EventEnvelope } from \"@qltysh/fabro-api-client\";\n import TestRenderer, { act } from \"react-test-renderer\";\n \n const IS_REACT_ACT_ENV = \"IS_REACT_ACT_ENVIRONMENT\" as const;\n@@ -39,6 +40,21 @@ export function setupReactTestEnv(): () => void {\n };\n }\n \n+/** Build an event envelope fixture; override any field via `partial`. */\n+export function makeEventEnvelope(\n+ seq: number,\n+ partial: Partial,\n+): EventEnvelope {\n+ return {\n+ seq,\n+ id: `evt-${seq}`,\n+ ts: `2026-04-09T12:00:0${seq}Z`,\n+ run_id: \"run-1\",\n+ event: \"stage.prompt\",\n+ ...partial,\n+ } as EventEnvelope;\n+}\n+\n export function renderHook(\n hook: () => T,\n options: { wrapper: React.ComponentType<{ children: ReactNode }> },\ndiff --git a/apps/fabro-web/app/routes/run-stages.tsx b/apps/fabro-web/app/routes/run-stages.tsx\nindex aa0ae43af..deaf26d44 100644\n--- a/apps/fabro-web/app/routes/run-stages.tsx\n+++ b/apps/fabro-web/app/routes/run-stages.tsx\n@@ -42,10 +42,7 @@ import {\n } from \"../components/ui\";\n import { ConditionalDecision } from \"../components/stage-renderers/conditional-decision\";\n import { FanInResults } from \"../components/stage-renderers/fan-in-results\";\n-import {\n- extractStageContext,\n- extractStageNotes,\n-} from \"../components/stage-renderers/helpers\";\n+import { extractStageContext } from \"../components/stage-renderers/helpers\";\n import { HumanQA } from \"../components/stage-renderers/human-qa\";\n import { ParallelChildren } from \"../components/stage-renderers/parallel-children\";\n import {\n@@ -1572,11 +1569,7 @@ function StageActivityBody({\n allStages={stages}\n />\n ) : renderer === \"fan_in\" ? (\n- \n+ \n ) : renderer === \"wait\" ? (\n \n ) : (\ndiff --git a/docs/internal/demo/06-parallel.fabro b/docs/internal/demo/06-parallel.fabro\nindex 7f7191a3c..f4714e600 100644\n--- a/docs/internal/demo/06-parallel.fabro\n+++ b/docs/internal/demo/06-parallel.fabro\n@@ -5,7 +5,7 @@ digraph Parallel {\n start [shape=Mdiamond, label=\"Start\"]\n exit [shape=Msquare, label=\"Exit\"]\n \n- fork [label=\"Fork Analysis\", shape=component, join_policy=\"wait_all\"]\n+ fork [label=\"Fork Analysis\", shape=component]\n \n security [label=\"Security Audit\", prompt=\"Examine the codebase for security concerns: hardcoded secrets, injection risks, unsafe dependencies. List findings as bullet points.\", shape=tab, reasoning_effort=\"low\"]\n architecture [label=\"Architecture Review\", prompt=\"Assess the codebase architecture: separation of concerns, dependency structure, modularity. List findings as bullet points.\", shape=tab, reasoning_effort=\"low\"]\ndiff --git a/docs/internal/demo/11-ensemble.fabro b/docs/internal/demo/11-ensemble.fabro\nindex 3a8655cde..87aadf36c 100644\n--- a/docs/internal/demo/11-ensemble.fabro\n+++ b/docs/internal/demo/11-ensemble.fabro\n@@ -14,7 +14,7 @@ digraph Ensemble {\n start [shape=Mdiamond, label=\"Start\"]\n exit [shape=Msquare, label=\"Exit\"]\n \n- fork [label=\"Fan Out\", shape=component, join_policy=\"wait_all\"]\n+ fork [label=\"Fan Out\", shape=component]\n \n opus [label=\"Opus\", prompt=\"Analyze the goal. Provide your independent assessment, recommendations, and any code or prose needed. Be thorough.\", shape=tab]\n gemini [label=\"Gemini\", prompt=\"Analyze the goal. Provide your independent assessment, recommendations, and any code or prose needed. Be thorough.\", shape=tab]\ndiff --git a/docs/internal/events.md b/docs/internal/events.md\nindex 4af5ab491..53d10e1e4 100644\n--- a/docs/internal/events.md\n+++ b/docs/internal/events.md\n@@ -523,16 +523,16 @@ Emitted when a parallel node begins executing branches.\n \"id\": \"...\", \"ts\": \"...\", \"run_id\": \"...\",\n \"event\": \"parallel.started\",\n \"properties\": {\n- \"branch_count\": 3,\n- \"join_policy\": \"all\"\n+ \"visit\": 1,\n+ \"branch_count\": 3\n }\n }\n ```\n \n | Property | Type | Description |\n |----------|------|-------------|\n+| `visit` | number | Visit number for this parallel stage |\n | `branch_count` | number | Number of parallel branches |\n-| `join_policy` | string | Join policy |\n \n ### `parallel.branch.started`\n \n@@ -587,18 +587,33 @@ Emitted when all parallel branches have finished.\n \"id\": \"...\", \"ts\": \"...\", \"run_id\": \"...\",\n \"event\": \"parallel.completed\",\n \"properties\": {\n+ \"visit\": 1,\n \"duration_ms\": 12000,\n \"success_count\": 2,\n- \"failure_count\": 1\n+ \"failure_count\": 1,\n+ \"results\": [\n+ {\n+ \"id\": \"branch_a\",\n+ \"status\": \"succeeded\",\n+ \"context_updates\": {\"response.branch_a\": \"review complete\"}\n+ },\n+ {\n+ \"id\": \"branch_b\",\n+ \"status\": \"failed\",\n+ \"context_updates\": {\"command.output\": \"validation failed\"}\n+ }\n+ ]\n }\n }\n ```\n \n | Property | Type | Description |\n |----------|------|-------------|\n+| `visit` | number | Visit number for this parallel stage |\n | `duration_ms` | number | Total parallel duration |\n | `success_count` | number | Branches that succeeded |\n | `failure_count` | number | Branches that failed |\n+| `results` | array | Ordered typed branch results with `id`, `status`, and isolated `context_updates` |\n \n ---\n \n@@ -760,58 +775,6 @@ Note: `node_id` is optional — may be absent for non-stage commits.\n | `branch` | string | Branch name |\n | `success` | boolean | Whether push succeeded |\n \n-### `git.branch`\n-\n-```json\n-{\n- \"id\": \"...\", \"ts\": \"...\", \"run_id\": \"...\",\n- \"event\": \"git.branch\",\n- \"properties\": {\n- \"branch\": \"fabro/run-01JQXYZ\",\n- \"sha\": \"abc123...\"\n- }\n-}\n-```\n-\n-| Property | Type | Description |\n-|----------|------|-------------|\n-| `branch` | string | Branch name |\n-| `sha` | string | Branch HEAD SHA |\n-\n-### `git.worktree.added`\n-\n-```json\n-{\n- \"id\": \"...\", \"ts\": \"...\", \"run_id\": \"...\",\n- \"event\": \"git.worktree.added\",\n- \"properties\": {\n- \"path\": \"/tmp/fabro-worktrees/...\",\n- \"branch\": \"fabro/run-01JQXYZ\"\n- }\n-}\n-```\n-\n-| Property | Type | Description |\n-|----------|------|-------------|\n-| `path` | string | Worktree directory path |\n-| `branch` | string | Branch name |\n-\n-### `git.worktree.removed`\n-\n-```json\n-{\n- \"id\": \"...\", \"ts\": \"...\", \"run_id\": \"...\",\n- \"event\": \"git.worktree.removed\",\n- \"properties\": {\n- \"path\": \"/tmp/fabro-worktrees/...\"\n- }\n-}\n-```\n-\n-| Property | Type | Description |\n-|----------|------|-------------|\n-| `path` | string | Worktree directory path |\n-\n ### `git.fetch`\n \n ```json\ndiff --git a/docs/internal/parallel-strategy.md b/docs/internal/parallel-strategy.md\nindex 351764ade..61a019326 100644\n--- a/docs/internal/parallel-strategy.md\n+++ b/docs/internal/parallel-strategy.md\n@@ -1,318 +1,153 @@\n-# Parallel fan-out / fan-in strategy\n-\n-Status: proposed (not yet implemented). Line numbers reference the tree at the\n-time of writing and will drift.\n-\n-This document specifies the intended product behavior of parallel fan-out\n-(`shape=component`) and fan-in (`shape=tripleoctagon`). Appendix A catalogs\n-pre-existing bugs this design resolves. Appendix B lists behavior changes\n-relative to today's implementation.\n-\n-## 1. Introduction: goals and current weaknesses\n-\n-The goal of this design is a parallel execution model that is **simple**,\n-**coherent**, and **correct**:\n-\n-- **Simple** — a user should be able to predict what a fan-out/fan-in does\n- from the graph alone. One mental model (\"branches produce candidates; fan-in\n- picks a commit; downstream sees all responses\"), no new syntax for\n- synthesis, no incantations (special fidelity settings, escape-hatch\n- attributes) to make the flagship patterns work.\n-- **Coherent** — the same rules apply regardless of node type or channel.\n- What holds for a sequential command node's output should hold for a branch\n- command node's output; the workspace (git) facet and the text (context)\n- facet should follow parallel logic; selection should live in exactly one\n- place.\n-- **Correct** — the engine must do what the graph, the docs, and the recorded\n- run state say it does. Nodes drawn in the graph must run; documented outputs\n- must exist; a judge asked to pick the best candidate must be shown the\n- candidates.\n-\n-Today's implementation misses all three. Issue #490 is the visible symptom:\n-a synthesis node after fan-in never sees branch responses — only\n-`{id, status, head_sha}` metadata — while two tutorials promise the opposite.\n-Investigation showed a broader incoherence:\n-\n-- **Inherited spec gap.** The Attractor spec (fabro's ancestor) deliberately\n- isolates branch context and never defines a channel for branch output text.\n- Its fan-in pseudocode judges candidates it cannot see (`llm_evaluate`\n- receives only statuses) and sorts by a `score` field nothing sets. Fabro\n- inherited this gap faithfully.\n-- **Missing compensation.** Kilroy (a sibling Attractor implementation)\n- compensates with a file/git handoff: the post-merge node's prompt is\n- injected with each branch's `worktree_dir`, `logs_root`, and `head_sha` plus\n- instructions to read/merge them. Fabro has no equivalent, but its tutorials\n- promise kilroy-like behavior (\"the synth node receives all four perspectives\n- in its preamble\").\n-- **Accidental semantics.** Several adjacent behaviors are unprincipled\n- accidents rather than decisions: branches execute a single node and silently\n- skip chained nodes; two handlers fast-forward to two different definitions\n- of \"winner\"; branch nodes run with a stale preamble built for the fan-out\n- node; selection is vacuous in both modes; nested fan-outs silently lose git\n- isolation. Appendix A catalogs these.\n-\n-## 2. Core model: candidates\n-\n-A parallel branch is an isolated unit of execution that produces a\n-**candidate**. A candidate has exactly three facets:\n-\n-| Facet | Content | Carried by |\n-|---|---|---|\n-| Commit | Workspace state produced by the branch | Per-branch git commit (`head_sha`) |\n-| Response | Text output (LLM response, command output) | Branch stage records + context keys |\n-| Verdict | Terminal status plus optional numeric `score` | `parallel.results` entries |\n-\n-Fan-out produces N candidates in isolation. Fan-in selects **one commit** to\n-continue on. Downstream nodes (synthesis) get **all responses**. Selection and\n-synthesis are distinct concerns: selection needs a node (`tripleoctagon`);\n-synthesis is any ordinary downstream node, because responses propagate.\n-\n-The post-fan-in contract, in one sentence: **after fan-in, the run looks as if\n-every branch had run sequentially, and the winner ran last.**\n-\n-## 3. Topology: branches are subgraphs\n-\n-Each outgoing edge of a fan-out node starts a branch. A branch executes as a\n-subgraph walk: the engine traverses nodes and edges from the branch entry node\n-until it reaches the join node (the fan-in). Multi-node chains\n-(`fork -> plan_a; plan_a -> impl_a; impl_a -> merge`) run every node in the\n-chain. This matches the Attractor spec (`execute_subgraph`) and kilroy\n-(`runSubgraphUntil`); today fabro executes exactly one node per branch and\n-silently skips the rest (Appendix A.2).\n-\n-Structural validation (lint):\n-\n-- Every path from a branch entry must converge on the run's join node.\n- A branch path that escapes the join (reaches exit, or a node outside the\n- fan-out region) is a validation error.\n-- A nested `component` node inside a branch is rejected until\n- worktree-from-worktree isolation is implemented (today it silently runs with\n- no git isolation; Appendix A.6).\n-- `fidelity=\"full\"` on a fan-in's outgoing edge gets a lint warning: branches\n- run on different threads, so full fidelity can never carry branch outputs\n- (it drops the preamble entirely).\n-\n-## 4. Node types in branches\n-\n-No type-based restrictions. Restriction is structural (§3), not by allowlist:\n-\n-- **Agent / prompt nodes** — the primary case.\n-- **Command / script / tool nodes** — first-class. Deterministic fan-out\n- (test matrices, benchmark bake-offs across worktrees) is a supported pattern\n- with no LLM anywhere: branches emit `score` via status fields, heuristic\n- selection picks the winner by measurement.\n-- **Conditionals** — meaningful under subgraph branches: they route *within*\n- the branch.\n-- **Human gates** — allowed; each branch may pause independently.\n-- **Nested parallel** — rejected by lint until isolation composes (§3).\n-\n-## 5. Git isolation\n-\n-Unchanged mechanics, with ownership fixed:\n-\n-1. Before fan-out, checkpoint the sandbox to produce `base_sha`.\n-2. Each branch gets a worktree on a branch ref\n- (`fabro/run/parallel///pass/`), rooted at `base_sha`.\n- The branch's `internal.work_dir` points at the worktree.\n-3. After a branch completes, `git add -A` + commit (`--allow-empty`) yields the\n- candidate's `head_sha`.\n-4. Worktrees are removed after the join. Loser branch refs are **kept** so\n- downstream nodes and humans can `git show`/`git diff` any candidate.\n-5. **Fan-in exclusively owns the fast-forward.** The parallel handler performs\n- no merge. After selection, fan-in fast-forwards the primary workspace to the\n- winner's `head_sha`. (Today both handlers fast-forward, to potentially\n- different winners; Appendix A.3.)\n-\n-Degradation without git (no repo, or git isolation disabled): branches share\n-the primary sandbox with no workspace isolation, `head_sha` is absent from\n-candidates, and fan-in performs no merge. Response and verdict facets work\n-unchanged — prompt-only ensembles do not require git.\n-\n-## 6. Execution and stage recording\n-\n-Branch nodes execute as **real stages**, recorded through the normal\n-`ExecutionState::record` path and namespaced under the fan-out\n-(e.g. stage `a@1` within `fork@1`). Consequences (all fixes to current\n-behavior):\n-\n-- Branch prompts/responses appear in events, `fabro dump`, and the web UI as\n- ordinary stages.\n-- Each branch node gets a **freshly built preamble** for its own position, via\n- the standard lifecycle, instead of reusing the fan-out node's stale preamble.\n-- Branch stages participate in the standard retry policy per node.\n-\n-## 7. Context merge-back at fan-in\n-\n-When fan-in completes, branch context updates are applied to the parent\n-context with a collision rule:\n-\n-- **Per-node keys** (`response.`, structured-output fields\n- namespaced by node) apply for **all** branches. Branch node IDs are unique,\n- so no collisions.\n-- **Singleton keys** (`last_stage`, `last_response`, `command.output`,\n- un-namespaced status fields) are taken from the **winner only**.\n-- Failed branches' `response.` values are still applied (a synthesis node\n- analyzing disagreement wants to see the failure text). Their singletons are\n- never applied.\n-\n-Fan-in additionally writes (as today):\n-\n-- `parallel.results` — one entry per candidate: `{id, status, head_sha?,\n- score?}`.\n-- `parallel.branch_count`, `parallel.fan_in.best_id`,\n- `parallel.fan_in.best_outcome`, `parallel.fan_in.best_head_sha`.\n-\n-No file is materialized into the run workspace. `parallel.results` reaches LLM\n-consumers through the preamble's context section, and agents can `git show`\n-any candidate via its `head_sha`. (The current docs claim\n-`parallel_results.json` is available to downstream nodes; that claim is false\n-today and should be corrected rather than implemented — see open question 4\n-for the one consumer this leaves unserved.)\n-\n-## 8. Selection\n-\n-Fan-in selects the winning candidate. Two modes, as today, but with real\n-signal:\n-\n-- **Heuristic** (no prompt on the fan-in node): rank by status\n- (succeeded < partially_succeeded < failed), then `score` descending, then\n- lexical id. `score` becomes settable: branches emit it via structured-output\n- / status fields, which now survive into `parallel.results` (§7).\n-- **LLM judge** (fan-in node has a `prompt` and a backend is configured): the\n- judge prompt includes, per candidate: id, status, score, a bounded response\n- excerpt, and `git diff --stat` vs. `base_sha` when git isolation is active.\n- Today the judge sees only `{id, status, head_sha}` and cannot possibly\n- discriminate (Appendix A.4).\n-\n-Selection determines the commit facet only. It does not suppress loser\n-responses (§7) or loser refs (§5).\n-\n-## 9. Downstream visibility (preamble)\n-\n-Prompt templates render once at manifest build time with `{goal, inputs}`\n-only; the preamble is the sole channel for runtime context into a\n-fresh-session node. Therefore:\n-\n-- At **`compact`** (default) fidelity, branch stage summaries render their\n- responses **inline**, bounded, with a `See: ` reference when\n- truncated — the same treatment `command.output` already receives at compact.\n- Rationale: post-fan-in branch responses are unrecoverable through any\n- fidelity setting (different threads), exactly like command output.\n-- The per-branch response budget is larger than command output's 25-line tail\n- (ensemble analyses front-load their substance; a small tail amputates it).\n- Exact budget TBD at implementation; must remain bounded so N long branches\n- cannot blow the downstream context. Agent-type synthesis nodes can read the\n- full text from the blob/artifact reference.\n-- `summary:high` renders the same with its larger budget. `truncate` carries\n- goal only (explicit opt-out). `full` remains the degenerate case and lints\n- (§3).\n-\n-## 10. Join policies\n-\n-- `wait_all` (default): all branches run to completion; join proceeds when all\n- are terminal. Succeeds if no branch failed, else partially succeeds (fan-in\n- fails only when *all* candidates failed).\n-- `first_success`: join proceeds at the first successful branch. Remaining\n- branches are cancelled; cancelled branches record a terminal cancelled stage\n- (their partial responses are not merged back). The sole successful branch is\n- the winner.\n-- `k_of_n` / `quorum` (kilroy has them): deliberately **not** added now. The\n- surface stays minimal until a concrete need appears.\n-\n-## Open questions\n-\n-1. Implementation phasing: subgraph branches (§3) are the largest lift.\n- Response propagation (§6–§9) fixes #490 and both tutorials on its own and\n- can ship first.\n-2. `first_success` cancellation semantics for in-flight agent sessions\n- (graceful stop vs. abort; what the cancelled stage records).\n-3. Exact preamble budget per branch response (§9).\n-4. Context access for deterministic post-fan-in consumers. Command/script\n- nodes have no channel to context (no preamble, no template rendering in\n- `script`), so a deterministic aggregator after fan-in cannot learn\n- candidate `head_sha`s. Candidate mechanisms: a results file under a\n- checkpoint-excluded workspace path, or an env var (e.g.\n- `FABRO_PARALLEL_RESULTS`) pointing at a file outside the workspace. A bare\n- workspace file is ruled out: checkpoint commits `git add -A`, so it would\n- leak into run history and PRs. Design alongside the deterministic fan-out\n- pattern (§4).\n-\n----\n-\n-## Appendix A: pre-existing bugs\n-\n-Cataloged against the current tree; line numbers will drift.\n-\n-1. **Branch outputs dropped (#490).** The fan-out task reads only\n- `outcome.status` and `head_sha` from each branch; branch `context_updates`\n- (including `response.`) are discarded with the forked context\n- (`fabro-workflow/src/handler/parallel.rs:389-397,465-470`). No channel\n- carries branch text to downstream nodes. Confirmed by live repro on\n- fabro-testing (run `01KX148ZAMMJRAADHK1HBF7PC3`, server 0.287.0-nightly.0).\n-2. **Chained branch nodes silently skipped.** Branches execute exactly one\n- node; the engine then jumps to the join (`parallel.rs:388-397,612,628`;\n- `fabro-core/src/executor.rs:421`). In `fork -> a; a -> a2; a2 -> merge`,\n- `a2` never runs and nothing warns.\n-3. **Double fast-forward with two different winner definitions.** The parallel\n- handler fast-forwards the *lexically first* successful branch\n- (`parallel.rs:511-538`); fan-in then fast-forwards *its* selected winner\n- (`fan_in.rs:120-131`). If selection ever picks a non-lexical-first branch,\n- the second `--ff-only` merge cannot succeed (sibling commits diverge).\n- Masked today only because selection is vacuous (A.4).\n-4. **Selection is vacuous.** Heuristic tie-breaks on a `score` field nothing\n- can set (scores would arrive via branch context updates, which are dropped\n- per A.1). The LLM judge prompt is `serde_json::to_string_pretty` of\n- `parallel.results` — id/status/head_sha only (`fan_in.rs:247-250`). Both\n- modes reduce to \"first successful branch, alphabetically.\"\n-5. **Branch preambles are stale.** Branches run via `dispatch_handler`,\n- bypassing the lifecycle's per-node preamble rebuild; each branch node\n- inherits `current.preamble` as computed for the fan-out node itself.\n-6. **Nested parallel silently loses git isolation.** Branch `EngineServices`\n- are built with `git_state: RwLock::new(None)` (`parallel.rs:380`), so a\n- `component` node inside a branch runs its own branches with no worktrees\n- and no warning.\n-7. **Docs contradict the engine.** `tutorials/ensemble.mdx:85` and\n- `tutorials/parallel-review.mdx:81` claim the post-merge node receives all\n- branch perspectives in its preamble (false, per A.1).\n- `workflows/stages-and-nodes.mdx:195` claims merged results are available to\n- downstream nodes as `parallel_results.json` (the file exists only under\n- `stages/@1/` in dumps, not in any node's working directory).\n-8. **`fidelity=\"full\"` across a fan-in is a trap.** It drops the preamble\n- (metadata included) and cannot attach to any branch thread; raising\n- fidelity strictly reduces what the downstream node sees. No lint warns.\n-\n-## Appendix B: behavior changes vs. today\n-\n-Changes a user could observe if this spec is implemented as written.\n-\n-1. **Chained branch nodes execute.** Graphs that (unknowingly) relied on\n- single-node branch semantics will now run the full chain (fixes A.2; may\n- lengthen existing runs).\n-2. **New validation errors.** Branch paths that don't converge on the join,\n- and nested `component` nodes inside branches, become lint failures for\n- graphs that previously ran (with wrong or silently degraded semantics).\n-3. **Fan-in owns the fast-forward.** The workspace after fan-in may land on a\n- different commit than today whenever selection (scores, LLM judge)\n- disagrees with lexical-first order. The parallel handler no longer merges.\n-4. **Post-fan-in context is richer.** `response.` for every branch,\n- winner-sourced singletons (`last_stage`, `last_response`,\n- `command.output`), and `score` in `parallel.results`. Today those\n- singletons retain their pre-fork values; workflows with edge conditions\n- over them could route differently.\n-5. **Preambles after fan-in grow.** Branch responses render inline at\n- `compact` fidelity (bounded). Downstream nodes see more tokens per run;\n- snapshot tests over preambles will churn.\n-6. **Branch executions become visible stages.** Events, dumps, the web UI, and\n- the stage list gain per-branch stages (`a@1` under `fork@1`). Consumers of\n- `events.jsonl` / the API will see new stage records.\n-7. **`parallel_results.json` docs claim is corrected, not implemented.**\n- `workflows/stages-and-nodes.mdx:195` is updated to describe the real\n- channels (context key + preamble); no file appears in the workspace\n- (open question 4 covers deterministic consumers).\n-8. **Loser branch refs are documented as retained** and become part of the\n- product contract instead of an accident of not deleting them.\n-9. **`first_success` cancels losers explicitly** and records cancelled stages;\n- today's exact cancellation behavior is unspecified.\n-10. **LLM judge prompts change shape.** Fan-in nodes with prompts now send\n- candidate excerpts and diff stats to the judge — more tokens, different\n- (better) selections than today's id-only prompt.\n+# Shared-checkout parallel execution strategy\n+\n+Status: implemented.\n+\n+This document defines Fabro's parallel fan-out (`shape=component`) and fan-in\n+(`shape=tripleoctagon`) behavior.\n+\n+## 1. Execution model\n+\n+A parallel node dispatches one branch for each outgoing edge. A branch executes\n+the single target node on that edge; parallel branches are not subgraph walks.\n+Every branch:\n+\n+- receives an independent fork of the parent workflow context;\n+- receives the same `Arc` as the parent run;\n+- inherits the same sandbox working directory and `internal.work_dir`;\n+- runs through the normal handler dispatch path, including dry-run behavior;\n+- retains its branch identity, lifecycle events, and hook scope.\n+\n+Branches execute concurrently. `max_parallel` limits the number that may run at\n+once and defaults to 4. The parallel node always waits for every branch task,\n+even when a branch fails or run cancellation begins. There is no early-success\n+join mode.\n+\n+The parent context is not used as shared mutable branch state. A branch can\n+change its context fork without exposing those changes as top-level values to\n+other branches or to the parent.\n+\n+## 2. Shared checkout\n+\n+All branches use the run's existing sandbox and checkout. Parallel execution\n+creates no branch-specific:\n+\n+- Git refs or branches;\n+- worktrees;\n+- base checkpoints;\n+- commits;\n+- cleanup operations;\n+- merges or fast-forwards.\n+\n+Normal run-level checkpointing still occurs after the parallel node. Any files\n+left in the shared checkout by its branches are captured together by that\n+checkpoint.\n+\n+Read-only parallel work is best effort: an agent or command can still write if\n+its configured capabilities permit it. Concurrent writes are allowed and are\n+entirely user-managed. Fabro does not lock files, enforce read-only access,\n+detect overlapping edits, or warn about races. Workflows that write in parallel\n+should coordinate externally or assign disjoint paths.\n+\n+## 3. Branch results\n+\n+The shared result type is:\n+\n+```rust\n+ParallelBranchResult {\n+ id: String,\n+ status: String,\n+ context_updates: BTreeMap,\n+}\n+```\n+\n+The parallel handler stores one result per outgoing edge in\n+`parallel.results`. Results preserve outgoing-edge order, independent of branch\n+completion order. `parallel.branch_count` stores the number of dispatched\n+branches.\n+\n+`context_updates` includes changes made in the branch context and updates\n+returned by the branch outcome. This applies to successful and failed branches,\n+including structured values, `response.`, and `command.output`.\n+Engine-internal context keys are omitted. A task failure or panic cannot provide\n+updates that were never returned, but its result still preserves the original\n+branch ID and index.\n+\n+Branch updates remain nested in their result. Fabro never merges them into the\n+parent's top-level context, so branches cannot collide through context keys.\n+\n+The parallel stage outcome is:\n+\n+- `succeeded` when every branch succeeds;\n+- `failed` when every branch fails;\n+- `partially_succeeded` for mixed outcomes, partial outcomes, and zero branches.\n+\n+## 4. Artifacts and downstream context\n+\n+Large context values use the normal artifact store. Offloading recursively\n+replaces oversized leaf values while retaining the object and array structure\n+of `parallel.results`.\n+\n+When Fabro constructs execution or prompt context, it resolves nested textual\n+blob references under `response.*` and `command.output`, including those keys\n+inside a branch result's `context_updates`. This lets a prompted fan-in inspect\n+complete branch text without flattening branch state into the parent context.\n+\n+`parallel.results` is runtime context. Fabro does not materialize a\n+`parallel_results.json` file in the workspace. Diagnostic run dumps may export\n+stage projection data, but that export is not a workflow handoff mechanism and\n+is not visible as a checkout file to downstream nodes.\n+\n+## 5. Fan-in\n+\n+Fan-in is an explicit join node.\n+\n+A fan-in node without a nonblank prompt validates that `parallel.results`\n+exists and deserializes as typed branch results. It then succeeds with a joined\n+branches note. It is a no-op barrier: it does not alter context or workspace\n+state.\n+\n+A fan-in node with a prompt delegates to the standard prompt handler. It sees\n+the aggregated runtime context in the normal prompt preamble and records the\n+normal prompt-stage outputs:\n+\n+- `response.`;\n+- `last_response`;\n+- model usage and timing;\n+- prompt and response events.\n+\n+A prompted fan-in synthesizes results. It does not rank branches, select a\n+winner, restore files, or choose workspace state.\n+\n+## 6. Events and projections\n+\n+Parallel execution emits:\n+\n+- `parallel.started` with `visit` and `branch_count`;\n+- `parallel.branch.started` with stable branch identity and index;\n+- `parallel.branch.completed` with index, duration, and status;\n+- `parallel.completed` with counts and the ordered typed result array.\n+\n+Every branch task emits one terminal branch completion event, including handler\n+failure, cancellation before semaphore acquisition, panic, or join failure.\n+The final typed array is also projected into\n+`StageProjection.parallel_results`.\n+\n+## 7. Cancellation\n+\n+Semaphore acquisition observes the run cancellation token. Branches waiting for\n+a permit can terminate as cancelled rather than waiting indefinitely. Branches\n+already executing continue through their handler's cooperative cancellation\n+path. The parallel handler joins every task before returning cancellation to the\n+run executor.\n+\n+Cancellation does not trigger branch Git cleanup because no branch Git state is\n+created.\n+\n+## 8. Product constraints\n+\n+- Branches remain single-node executions.\n+- `max_parallel` remains supported.\n+- Results are deterministic in outgoing-edge order.\n+- There is no branch-selection score, SHA, model-usage mode, notice, or UI.\n+- Server-owned independent checkout/worktree behavior is separate and remains\n+ unchanged.\ndiff --git a/docs/public/api-reference/fabro-api.yaml b/docs/public/api-reference/fabro-api.yaml\nindex d6e384405..a39a4c414 100644\n--- a/docs/public/api-reference/fabro-api.yaml\n+++ b/docs/public/api-reference/fabro-api.yaml\n@@ -10411,6 +10411,22 @@ components:\n items:\n $ref: \"#/components/schemas/StageContextWindowWarning\"\n \n+ ParallelBranchResult:\n+ description: The outcome and isolated context updates from one parallel branch.\n+ type: object\n+ required:\n+ - id\n+ - status\n+ - context_updates\n+ properties:\n+ id:\n+ type: string\n+ status:\n+ $ref: \"#/components/schemas/StageOutcome\"\n+ context_updates:\n+ type: object\n+ additionalProperties: true\n+\n StageProjection:\n description: Observable projection data for one workflow stage execution.\n type: object\n@@ -10447,8 +10463,8 @@ components:\n parallel_results:\n type: [\"array\", \"null\"]\n items:\n- type: object\n- description: Per-branch result objects produced by a parallel stage.\n+ $ref: \"#/components/schemas/ParallelBranchResult\"\n+ description: Ordered per-branch results produced by a parallel stage.\n output:\n type: [\"string\", \"null\"]\n output_bytes:\ndiff --git a/docs/public/examples/clone-substack.mdx b/docs/public/examples/clone-substack.mdx\nindex cc11157f7..3529436a2 100644\n--- a/docs/public/examples/clone-substack.mdx\n+++ b/docs/public/examples/clone-substack.mdx\n@@ -153,8 +153,8 @@ Write to .workflow/plan_b.md.\"\n label=\"Debate & Consolidate\",\n prompt=\"Synthesize the two implementation plans into a single best-of-breed \\\n final plan.\\n\\n\\\n-Read branch outputs via parallel_results.json. If parallel_results.json is missing, \\\n-fall back to reading .workflow/plan_a.md and .workflow/plan_b.md.\\n\\n\\\n+Review every branch result in parallel.results from the prompt context, then read \\\n+.workflow/plan_a.md and .workflow/plan_b.md from the shared checkout.\\n\\n\\\n If .workflow/postmortem_latest.md exists, read it FIRST. The postmortem contains \\\n root-cause analysis and concrete fixes from the previous iteration. The final plan \\\n MUST be adjusted to address every issue identified in the postmortem — add new \\\n@@ -481,8 +481,8 @@ Write to .workflow/review_b.md.\"\n goal_gate=true,\n retry_target=\"postmortem\",\n prompt=\"Synthesize the two reviews into a consensus verdict.\\n\\n\\\n-Read branch outputs via parallel_results.json. If parallel_results.json is \\\n-missing, fall back to reading .workflow/review_a.md and .workflow/review_b.md.\\n\\n\\\n+Review every branch result in parallel.results from the prompt context, then read \\\n+.workflow/review_a.md and .workflow/review_b.md from the shared checkout.\\n\\n\\\n Read .workflow/definition_of_done.md for acceptance criteria reference.\\n\\n\\\n Consensus rules:\\n\\\n - Both APPROVED with no critical gaps: the implementation passes\\n\\\n@@ -513,7 +513,7 @@ Read (if they exist):\\n\\\n - .workflow/test-evidence/latest/manifest.json\\n\\\n - Evidence files referenced by manifest entries for failed or suspicious IT \\\n scenarios\\n\\\n-- Branch review outputs via parallel_results.json (if available)\\n\\n\\\n+- Parallel branch review status and context updates from parallel.results (if available)\\n\\n\\\n Output to .workflow/postmortem_latest.md (overwrite previous):\\n\\\n - Root causes of failure\\n\\\n - What works and must be preserved\\n\\\ndiff --git a/docs/public/execution/context.mdx b/docs/public/execution/context.mdx\nindex c67012b3c..25f6e536d 100644\n--- a/docs/public/execution/context.mdx\n+++ b/docs/public/execution/context.mdx\n@@ -57,13 +57,14 @@ Agents can also emit arbitrary context updates by including a JSON object with a\n | `human.gate..answer` | The answer text for a specific human gate node |\n | `human.gate..label` | The selected label for a specific human gate node, when applicable |\n \n-### Parallel merge (fan-in)\n+### Parallel fan-out and fan-in\n \n | Key | Value |\n |---|---|\n-| `parallel.fan_in.best_id` | Node ID of the best-performing branch |\n-| `parallel.fan_in.best_outcome` | Status of the best branch |\n-| `parallel.fan_in.best_head_sha` | Git SHA from the best branch (if applicable) |\n+| `parallel.results` | Ordered branch results. Each entry contains `id`, `status`, and the branch's isolated `context_updates`. |\n+| `parallel.branch_count` | Number of outgoing branches dispatched by the parallel node. |\n+\n+Branch updates remain nested inside `parallel.results`; they are not merged into top-level context. Prompted fan-in nodes can synthesize the complete result array, while promptless fan-in nodes act as barriers.\n \n ### Engine-managed keys\n \ndiff --git a/docs/public/execution/outcomes.mdx b/docs/public/execution/outcomes.mdx\nindex 5ddbdc774..4ebf04a96 100644\n--- a/docs/public/execution/outcomes.mdx\n+++ b/docs/public/execution/outcomes.mdx\n@@ -26,7 +26,7 @@ Each node type has its own rules for which outcomes it can return:\n |---|---|---|\n | **Command** | `succeeded`, `failed` | `succeeded` when exit code is 0; `failed` otherwise |\n | **Agent / Prompt** | `succeeded`, `failed`, `partially_succeeded`, `skipped` | Defaults to `succeeded`. The LLM can set any outcome via a [routing directive](/agents/outputs#routing-directives) JSON object in its response. Backend errors request retry when retryable or finish as `failed`. |\n-| **Parallel** | `succeeded`, `partially_succeeded`, `failed` | Depends on the `join_policy`. `wait_all`: `succeeded` if no failures, `partially_succeeded` if some branches failed. `first_success`: `succeeded` if threshold met, else `failed`. |\n+| **Parallel** | `succeeded`, `partially_succeeded`, `failed` | Waits for every branch. `succeeded` when all branches succeed, `failed` when all branches fail, and `partially_succeeded` for mixed, partial, or zero-branch results. |\n | **Human** | `succeeded` | Always succeeds — the user's selection becomes a routing signal via `preferred_label` |\n | **Conditional** | `succeeded` | Always succeeds — routing is handled by the engine's edge selection |\n | **Start / Exit / Wait** | `succeeded` | Always succeed |\ndiff --git a/docs/public/reference/dot-language.mdx b/docs/public/reference/dot-language.mdx\nindex f01fbf2ed..678d5ac23 100644\n--- a/docs/public/reference/dot-language.mdx\n+++ b/docs/public/reference/dot-language.mdx\n@@ -249,8 +249,7 @@ audit [\n \n | Attribute | Type | Description |\n |---|---|---|\n-| `join_policy` | String | When the merge can proceed: `wait_all` (default), `first_success` |\n-| `max_parallel` | Integer | Maximum concurrent branches (default: 4) |\n+| `max_parallel` | Integer | Maximum concurrent branches (default: 4). The node always waits for every branch. |\n \n ### Wait nodes\n \ndiff --git a/docs/public/tutorials/ensemble.mdx b/docs/public/tutorials/ensemble.mdx\nindex e6ff5adb8..c134c8d26 100644\n--- a/docs/public/tutorials/ensemble.mdx\n+++ b/docs/public/tutorials/ensemble.mdx\n@@ -1,6 +1,6 @@\n ---\n title: \"Ensemble\"\n-description: \"Multi-provider fan-out, error policies, and result synthesis\"\n+description: \"Multi-provider fan-out and shared-checkout result synthesis\"\n ---\n \n This tutorial combines parallel execution with multi-model routing to get independent opinions from four different LLM providers, then synthesizes the results. This is the ensemble pattern — useful when you want diverse perspectives, consensus-based decisions, or protection against any single model's blind spots.\n@@ -28,15 +28,15 @@ digraph Ensemble {\n start [shape=Mdiamond, label=\"Start\"]\n exit [shape=Msquare, label=\"Exit\"]\n \n- fork [label=\"Fan Out\", shape=component, join_policy=\"wait_all\"]\n+ fork [label=\"Fan Out\", shape=component]\n \n opus [label=\"Opus\", prompt=\"Analyze the goal. Provide your independent assessment, recommendations, and any code or prose needed. Be thorough.\", shape=tab]\n gemini [label=\"Gemini\", prompt=\"Analyze the goal. Provide your independent assessment, recommendations, and any code or prose needed. Be thorough.\", shape=tab]\n codex [label=\"Codex\", prompt=\"Analyze the goal. Provide your independent assessment, recommendations, and any code or prose needed. Be thorough.\", shape=tab]\n mercury [label=\"Mercury\", prompt=\"Analyze the goal. Provide your independent assessment, recommendations, and any code or prose needed. Be thorough.\", shape=tab]\n \n- merge [label=\"Merge\", shape=tripleoctagon]\n- synth [label=\"Synthesize\", prompt=\"You have received independent analyses from four different models (Opus, Gemini, Codex, Mercury). Compare their perspectives: identify consensus, highlight disagreements, and synthesize the strongest ideas into a single coherent recommendation. Note where models agreed and where they diverged.\", shape=tab]\n+ merge [label=\"Synthesize\", shape=tripleoctagon, prompt=\"Compare every branch result: identify consensus, highlight disagreements, and synthesize the strongest ideas into one recommendation. Note where models agreed and diverged.\"]\n+ synth [label=\"Write Recommendation\", prompt=\"Write the synthesized analysis as a single coherent recommendation.\", shape=tab]\n \n start -> fork\n fork -> opus\ndiff --git a/docs/public/tutorials/parallel-review.mdx b/docs/public/tutorials/parallel-review.mdx\nindex 41f589bea..39f6fd7f8 100644\n--- a/docs/public/tutorials/parallel-review.mdx\n+++ b/docs/public/tutorials/parallel-review.mdx\n@@ -1,6 +1,6 @@\n ---\n title: \"Parallel Review\"\n-description: \"Fan-out, fan-in, join policies, and merge nodes\"\n+description: \"Shared-checkout fan-out, fan-in, and result synthesis\"\n ---\n \n This tutorial runs three code review perspectives in parallel — security, architecture, and quality — then merges the results into a single report.\n@@ -19,14 +19,14 @@ digraph Parallel {\n start [shape=Mdiamond, label=\"Start\"]\n exit [shape=Msquare, label=\"Exit\"]\n \n- fork [label=\"Fork Analysis\", shape=component, join_policy=\"wait_all\"]\n+ fork [label=\"Fork Analysis\", shape=component]\n \n security [label=\"Security Audit\", prompt=\"Examine the codebase for security concerns: hardcoded secrets, injection risks, unsafe dependencies. List findings as bullet points.\", shape=tab, reasoning_effort=\"low\"]\n architecture [label=\"Architecture Review\", prompt=\"Assess the codebase architecture: separation of concerns, dependency structure, modularity. List findings as bullet points.\", shape=tab, reasoning_effort=\"low\"]\n quality [label=\"Code Quality\", prompt=\"Check code quality: naming conventions, dead code, test coverage gaps, error handling. List findings as bullet points.\", shape=tab, reasoning_effort=\"low\"]\n \n- merge [label=\"Merge Findings\", shape=tripleoctagon]\n- report [label=\"Final Report\", prompt=\"Synthesize the security, architecture, and code quality findings into a prioritized summary report with top 5 action items.\", shape=tab]\n+ merge [label=\"Synthesize Findings\", shape=tripleoctagon, prompt=\"Synthesize every branch result into a prioritized summary report with the top 5 action items.\"]\n+ report [label=\"Write Final Report\", prompt=\"Write the synthesized review as a clear final report.\", shape=tab]\n \n start -> fork\n fork -> security\n@@ -48,37 +48,38 @@ fabro run docs/internal/demo/06-parallel.fabro\n The `fork` node has `shape=component`, making it a **parallel fan-out node**. Every outgoing edge becomes a concurrent branch:\n \n ```dot\n-fork [label=\"Fork Analysis\", shape=component, join_policy=\"wait_all\"]\n+fork [label=\"Fork Analysis\", shape=component]\n \n fork -> security\n fork -> architecture\n fork -> quality\n ```\n \n-All three branches start at the same time. Each gets an isolated copy of the run context, so branches can't interfere with each other.\n+All three branches start concurrently and operate in the same sandbox checkout and working directory. This review is safe because the branches are prompt nodes with no workspace tools.\n \n-### Join policies\n+\n+Parallel branches are not isolated from one another. If branches edit files, they can observe, race with, or overwrite each other's changes. Fabro does not lock files, detect overlapping writes, or warn about races. Keep branches read-only or give each branch responsibility for disjoint paths.\n+\n \n-The `join_policy` controls when execution can proceed past the merge:\n-\n-| Policy | Behavior |\n-|---|---|\n-| `wait_all` | Wait for every branch to finish (default) |\n-| `first_success` | Proceed as soon as one branch succeeds |\n+The parallel node always waits for every branch to finish before continuing.\n \n ## Fan-in with the merge node\n \n-The `merge` node has `shape=tripleoctagon`, making it a **merge (fan-in) node**. It collects results from all branches into a single context:\n+The `merge` node has `shape=tripleoctagon`, making it a **merge (fan-in) node**. It receives every branch's status and context updates in `parallel.results` and synthesizes them with its prompt:\n \n ```dot\n-merge [label=\"Merge Findings\", shape=tripleoctagon]\n+merge [\n+ label=\"Synthesize Findings\",\n+ shape=tripleoctagon,\n+ prompt=\"Synthesize every branch result into a prioritized summary report with the top 5 action items.\"\n+]\n \n security -> merge\n architecture -> merge\n quality -> merge\n ```\n \n-The merged branch results are available to downstream nodes. The `report` node receives all three perspectives in its preamble and synthesizes them.\n+`parallel.results` is runtime context, not a `parallel_results.json` file in the checkout. Fan-in never selects a branch's files or changes workspace state; every branch has already worked in the same checkout. The downstream `report` node writes the synthesis produced by fan-in as the final report.\n \n ## Concurrency control\n \n@@ -92,10 +93,10 @@ This is useful when branches are resource-intensive (e.g., each running a full a\n \n ## What you've learned\n \n-- **Fan-out nodes** (`shape=component`) spawn concurrent branches\n-- **Merge nodes** (`shape=tripleoctagon`) collect branch results\n-- **Join policies** control when execution can proceed past the merge\n-- Each branch gets an isolated copy of the context\n+- **Fan-out nodes** (`shape=component`) spawn concurrent branches and wait for all of them\n+- Parallel branches share one checkout, so workflows must prevent or tolerate file races\n+- **Fan-in nodes** (`shape=tripleoctagon`) can synthesize `parallel.results` with a prompt\n+- Fan-in never selects or restores workspace state, and no results file is created\n \n ## Next\n \ndiff --git a/docs/public/workflows/stages-and-nodes.mdx b/docs/public/workflows/stages-and-nodes.mdx\nindex 1dd0f71ac..9263d1ea7 100644\n--- a/docs/public/workflows/stages-and-nodes.mdx\n+++ b/docs/public/workflows/stages-and-nodes.mdx\n@@ -155,10 +155,10 @@ Conditions support `=`, `!=`, `&&`, and context variable lookups (e.g. `context.\n \n **Shape:** `component`\n \n-Fans out to execute multiple branches concurrently. Each branch gets its own isolated context.\n+Fans out to execute multiple branches concurrently. Every branch runs in the same sandbox checkout and working directory, and the parallel node waits for every branch to finish.\n \n ```dot\n-fork [label=\"Fan Out\", shape=component, join_policy=\"wait_all\"]\n+fork [label=\"Fan Out\", shape=component]\n \n fork -> security\n fork -> architecture\n@@ -167,24 +167,22 @@ fork -> quality\n \n | Attribute | Description |\n |---|---|\n-| `join_policy` | When the merge can proceed (see table below) |\n | `max_parallel` | Maximum concurrent branches (default: 4) |\n \n-**Join policies:**\n-\n-| Policy | Behavior |\n-|---|---|\n-| `wait_all` | Wait for every branch to finish (default) |\n-| `first_success` | Proceed as soon as one branch succeeds |\n+Because the checkout is shared, file changes from one branch are immediately visible to the others. Concurrent writes can race or overwrite each other. Fabro does not isolate branch files, lock paths, detect conflicts, or warn about overlapping writes. Design branches to be read-only or assign each branch disjoint files and directories when deterministic workspace changes matter.\n \n ### Merge (fan-in)\n \n **Shape:** `tripleoctagon`\n \n-Collects results from parallel branches into a single context. Typically paired with a parallel fan-out node:\n+Converges parallel branches after all of them finish. Branch status and context updates are collected in the runtime context at `parallel.results`; Fabro does not create a `parallel_results.json` file in the checkout.\n \n ```dot\n-merge [label=\"Merge Results\", shape=tripleoctagon]\n+merge [\n+ label=\"Synthesize Results\",\n+ shape=tripleoctagon,\n+ prompt=\"Synthesize every branch result into one report.\"\n+]\n \n security -> merge\n architecture -> merge\n@@ -192,7 +190,7 @@ quality -> merge\n merge -> report\n ```\n \n-The merged results are available to downstream nodes as `parallel_results.json`.\n+A fan-in node with a `prompt` synthesizes the collected results. It never chooses, restores, or merges a branch's workspace state: all branches have already operated on the same checkout. Without a prompt, fan-in is only a convergence barrier.\n \n ## Common node attributes\n \ndiff --git a/lib/crates/fabro-agent/src/lib.rs b/lib/crates/fabro-agent/src/lib.rs\nindex 47aa8cd75..fd6f84db7 100644\n--- a/lib/crates/fabro-agent/src/lib.rs\n+++ b/lib/crates/fabro-agent/src/lib.rs\n@@ -57,8 +57,7 @@ pub use read_before_write_sandbox::ReadBeforeWriteSandbox;\n pub use sandbox::{\n CommandOutputCallback, DirEntry, ExecResult, ExecStreamingResult, GrepOptions, RefreshOutcome,\n Sandbox, SandboxEvent, SandboxEventCallback, StderrCollector, StdioProcess, StdioProcessHandle,\n- WorktreeEvent, WorktreeEventCallback, WorktreeOptions, WorktreeSandbox, format_lines_numbered,\n- shell_quote,\n+ format_lines_numbered, shell_quote,\n };\n pub use session::{\n CompletionCoordinator, Session, SessionControlHandle, SessionInputTiming, StaticEnvProvider,\ndiff --git a/lib/crates/fabro-agent/src/sandbox.rs b/lib/crates/fabro-agent/src/sandbox.rs\nindex 5884876ab..3ba22c7a9 100644\n--- a/lib/crates/fabro-agent/src/sandbox.rs\n+++ b/lib/crates/fabro-agent/src/sandbox.rs\n@@ -4,6 +4,5 @@\n pub use fabro_sandbox::{\n CommandOutputCallback, DirEntry, ExecResult, ExecStreamingResult, GrepOptions, RefreshOutcome,\n Sandbox, SandboxEvent, SandboxEventCallback, StderrCollector, StdioProcess, StdioProcessHandle,\n- StdioProcessTermination, WorktreeEvent, WorktreeEventCallback, WorktreeOptions,\n- WorktreeSandbox, delegate_sandbox, format_lines_numbered, shell_quote,\n+ StdioProcessTermination, delegate_sandbox, format_lines_numbered, shell_quote,\n };\ndiff --git a/lib/crates/fabro-api/build.rs b/lib/crates/fabro-api/build.rs\nindex 0a81b9845..3fa54d1a6 100644\n--- a/lib/crates/fabro-api/build.rs\n+++ b/lib/crates/fabro-api/build.rs\n@@ -348,6 +348,11 @@ fn main() {\n (\"StageState\", \"fabro_types::StageState\", &[]),\n (\"CommandTermination\", \"fabro_types::CommandTermination\", &[]),\n (\"StageModelUsage\", \"fabro_types::StageModelUsage\", &[]),\n+ (\n+ \"ParallelBranchResult\",\n+ \"fabro_types::ParallelBranchResult\",\n+ &[],\n+ ),\n (\"StageProjection\", \"fabro_types::StageProjection\", &[]),\n (\"PermissionLevel\", \"fabro_types::PermissionLevel\", &[]),\n (\ndiff --git a/lib/crates/fabro-api/src/lib.rs b/lib/crates/fabro-api/src/lib.rs\nindex da898f8c2..b8401950c 100644\n--- a/lib/crates/fabro-api/src/lib.rs\n+++ b/lib/crates/fabro-api/src/lib.rs\n@@ -50,8 +50,8 @@ pub mod types {\n McpServerReplace as ReplaceMcpServerRequest, McpServerStatus, McpServerView as McpServer,\n McpTransportView, Message, PairId, PairMessageId, PairMessageRecord, PairMessageRequest,\n PairRecord, PairStartRequest, PairStatus, PairTarget, PairTranscriptEntry,\n- PairTranscriptResponse, PendingInterviewRecord, PermissionLevel, PreRunPushOutcome,\n- Principal, PullRequest, PullRequestDetails, PullRequestDetailsStatus,\n+ PairTranscriptResponse, ParallelBranchResult, PendingInterviewRecord, PermissionLevel,\n+ PreRunPushOutcome, Principal, PullRequest, PullRequestDetails, PullRequestDetailsStatus,\n PullRequestDetailsUnavailableReason, PullRequestLink, PullRequestMeta, PullRequestResponse,\n QuestionType, RepositoryRef, Role, Run, RunApproval, RunApprovalState, RunClientProvenance,\n RunEvent, RunEventDetailContentKind, RunEventDetailResponse, RunFailure,\ndiff --git a/lib/crates/fabro-api/tests/run_event_round_trip.rs b/lib/crates/fabro-api/tests/run_event_round_trip.rs\nindex c36c1e67f..4f4ea5e99 100644\n--- a/lib/crates/fabro-api/tests/run_event_round_trip.rs\n+++ b/lib/crates/fabro-api/tests/run_event_round_trip.rs\n@@ -250,6 +250,65 @@ fn run_event_round_trips_stage_started() {\n assert_run_event_round_trip(value);\n }\n \n+#[test]\n+fn run_event_round_trips_parallel_public_contracts() {\n+ assert_run_event_round_trip(json!({\n+ \"id\": \"evt_parallel_started\",\n+ \"ts\": \"2026-04-29T12:02:00Z\",\n+ \"run_id\": fixtures::RUN_1,\n+ \"event\": \"parallel.started\",\n+ \"node_id\": \"fanout\",\n+ \"node_label\": \"Fanout\",\n+ \"parallel_group_id\": \"fanout@2\",\n+ \"properties\": {\n+ \"visit\": 2,\n+ \"branch_count\": 2\n+ }\n+ }));\n+ assert_run_event_round_trip(json!({\n+ \"id\": \"evt_parallel_branch_completed\",\n+ \"ts\": \"2026-04-29T12:02:01Z\",\n+ \"run_id\": fixtures::RUN_1,\n+ \"event\": \"parallel.branch.completed\",\n+ \"node_id\": \"review_api\",\n+ \"node_label\": \"Review API\",\n+ \"parallel_group_id\": \"fanout@2\",\n+ \"parallel_branch_id\": \"fanout@2:0\",\n+ \"properties\": {\n+ \"index\": 0,\n+ \"duration_ms\": 1000,\n+ \"status\": \"succeeded\"\n+ }\n+ }));\n+ assert_run_event_round_trip(json!({\n+ \"id\": \"evt_parallel_completed\",\n+ \"ts\": \"2026-04-29T12:02:02Z\",\n+ \"run_id\": fixtures::RUN_1,\n+ \"event\": \"parallel.completed\",\n+ \"node_id\": \"fanout\",\n+ \"node_label\": \"Fanout\",\n+ \"parallel_group_id\": \"fanout@2\",\n+ \"properties\": {\n+ \"visit\": 2,\n+ \"duration_ms\": 2000,\n+ \"success_count\": 1,\n+ \"failure_count\": 1,\n+ \"results\": [\n+ {\n+ \"id\": \"review_api\",\n+ \"status\": \"succeeded\",\n+ \"context_updates\": {\"response.review_api\": \"looks good\"}\n+ },\n+ {\n+ \"id\": \"review_ux\",\n+ \"status\": \"failed\",\n+ \"context_updates\": {}\n+ }\n+ ]\n+ }\n+ }));\n+}\n+\n #[test]\n fn run_event_round_trips_agent_tool_started() {\n let value = json!({\ndiff --git a/lib/crates/fabro-api/tests/stage_projection_round_trip.rs b/lib/crates/fabro-api/tests/stage_projection_round_trip.rs\nindex 2f26ad612..7a680b135 100644\n--- a/lib/crates/fabro-api/tests/stage_projection_round_trip.rs\n+++ b/lib/crates/fabro-api/tests/stage_projection_round_trip.rs\n@@ -7,8 +7,8 @@ use fabro_api::types::{\n AgentToolSource as ApiAgentToolSource, AgentToolSummary as ApiAgentToolSummary,\n AgentToolsAvailableProps as ApiAgentToolsAvailableProps,\n McpServerProjection as ApiMcpServerProjection, McpServerStatus as ApiMcpServerStatus,\n- PermissionLevel as ApiPermissionLevel, SkillsProjection as ApiSkillsProjection,\n- StageContextWindow as ApiStageContextWindow,\n+ ParallelBranchResult as ApiParallelBranchResult, PermissionLevel as ApiPermissionLevel,\n+ SkillsProjection as ApiSkillsProjection, StageContextWindow as ApiStageContextWindow,\n StageContextWindowBreakdownItem as ApiStageContextWindowBreakdownItem,\n StageContextWindowCategory as ApiStageContextWindowCategory,\n StageContextWindowCountMethod as ApiStageContextWindowCountMethod,\n@@ -22,11 +22,11 @@ use fabro_api::types::{\n use fabro_types::{\n ActivatedSkill, AgentMcpToolSummary, AgentSkillActivationSource, AgentSkillSummary,\n AgentToolCategory, AgentToolSource, AgentToolSummary, AgentToolsAvailableProps,\n- McpServerProjection, McpServerStatus, PermissionLevel, SkillsProjection, StageContextWindow,\n- StageContextWindowBreakdownItem, StageContextWindowCategory, StageContextWindowCountMethod,\n- StageContextWindowProjection, StageContextWindowStaleness, StageContextWindowUnavailableReason,\n- StageContextWindowWarning, StageProjection, SubAgentProjection, SubAgentStatus, TodoListKind,\n- TodoListProjection,\n+ McpServerProjection, McpServerStatus, ParallelBranchResult, PermissionLevel, SkillsProjection,\n+ StageContextWindow, StageContextWindowBreakdownItem, StageContextWindowCategory,\n+ StageContextWindowCountMethod, StageContextWindowProjection, StageContextWindowStaleness,\n+ StageContextWindowUnavailableReason, StageContextWindowWarning, StageProjection,\n+ SubAgentProjection, SubAgentStatus, TodoListKind, TodoListProjection,\n };\n use serde_json::json;\n \n@@ -37,6 +37,7 @@ fn stage_projection_reuses_canonical_type() {\n \n #[test]\n fn stage_projection_reuses_nested_agent_state_types() {\n+ assert_same_type::();\n assert_same_type::();\n assert_same_type::();\n assert_same_type::();\n@@ -85,7 +86,21 @@ fn stage_projection_round_trips_representative_json() {\n \"diff\": \"diff --git a/file b/file\",\n \"script_invocation\": { \"command\": \"cargo test\" },\n \"script_timing\": { \"duration_ms\": 42 },\n- \"parallel_results\": [{ \"branch\": 0, \"status\": \"succeeded\" }],\n+ \"parallel_results\": [\n+ {\n+ \"id\": \"review_api\",\n+ \"status\": \"succeeded\",\n+ \"context_updates\": {\n+ \"response.review_api\": \"looks good\",\n+ \"score\": 0.95\n+ }\n+ },\n+ {\n+ \"id\": \"review_ux\",\n+ \"status\": \"failed\",\n+ \"context_updates\": {}\n+ }\n+ ],\n \"output\": \"ok\",\n \"termination\": \"exited\",\n \"started_at\": \"2026-04-29T12:34:00Z\",\ndiff --git a/lib/crates/fabro-cli/src/commands/run/run_progress/event.rs b/lib/crates/fabro-cli/src/commands/run/run_progress/event.rs\nindex a4a72f0bc..6f8498411 100644\n--- a/lib/crates/fabro-cli/src/commands/run/run_progress/event.rs\n+++ b/lib/crates/fabro-cli/src/commands/run/run_progress/event.rs\n@@ -130,7 +130,7 @@ pub(super) enum ProgressEvent {\n ParallelBranchCompleted {\n branch: String,\n duration_ms: u64,\n- status: String,\n+ status: fabro_types::StageOutcome,\n },\n ParallelCompleted,\n AssistantMessage {\n@@ -318,7 +318,7 @@ pub(super) fn from_run_event(stored: &RunEvent) -> Option {\n EventBody::ParallelBranchCompleted(props) => Some(ProgressEvent::ParallelBranchCompleted {\n branch: node_id,\n duration_ms: props.duration_ms,\n- status: props.status.clone(),\n+ status: props.status,\n }),\n EventBody::ParallelCompleted(_) => Some(ProgressEvent::ParallelCompleted),\n EventBody::AgentMessage(props) => Some(ProgressEvent::AssistantMessage {\ndiff --git a/lib/crates/fabro-cli/src/commands/run/run_progress/mod.rs b/lib/crates/fabro-cli/src/commands/run/run_progress/mod.rs\nindex 6e1a6227b..24570dac4 100644\n--- a/lib/crates/fabro-cli/src/commands/run/run_progress/mod.rs\n+++ b/lib/crates/fabro-cli/src/commands/run/run_progress/mod.rs\n@@ -248,7 +248,7 @@ impl ProgressUI {\n status,\n } => {\n self.stage\n- .on_parallel_branch_completed(renderer, &branch, duration_ms, &status);\n+ .on_parallel_branch_completed(renderer, &branch, duration_ms, status);\n }\n ProgressEvent::ParallelCompleted => {\n self.stage.on_parallel_completed();\n@@ -587,7 +587,6 @@ mod tests {\n node_id: \"fork1\".into(),\n visit: 1,\n branch_count: 2,\n- join_policy: \"wait_all\".into(),\n });\n assert_eq!(ui.stage.parallel_parent.as_deref(), Some(\"fork1\"));\n \n@@ -611,8 +610,7 @@ mod tests {\n branch: \"security\".into(),\n index: 0,\n duration_ms: 2000,\n- status: \"succeeded\".into(),\n- head_sha: None,\n+ status: fabro_workflow::outcome::StageOutcome::Succeeded,\n });\n let stage = &ui.stage.active_stages[\"fork1\"];\n assert!(matches!(\n@@ -630,7 +628,6 @@ mod tests {\n node_id: \"fork1\".into(),\n visit: 1,\n branch_count: 1,\n- join_policy: \"wait_all\".into(),\n });\n emit(&mut ui, Event::ParallelBranchStarted {\n parallel_group_id: StageId::new(\"fork1\", 1),\n@@ -1246,7 +1243,6 @@ mod tests {\n node_id: \"fork1\".into(),\n visit: 1,\n branch_count: 1,\n- join_policy: \"wait_all\".into(),\n });\n emit(&mut ui, Event::ParallelBranchStarted {\n parallel_group_id: StageId::new(\"fork1\", 1),\n@@ -1260,8 +1256,7 @@ mod tests {\n branch: \"security\".into(),\n index: 0,\n duration_ms: 500,\n- status: \"succeeded\".into(),\n- head_sha: None,\n+ status: fabro_workflow::outcome::StageOutcome::Succeeded,\n });\n \n let stage = &ui.stage.active_stages[\"fork1\"];\ndiff --git a/lib/crates/fabro-cli/src/commands/run/run_progress/stage_display.rs b/lib/crates/fabro-cli/src/commands/run/run_progress/stage_display.rs\nindex 13014c2a0..b58568b48 100644\n--- a/lib/crates/fabro-cli/src/commands/run/run_progress/stage_display.rs\n+++ b/lib/crates/fabro-cli/src/commands/run/run_progress/stage_display.rs\n@@ -244,7 +244,7 @@ impl StageDisplay {\n renderer: &ProgressRenderer,\n branch: &str,\n duration_ms: u64,\n- status: &str,\n+ status: StageOutcome,\n ) {\n let Some(parent_id) = self.parallel_parent.clone() else {\n return;\n@@ -261,7 +261,7 @@ impl StageDisplay {\n return;\n };\n \n- let succeeded = matches!(status, \"succeeded\" | \"partially_succeeded\");\n+ let succeeded = status.is_successful();\n entry.status = if succeeded {\n ToolCallStatus::Succeeded\n } else {\ndiff --git a/lib/crates/fabro-cli/tests/it/workflow/dry_run_examples.rs b/lib/crates/fabro-cli/tests/it/workflow/dry_run_examples.rs\nindex bb8a50c85..658dd1fea 100644\n--- a/lib/crates/fabro-cli/tests/it/workflow/dry_run_examples.rs\n+++ b/lib/crates/fabro-cli/tests/it/workflow/dry_run_examples.rs\n@@ -80,7 +80,9 @@ fn dry_run_parallel() {\n let mut cmd = context.run_cmd();\n cmd.args([\"--dry-run\", \"--auto-approve\"]);\n cmd.arg(&workflow);\n- fabro_snapshot!(run_output_filters(&context), cmd, @\"\n+ let mut filters = run_output_filters(&context);\n+ filters.push((r\"\\bbranch[12]\\b\".to_string(), \"[BRANCH]\".to_string()));\n+ fabro_snapshot!(filters, cmd, @\"\n success: true\n exit_code: 0\n ----- stdout -----\n@@ -93,6 +95,8 @@ fn dry_run_parallel() {\n Web UI: http://localhost:3000/runs/[ULID]\n Sandbox: local (ready in [TIME])\n ✓ start [TIME]\n+ ✓ [BRANCH] [TIME]\n+ ✓ [BRANCH] [TIME]\n ✓ Fork Work [TIME]\n ✓ Merge Results [TIME]\n ✓ Review [TIME]\ndiff --git a/lib/crates/fabro-dump/src/lib.rs b/lib/crates/fabro-dump/src/lib.rs\nindex b4da2215c..d8378cbd5 100644\n--- a/lib/crates/fabro-dump/src/lib.rs\n+++ b/lib/crates/fabro-dump/src/lib.rs\n@@ -127,7 +127,7 @@ impl RunDump {\n if let Some(parallel_results) = stage.parallel_results.as_ref() {\n entries.push(RunDumpEntry::json_path(\n &base.join(\"parallel_results.json\"),\n- parallel_results.clone(),\n+ serde_json::to_value(parallel_results)?,\n ));\n }\n if let Some(output) = stage.output.as_ref() {\n@@ -605,7 +605,14 @@ mod tests {\n stage.diff = Some(\"diff --git a/a b/a\".to_string());\n stage.script_invocation = Some(serde_json::json!({ \"command\": \"cargo test\" }));\n stage.script_timing = Some(serde_json::json!({ \"duration_ms\": 10 }));\n- stage.parallel_results = Some(serde_json::json!([{ \"stage\": \"fanout@1\" }]));\n+ stage.parallel_results = Some(vec![fabro_types::ParallelBranchResult {\n+ id: \"review\".to_string(),\n+ status: fabro_types::StageOutcome::Succeeded,\n+ context_updates: std::collections::BTreeMap::from([(\n+ \"response.review\".to_string(),\n+ serde_json::json!(\"looks good\"),\n+ )]),\n+ }]);\n stage.output = Some(\"output\".to_string());\n \n let dump = RunDump::from_projection(&projection).unwrap();\ndiff --git a/lib/crates/fabro-sandbox/src/daytona/mod.rs b/lib/crates/fabro-sandbox/src/daytona/mod.rs\nindex 181b7895a..fa227d2c8 100644\n--- a/lib/crates/fabro-sandbox/src/daytona/mod.rs\n+++ b/lib/crates/fabro-sandbox/src/daytona/mod.rs\n@@ -1311,22 +1311,6 @@ impl Sandbox for DaytonaSandbox {\n crate::git_push_via_exec(self, refspec).await\n }\n \n- fn parallel_worktree_path(\n- &self,\n- _run_dir: &std::path::Path,\n- run_id: &str,\n- node_id: &str,\n- key: &str,\n- ) -> String {\n- format!(\n- \"{}/.fabro/scratch/{}/parallel/{}/{}\",\n- self.working_directory(),\n- run_id,\n- node_id,\n- key\n- )\n- }\n-\n async fn ssh_access_command(&self) -> crate::Result> {\n self.create_ssh_access(Some(60.0)).await.map(Some)\n }\ndiff --git a/lib/crates/fabro-sandbox/src/docker.rs b/lib/crates/fabro-sandbox/src/docker.rs\nindex 08dc04c3d..51f52a41c 100644\n--- a/lib/crates/fabro-sandbox/src/docker.rs\n+++ b/lib/crates/fabro-sandbox/src/docker.rs\n@@ -1929,22 +1929,6 @@ impl Sandbox for DockerSandbox {\n crate::git_push_via_exec(self, refspec).await\n }\n \n- fn parallel_worktree_path(\n- &self,\n- _run_dir: &std::path::Path,\n- run_id: &str,\n- node_id: &str,\n- key: &str,\n- ) -> String {\n- format!(\n- \"{}/.fabro/scratch/{}/parallel/{}/{}\",\n- self.working_directory(),\n- run_id,\n- node_id,\n- key\n- )\n- }\n-\n fn origin_url(&self) -> Option<&str> {\n if !self.repo_cloned() {\n return None;\ndiff --git a/lib/crates/fabro-sandbox/src/lib.rs b/lib/crates/fabro-sandbox/src/lib.rs\nindex 54daf581a..c4d10a7a2 100644\n--- a/lib/crates/fabro-sandbox/src/lib.rs\n+++ b/lib/crates/fabro-sandbox/src/lib.rs\n@@ -22,8 +22,6 @@ pub mod details;\n \n pub mod reconnect;\n \n-pub mod worktree;\n-\n pub mod terminal;\n \n pub mod local;\n@@ -62,4 +60,3 @@ pub use sandbox::{\n };\n pub use sandbox_spec::SandboxSpec;\n pub use terminal::{TerminalSession, TerminalSize, open_terminal_for_run};\n-pub use worktree::{WorktreeEvent, WorktreeEventCallback, WorktreeOptions, WorktreeSandbox};\ndiff --git a/lib/crates/fabro-sandbox/src/sandbox.rs b/lib/crates/fabro-sandbox/src/sandbox.rs\nindex 352a51be4..fa2e9bacb 100644\n--- a/lib/crates/fabro-sandbox/src/sandbox.rs\n+++ b/lib/crates/fabro-sandbox/src/sandbox.rs\n@@ -217,16 +217,6 @@ macro_rules! delegate_sandbox {\n self.$field.git_push_ref(refspec).await\n }\n \n- fn parallel_worktree_path(\n- &self,\n- run_dir: &std::path::Path,\n- run_id: &str,\n- node_id: &str,\n- key: &str,\n- ) -> String {\n- self.$field.parallel_worktree_path(run_dir, run_id, node_id, key)\n- }\n-\n async fn ssh_access_command(&self) -> $crate::Result> {\n self.$field.ssh_access_command().await\n }\n@@ -1005,23 +995,6 @@ pub trait Sandbox: Send + Sync {\n ))\n }\n \n- /// Compute the filesystem path for a parallel branch worktree.\n- fn parallel_worktree_path(\n- &self,\n- run_dir: &std::path::Path,\n- _run_id: &str,\n- node_id: &str,\n- key: &str,\n- ) -> String {\n- run_dir\n- .join(\"parallel\")\n- .join(node_id)\n- .join(key)\n- .join(\"worktree\")\n- .to_string_lossy()\n- .into_owned()\n- }\n-\n /// Return an SSH command string for connecting to this sandbox, if\n /// supported.\n async fn ssh_access_command(&self) -> crate::Result> {\ndiff --git a/lib/crates/fabro-sandbox/src/worktree.rs b/lib/crates/fabro-sandbox/src/worktree.rs\ndeleted file mode 100644\nindex 45370f11a..000000000\n--- a/lib/crates/fabro-sandbox/src/worktree.rs\n+++ /dev/null\n@@ -1,908 +0,0 @@\n-use std::collections::HashMap;\n-use std::path::Path;\n-use std::sync::Arc;\n-\n-use async_trait::async_trait;\n-use tokio_util::sync::CancellationToken;\n-\n-use crate::sandbox::fetch_source_run_ref;\n-use crate::{\n- CommandOutputCallback, DirEntry, ExecResult, ExecStreamingResult, GitRunInfo, GitSetupIntent,\n- GrepOptions, Sandbox, StdioProcess, shell_quote,\n-};\n-\n-/// Git command prefix that disables background maintenance.\n-const GIT: &str = \"git -c maintenance.auto=0 -c gc.auto=0\";\n-\n-// ---------------------------------------------------------------------------\n-// Public types\n-// ---------------------------------------------------------------------------\n-\n-/// Events emitted during worktree lifecycle operations.\n-pub enum WorktreeEvent {\n- BranchCreated { branch: String, sha: String },\n- WorktreeAdded { path: String, branch: String },\n- WorktreeRemoved { path: String },\n-}\n-\n-/// Callback type for worktree lifecycle events.\n-pub type WorktreeEventCallback = Arc;\n-\n-/// Configuration for a `WorktreeSandbox`.\n-pub struct WorktreeOptions {\n- pub branch_name: String,\n- pub base_sha: String,\n- pub worktree_path: String,\n- /// Skip branch creation and hard reset (for resume, where branch already\n- /// exists).\n- pub skip_branch_creation: bool,\n- pub setup_intent: Option,\n-}\n-\n-/// Wraps any `Sandbox`, manages a git worktree lifecycle in\n-/// `initialize()`/`cleanup()`, and overrides `working_directory()` and\n-/// `exec_command()` to use the worktree path.\n-///\n-/// `initialize()` and `cleanup()` do NOT call the inner sandbox's lifecycle\n-/// methods. The inner sandbox's lifecycle is managed separately by the caller.\n-pub struct WorktreeSandbox {\n- inner: Arc,\n- config: WorktreeOptions,\n- event_callback: Option,\n- initialized: std::sync::atomic::AtomicBool,\n-}\n-\n-impl WorktreeSandbox {\n- /// Create a new `WorktreeSandbox` wrapping `inner` with the given\n- /// configuration.\n- pub fn new(inner: Arc, config: WorktreeOptions) -> Self {\n- Self {\n- inner,\n- config,\n- event_callback: None,\n- initialized: std::sync::atomic::AtomicBool::new(false),\n- }\n- }\n-\n- /// Set the callback to receive worktree lifecycle events.\n- pub fn set_event_callback(&mut self, cb: WorktreeEventCallback) {\n- self.event_callback = Some(cb);\n- }\n-\n- /// The git branch name managed by this sandbox.\n- pub fn branch_name(&self) -> &str {\n- &self.config.branch_name\n- }\n-\n- /// The base commit SHA used when initializing the worktree.\n- pub fn base_sha(&self) -> &str {\n- &self.config.base_sha\n- }\n-\n- /// The filesystem path to the worktree directory.\n- pub fn worktree_path(&self) -> &str {\n- &self.config.worktree_path\n- }\n-\n- fn emit(&self, event: WorktreeEvent) {\n- if let Some(ref cb) = self.event_callback {\n- cb(event);\n- }\n- }\n-\n- fn resolve_path(&self, path: &str) -> String {\n- if std::path::Path::new(path).is_absolute() {\n- path.to_string()\n- } else {\n- format!(\"{}/{path}\", self.config.worktree_path)\n- }\n- }\n-\n- async fn fetch_fork_source_if_needed(&self) -> crate::Result<()> {\n- if let Some(GitSetupIntent::ForkFromCheckpoint {\n- source_run_id,\n- checkpoint_sha,\n- ..\n- }) = self.config.setup_intent.as_ref()\n- {\n- fetch_source_run_ref(&*self.inner, source_run_id, checkpoint_sha).await?;\n- }\n- Ok(())\n- }\n-}\n-\n-// ---------------------------------------------------------------------------\n-// Sandbox implementation\n-// ---------------------------------------------------------------------------\n-\n-#[async_trait]\n-impl Sandbox for WorktreeSandbox {\n- // --- Lifecycle ---\n-\n- /// Set up the git worktree:\n- /// 1. Best-effort remove any stale worktree at `path` (so the branch is\n- /// free to be updated).\n- /// 2. Unless `skip_branch_creation`: force-create the branch at `base_sha`,\n- /// emit `BranchCreated`.\n- /// 3. Add the worktree, emit `WorktreeAdded`.\n- ///\n- /// Does NOT call `inner.initialize()`.\n- async fn initialize(&self) -> crate::Result<()> {\n- if self\n- .initialized\n- .swap(true, std::sync::atomic::Ordering::Relaxed)\n- {\n- return Ok(());\n- }\n- let path = shell_quote(&self.config.worktree_path);\n- let branch = shell_quote(&self.config.branch_name);\n- let sha = shell_quote(&self.config.base_sha);\n-\n- self.fetch_fork_source_if_needed().await?;\n-\n- // Best-effort remove any stale worktree registration + directory first,\n- // so that the branch is not \"in use\" when we try to force-update it.\n- let rm_cmd = format!(\"{GIT} worktree remove --force {path}\");\n- let _ = self\n- .inner\n- .exec_command(&rm_cmd, 30_000, None, None, None)\n- .await;\n-\n- // Prune all stale worktree references whose directories no longer exist.\n- // Without this, a branch may remain locked by a worktree in a deleted\n- // temp directory from a previous run.\n- let prune_cmd = format!(\"{GIT} worktree prune\");\n- let _ = self\n- .inner\n- .exec_command(&prune_cmd, 30_000, None, None, None)\n- .await;\n-\n- if !self.config.skip_branch_creation {\n- let cmd = format!(\"{GIT} branch --force {branch} {sha}\");\n- let result = self\n- .inner\n- .exec_command(&cmd, 30_000, None, None, None)\n- .await?;\n- if !result.is_success() {\n- return Err(crate::Error::message(format!(\n- \"git branch --force failed (exit {}): {}\",\n- result.display_exit_code(),\n- result.stderr.trim()\n- )));\n- }\n- self.emit(WorktreeEvent::BranchCreated {\n- branch: self.config.branch_name.clone(),\n- sha: self.config.base_sha.clone(),\n- });\n- }\n-\n- let add_cmd = format!(\"{GIT} worktree add {path} {branch}\");\n- let result = self\n- .inner\n- .exec_command(&add_cmd, 30_000, None, None, None)\n- .await?;\n- if !result.is_success() {\n- // Roll back the branch created above so we don't leak partial state.\n- if !self.config.skip_branch_creation {\n- let rollback_cmd = format!(\"{GIT} branch -D {branch}\");\n- let _ = self\n- .inner\n- .exec_command(&rollback_cmd, 30_000, None, None, None)\n- .await;\n- }\n- return Err(crate::Error::message(format!(\n- \"git worktree add failed (exit {}): {}\",\n- result.display_exit_code(),\n- result.stderr.trim()\n- )));\n- }\n- self.emit(WorktreeEvent::WorktreeAdded {\n- path: self.config.worktree_path.clone(),\n- branch: self.config.branch_name.clone(),\n- });\n-\n- Ok(())\n- }\n-\n- /// No-op — the worktree must survive cleanup for `fabro cp` access.\n- /// Worktrees are pruned separately by `system prune`.\n- async fn cleanup(&self) -> crate::Result<()> {\n- Ok(())\n- }\n-\n- async fn start(&self) -> crate::Result<()> {\n- self.inner.start().await\n- }\n-\n- async fn stop(&self) -> crate::Result<()> {\n- self.inner.stop().await\n- }\n-\n- async fn delete(&self) -> crate::Result<()> {\n- self.inner.delete().await\n- }\n-\n- fn working_directory(&self) -> &str {\n- &self.config.worktree_path\n- }\n-\n- /// Execute a command, defaulting `working_dir` to the worktree path when\n- /// `None`.\n- async fn exec_command(\n- &self,\n- command: &str,\n- timeout_ms: u64,\n- working_dir: Option<&str>,\n- env_vars: Option<&HashMap>,\n- cancel_token: Option,\n- ) -> crate::Result {\n- let wd = working_dir.unwrap_or(&self.config.worktree_path);\n- self.inner\n- .exec_command(command, timeout_ms, Some(wd), env_vars, cancel_token)\n- .await\n- }\n-\n- /// Stream a command's output, forwarding to the inner sandbox's streaming\n- /// implementation so live output and `streams_separated` / `live_streaming`\n- /// flags survive the worktree wrapping.\n- async fn exec_command_streaming(\n- &self,\n- command: &str,\n- timeout_ms: Option,\n- working_dir: Option<&str>,\n- env_vars: Option<&HashMap>,\n- cancel_token: Option,\n- output_callback: CommandOutputCallback,\n- ) -> crate::Result {\n- let wd = working_dir.unwrap_or(&self.config.worktree_path);\n- self.inner\n- .exec_command_streaming(\n- command,\n- timeout_ms,\n- Some(wd),\n- env_vars,\n- cancel_token,\n- output_callback,\n- )\n- .await\n- }\n-\n- async fn spawn_stdio_process(\n- &self,\n- command: &str,\n- working_dir: Option<&str>,\n- env_vars: Option<&HashMap>,\n- cancel_token: Option,\n- ) -> crate::Result {\n- let wd = working_dir.unwrap_or(&self.config.worktree_path);\n- self.inner\n- .spawn_stdio_process(command, Some(wd), env_vars, cancel_token)\n- .await\n- }\n-\n- // --- Delegated methods ---\n-\n- async fn read_file_bytes(&self, path: &str) -> crate::Result> {\n- let resolved = self.resolve_path(path);\n- self.inner.read_file_bytes(&resolved).await\n- }\n-\n- async fn write_file(&self, path: &str, content: &str) -> crate::Result<()> {\n- let resolved = self.resolve_path(path);\n- self.inner.write_file(&resolved, content).await\n- }\n-\n- async fn delete_file(&self, path: &str) -> crate::Result<()> {\n- let resolved = self.resolve_path(path);\n- self.inner.delete_file(&resolved).await\n- }\n-\n- async fn file_exists(&self, path: &str) -> crate::Result {\n- let resolved = self.resolve_path(path);\n- self.inner.file_exists(&resolved).await\n- }\n-\n- async fn list_directory(\n- &self,\n- path: &str,\n- depth: Option,\n- ) -> crate::Result> {\n- let resolved = self.resolve_path(path);\n- self.inner.list_directory(&resolved, depth).await\n- }\n-\n- async fn grep(\n- &self,\n- pattern: &str,\n- path: &str,\n- options: &GrepOptions,\n- ) -> crate::Result> {\n- let resolved = self.resolve_path(path);\n- self.inner.grep(pattern, &resolved, options).await\n- }\n-\n- async fn glob(&self, pattern: &str, path: Option<&str>) -> crate::Result> {\n- let resolved = path.map(|p| self.resolve_path(p));\n- let glob_path = resolved.as_deref().unwrap_or(&self.config.worktree_path);\n- self.inner.glob(pattern, Some(glob_path)).await\n- }\n-\n- async fn download_file_to_local(\n- &self,\n- remote_path: &str,\n- local_path: &Path,\n- ) -> crate::Result<()> {\n- let resolved = self.resolve_path(remote_path);\n- self.inner\n- .download_file_to_local(&resolved, local_path)\n- .await\n- }\n-\n- async fn upload_file_from_local(\n- &self,\n- local_path: &Path,\n- remote_path: &str,\n- ) -> crate::Result<()> {\n- let resolved = self.resolve_path(remote_path);\n- self.inner\n- .upload_file_from_local(local_path, &resolved)\n- .await\n- }\n-\n- fn platform(&self) -> &str {\n- self.inner.platform()\n- }\n-\n- fn os_version(&self) -> String {\n- self.inner.os_version()\n- }\n-\n- fn sandbox_info(&self) -> String {\n- self.inner.sandbox_info()\n- }\n-\n- async fn refresh_push_credentials(&self) -> crate::Result {\n- self.inner.refresh_push_credentials().await\n- }\n-\n- async fn set_autostop_interval(&self, minutes: i32) -> crate::Result<()> {\n- self.inner.set_autostop_interval(minutes).await\n- }\n-\n- async fn setup_git(\n- &self,\n- intent: &crate::GitSetupIntent,\n- ) -> crate::Result> {\n- if let GitSetupIntent::ForkFromCheckpoint {\n- source_run_id,\n- checkpoint_sha,\n- ..\n- } = intent\n- {\n- fetch_source_run_ref(&*self.inner, source_run_id, checkpoint_sha).await?;\n- }\n- Ok(Some(GitRunInfo {\n- base_sha: self.config.base_sha.clone(),\n- run_branch: self.config.branch_name.clone(),\n- base_branch: None,\n- }))\n- }\n-\n- fn resume_setup_commands(&self, run_branch: &str) -> Vec {\n- self.inner.resume_setup_commands(run_branch)\n- }\n-\n- async fn git_push_ref(&self, refspec: &str) -> crate::Result<()> {\n- let has_origin = match self\n- .exec_command(\"git remote get-url origin\", 10_000, None, None, None)\n- .await\n- {\n- Ok(result) if result.is_success() => true,\n- Ok(_) => false,\n- Err(err) => return Err(crate::Error::context(\"git remote get-url origin\", err)),\n- };\n- if !has_origin {\n- return Ok(());\n- }\n-\n- crate::git_push_via_exec(self, refspec).await\n- }\n-\n- fn parallel_worktree_path(\n- &self,\n- run_dir: &Path,\n- run_id: &str,\n- node_id: &str,\n- key: &str,\n- ) -> String {\n- self.inner\n- .parallel_worktree_path(run_dir, run_id, node_id, key)\n- }\n-\n- async fn ssh_access_command(&self) -> crate::Result> {\n- self.inner.ssh_access_command().await\n- }\n-\n- fn origin_url(&self) -> Option<&str> {\n- self.inner.origin_url()\n- }\n-\n- async fn get_preview_url(\n- &self,\n- port: u16,\n- ) -> crate::Result)>> {\n- self.inner.get_preview_url(port).await\n- }\n-\n- fn mark_agent_read(&self, path: &str) {\n- let resolved = self.resolve_path(path);\n- self.inner.mark_agent_read(&resolved);\n- }\n-}\n-\n-// ---------------------------------------------------------------------------\n-// Tests\n-// ---------------------------------------------------------------------------\n-\n-#[cfg(test)]\n-#[expect(\n- clippy::disallowed_methods,\n- reason = \"worktree tests stage fixtures with sync std::fs writes in temp dirs\"\n-)]\n-mod tests {\n- use std::sync::Mutex;\n-\n- use fabro_types::CommandTermination;\n-\n- use super::*;\n- use crate::local::LocalSandbox;\n- use crate::test_support::MockSandbox;\n-\n- fn make_config(wt_path: &str) -> WorktreeOptions {\n- WorktreeOptions {\n- branch_name: \"fabro/run/test-branch\".to_string(),\n- base_sha: \"abc123def456\".to_string(),\n- worktree_path: wt_path.to_string(),\n- skip_branch_creation: false,\n- setup_intent: None,\n- }\n- }\n-\n- fn make_config_skip(wt_path: &str) -> WorktreeOptions {\n- WorktreeOptions {\n- branch_name: \"fabro/run/test-branch\".to_string(),\n- base_sha: \"abc123def456\".to_string(),\n- worktree_path: wt_path.to_string(),\n- skip_branch_creation: true,\n- setup_intent: None,\n- }\n- }\n-\n- /// Create a shared mock and return both the `Arc` (passed to\n- /// WorktreeSandbox) and the `Arc` (used to assert captured\n- /// state).\n- fn make_mock() -> (Arc, Arc) {\n- let mock = Arc::new(MockSandbox::linux());\n- let as_sandbox: Arc = mock.clone();\n- (as_sandbox, mock)\n- }\n-\n- // -----------------------------------------------------------------------\n- // initialize() — full setup (skip_branch_creation = false)\n- // -----------------------------------------------------------------------\n-\n- #[tokio::test]\n- async fn initialize_issues_correct_git_commands() {\n- let (inner, mock) = make_mock();\n- let wt = WorktreeSandbox::new(inner, make_config(\"/tmp/wt\"));\n-\n- wt.initialize().await.unwrap();\n-\n- let cmds = mock.captured_commands.lock().unwrap().clone();\n- // worktree remove (best-effort), worktree prune, branch --force, worktree add\n- assert_eq!(cmds.len(), 4, \"expected 4 git commands, got: {cmds:?}\");\n- assert!(\n- cmds[0].contains(\"worktree remove --force\"),\n- \"cmd[0]: {}\",\n- cmds[0]\n- );\n- assert!(cmds[1].contains(\"worktree prune\"), \"cmd[1]: {}\", cmds[1]);\n- assert!(cmds[2].contains(\"branch --force\"), \"cmd[2]: {}\", cmds[2]);\n- assert!(cmds[3].contains(\"worktree add\"), \"cmd[3]: {}\", cmds[3]);\n- }\n-\n- #[tokio::test]\n- async fn initialize_emits_branch_and_worktree_events() {\n- let (inner, _mock) = make_mock();\n- let mut wt = WorktreeSandbox::new(inner, make_config(\"/tmp/wt\"));\n-\n- let events: Arc>> = Arc::new(Mutex::new(Vec::new()));\n- let events_clone = Arc::clone(&events);\n- wt.set_event_callback(Arc::new(move |event| {\n- let label = match &event {\n- WorktreeEvent::BranchCreated { .. } => \"BranchCreated\",\n- WorktreeEvent::WorktreeAdded { .. } => \"WorktreeAdded\",\n- WorktreeEvent::WorktreeRemoved { .. } => \"WorktreeRemoved\",\n- };\n- events_clone.lock().unwrap().push(label.to_string());\n- }));\n-\n- wt.initialize().await.unwrap();\n-\n- let captured = events.lock().unwrap();\n- assert_eq!(*captured, vec![\"BranchCreated\", \"WorktreeAdded\"]);\n- }\n-\n- #[tokio::test]\n- async fn initialize_uses_shell_quoted_values_in_commands() {\n- let (inner, mock) = make_mock();\n- let config = WorktreeOptions {\n- branch_name: \"fabro/run/my-branch\".to_string(),\n- base_sha: \"deadbeef\".to_string(),\n- worktree_path: \"/tmp/my worktree\".to_string(), // path with space\n- skip_branch_creation: false,\n- setup_intent: None,\n- };\n- let wt = WorktreeSandbox::new(inner, config);\n-\n- wt.initialize().await.unwrap();\n-\n- let cmds = mock.captured_commands.lock().unwrap().clone();\n- // The path \"/tmp/my worktree\" should be quoted in the worktree remove command\n- // (cmd[0])\n- assert!(\n- cmds[0].contains(\"'/tmp/my worktree'\") || cmds[0].contains(\"\\\"/tmp/my worktree\\\"\"),\n- \"worktree path should be shell-quoted: {}\",\n- cmds[0]\n- );\n- }\n-\n- // -----------------------------------------------------------------------\n- // initialize() — skip_branch_creation = true\n- // -----------------------------------------------------------------------\n-\n- #[tokio::test]\n- async fn initialize_skip_branch_creation_issues_only_worktree_commands() {\n- let (inner, mock) = make_mock();\n- let wt = WorktreeSandbox::new(inner, make_config_skip(\"/tmp/wt\"));\n-\n- wt.initialize().await.unwrap();\n-\n- let cmds = mock.captured_commands.lock().unwrap().clone();\n- // worktree remove (best-effort), worktree prune, worktree add\n- assert_eq!(cmds.len(), 3, \"expected 3 git commands, got: {cmds:?}\");\n- assert!(\n- cmds[0].contains(\"worktree remove --force\"),\n- \"cmd[0]: {}\",\n- cmds[0]\n- );\n- assert!(cmds[1].contains(\"worktree prune\"), \"cmd[1]: {}\", cmds[1]);\n- assert!(cmds[2].contains(\"worktree add\"), \"cmd[2]: {}\", cmds[2]);\n- }\n-\n- #[tokio::test]\n- async fn initialize_skip_branch_creation_emits_only_worktree_added() {\n- let (inner, _mock) = make_mock();\n- let mut wt = WorktreeSandbox::new(inner, make_config_skip(\"/tmp/wt\"));\n-\n- let events: Arc>> = Arc::new(Mutex::new(Vec::new()));\n- let events_clone = Arc::clone(&events);\n- wt.set_event_callback(Arc::new(move |event| {\n- let label = match &event {\n- WorktreeEvent::BranchCreated { .. } => \"BranchCreated\",\n- WorktreeEvent::WorktreeAdded { .. } => \"WorktreeAdded\",\n- WorktreeEvent::WorktreeRemoved { .. } => \"WorktreeRemoved\",\n- };\n- events_clone.lock().unwrap().push(label.to_string());\n- }));\n-\n- wt.initialize().await.unwrap();\n-\n- let captured = events.lock().unwrap();\n- assert_eq!(*captured, vec![\"WorktreeAdded\"]);\n- }\n-\n- // -----------------------------------------------------------------------\n- // initialize() — error propagation\n- // -----------------------------------------------------------------------\n-\n- #[tokio::test]\n- async fn initialize_propagates_error_on_nonzero_exit() {\n- let inner: Arc = Arc::new(MockSandbox {\n- exec_result: ExecResult {\n- stdout: String::new(),\n- stderr: \"fatal: not a git repo\".to_string(),\n- exit_code: Some(128),\n- termination: CommandTermination::Exited,\n- duration_ms: 5,\n- },\n- ..MockSandbox::linux()\n- });\n- let wt = WorktreeSandbox::new(inner, make_config(\"/tmp/wt\"));\n-\n- let result = wt.initialize().await;\n-\n- assert!(result.is_err(), \"should return Err on non-zero exit\");\n- let err = result.unwrap_err().to_string();\n- assert!(\n- err.contains(\"branch --force failed\") || err.contains(\"128\"),\n- \"error should mention the failure: {err}\"\n- );\n- }\n-\n- // -----------------------------------------------------------------------\n- // cleanup()\n- // -----------------------------------------------------------------------\n-\n- // -----------------------------------------------------------------------\n- // working_directory()\n- // -----------------------------------------------------------------------\n-\n- #[test]\n- fn working_directory_returns_worktree_path() {\n- let (inner, _mock) = make_mock();\n- let wt = WorktreeSandbox::new(inner, make_config(\"/tmp/my_worktree\"));\n-\n- assert_eq!(wt.working_directory(), \"/tmp/my_worktree\");\n- }\n-\n- // -----------------------------------------------------------------------\n- // exec_command() working_dir defaulting\n- // -----------------------------------------------------------------------\n-\n- #[tokio::test]\n- async fn exec_command_none_working_dir_defaults_to_worktree_path() {\n- let (inner, mock) = make_mock();\n- let wt = WorktreeSandbox::new(inner, make_config(\"/tmp/wt\"));\n-\n- wt.exec_command(\"echo hello\", 5000, None, None, None)\n- .await\n- .unwrap();\n-\n- let wdirs = mock.captured_working_dirs.lock().unwrap().clone();\n- assert_eq!(\n- wdirs.last(),\n- Some(&Some(\"/tmp/wt\".to_string())),\n- \"None working_dir should be replaced with worktree path\"\n- );\n- }\n-\n- #[tokio::test]\n- async fn exec_command_explicit_working_dir_passes_through() {\n- let (inner, mock) = make_mock();\n- let wt = WorktreeSandbox::new(inner, make_config(\"/tmp/wt\"));\n-\n- wt.exec_command(\"echo hello\", 5000, Some(\"/explicit/path\"), None, None)\n- .await\n- .unwrap();\n-\n- let wdirs = mock.captured_working_dirs.lock().unwrap().clone();\n- assert_eq!(\n- wdirs.last(),\n- Some(&Some(\"/explicit/path\".to_string())),\n- \"explicit working_dir should be passed through unchanged\"\n- );\n- }\n-\n- #[tokio::test]\n- async fn stdio_process_none_working_dir_defaults_to_worktree_path() {\n- let (inner, mock) = make_mock();\n- let wt = WorktreeSandbox::new(inner, make_config(\"/tmp/wt\"));\n-\n- wt.spawn_stdio_process(\"python fake_agent.py\", None, None, None)\n- .await\n- .unwrap();\n-\n- let wdirs = mock.captured_working_dirs.lock().unwrap().clone();\n- assert_eq!(\n- wdirs.last(),\n- Some(&Some(\"/tmp/wt\".to_string())),\n- \"None working_dir should be replaced with worktree path\"\n- );\n- }\n-\n- // -----------------------------------------------------------------------\n- // Accessors\n- // -----------------------------------------------------------------------\n-\n- // -----------------------------------------------------------------------\n- // Bug: cleanup() destroys worktree, breaking `fabro cp`\n- // -----------------------------------------------------------------------\n-\n- #[tokio::test]\n- async fn cleanup_should_preserve_worktree_for_post_run_access() {\n- // The worktree directory must survive cleanup() so that `fabro cp` can\n- // access run artifacts afterward. It is pruned separately by `system prune`.\n- // LocalSandbox.cleanup() was a no-op; WorktreeSandbox should match.\n- let (inner, mock) = make_mock();\n- let wt = WorktreeSandbox::new(inner, make_config(\"/tmp/wt\"));\n-\n- wt.cleanup().await.unwrap();\n-\n- let cmds = mock.captured_commands.lock().unwrap().clone();\n- assert!(\n- cmds.is_empty(),\n- \"cleanup should not issue destructive git commands \\\n- (worktree must be preserved for fabro cp), but got: {cmds:?}\"\n- );\n- }\n-\n- #[tokio::test]\n- async fn lifecycle_operations_forward_to_inner_sandbox() {\n- let (inner, mock) = make_mock();\n- let wt = WorktreeSandbox::new(inner, make_config(\"/tmp/wt\"));\n-\n- wt.start().await.unwrap();\n- wt.stop().await.unwrap();\n- wt.delete().await.unwrap();\n-\n- assert_eq!(mock.start_count(), 1);\n- assert_eq!(mock.stop_count(), 1);\n- assert_eq!(mock.delete_count(), 1);\n- }\n-\n- // -----------------------------------------------------------------------\n- // Bug: initialize() is not idempotent — double call destroys worktree\n- // -----------------------------------------------------------------------\n-\n- #[tokio::test]\n- async fn initialize_is_idempotent_on_second_call() {\n- // engine.run_with_lifecycle() calls sandbox.initialize() unconditionally,\n- // even when run.rs already called it during sandbox construction.\n- // The second call must be a no-op; it must NOT re-run\n- // `git worktree remove --force` which would destroy the worktree.\n- let (inner, mock) = make_mock();\n- let wt = WorktreeSandbox::new(inner, make_config(\"/tmp/wt\"));\n-\n- wt.initialize().await.unwrap();\n- let first_count = mock.captured_commands.lock().unwrap().len();\n-\n- wt.initialize().await.unwrap();\n- let second_count = mock.captured_commands.lock().unwrap().len();\n-\n- assert_eq!(\n- first_count,\n- second_count,\n- \"second initialize() should be a no-op, but it issued {} additional commands\",\n- second_count - first_count\n- );\n- }\n-\n- // -----------------------------------------------------------------------\n- // Bug: file operations resolve against inner working_directory, not worktree\n- // -----------------------------------------------------------------------\n-\n- #[tokio::test]\n- async fn grep_should_search_worktree_not_inner_working_directory() {\n- // WorktreeSandbox delegates grep() to the inner sandbox without path\n- // adjustment. When the inner LocalSandbox was created with original_cwd,\n- // grep(\"pattern\", \".\") searches the original repo instead of the worktree.\n- let original =\n- std::env::temp_dir().join(format!(\"fabro-test-original-{}\", uuid::Uuid::new_v4()));\n- let worktree =\n- std::env::temp_dir().join(format!(\"fabro-test-worktree-{}\", uuid::Uuid::new_v4()));\n- std::fs::create_dir_all(&original).unwrap();\n- std::fs::create_dir_all(&worktree).unwrap();\n-\n- // Put a marker file ONLY in the worktree directory\n- std::fs::write(worktree.join(\"marker.txt\"), \"UNIQUE_WORKTREE_MARKER\").unwrap();\n-\n- let inner: Arc = Arc::new(LocalSandbox::new(original.clone()));\n- let config = WorktreeOptions {\n- branch_name: \"test-branch\".into(),\n- base_sha: \"abc123\".into(),\n- worktree_path: worktree.to_string_lossy().to_string(),\n- skip_branch_creation: false,\n- setup_intent: None,\n- };\n- let wt = WorktreeSandbox::new(inner, config);\n-\n- // working_directory() correctly returns the worktree path\n- assert_eq!(wt.working_directory(), worktree.to_string_lossy().as_ref());\n-\n- // grep with \".\" should search the worktree, not the original repo\n- let results = wt\n- .grep(\"UNIQUE_WORKTREE_MARKER\", \".\", &GrepOptions::default())\n- .await\n- .unwrap();\n- assert!(\n- !results.is_empty(),\n- \"grep(\\\".\\\") should search the worktree directory, not the inner sandbox's working directory\"\n- );\n-\n- std::fs::remove_dir_all(&original).ok();\n- std::fs::remove_dir_all(&worktree).ok();\n- }\n-\n- #[tokio::test]\n- async fn glob_should_search_worktree_when_path_is_none() {\n- // WorktreeSandbox delegates glob() to the inner sandbox without path\n- // adjustment. LocalSandbox::glob(pattern, None) defaults to\n- // self.working_directory, which is the original repo path.\n- let original =\n- std::env::temp_dir().join(format!(\"fabro-test-original-{}\", uuid::Uuid::new_v4()));\n- let worktree =\n- std::env::temp_dir().join(format!(\"fabro-test-worktree-{}\", uuid::Uuid::new_v4()));\n- std::fs::create_dir_all(&original).unwrap();\n- std::fs::create_dir_all(&worktree).unwrap();\n-\n- // Put a file ONLY in the worktree directory\n- std::fs::write(worktree.join(\"worktree_only.txt\"), \"content\").unwrap();\n-\n- let inner: Arc = Arc::new(LocalSandbox::new(original.clone()));\n- let config = WorktreeOptions {\n- branch_name: \"test-branch\".into(),\n- base_sha: \"abc123\".into(),\n- worktree_path: worktree.to_string_lossy().to_string(),\n- skip_branch_creation: false,\n- setup_intent: None,\n- };\n- let wt = WorktreeSandbox::new(inner, config);\n-\n- let results = wt.glob(\"*.txt\", None).await.unwrap();\n- assert!(\n- results.iter().any(|r| r.contains(\"worktree_only.txt\")),\n- \"glob(pattern, None) should search the worktree directory, not the inner sandbox's working directory. Got: {results:?}\"\n- );\n-\n- std::fs::remove_dir_all(&original).ok();\n- std::fs::remove_dir_all(&worktree).ok();\n- }\n-\n- #[tokio::test]\n- async fn read_file_relative_should_resolve_against_worktree() {\n- // WorktreeSandbox delegates read_file() to the inner sandbox without\n- // path adjustment. Relative paths resolve against the inner\n- // LocalSandbox's working_directory (original repo), not the worktree.\n- let original =\n- std::env::temp_dir().join(format!(\"fabro-test-original-{}\", uuid::Uuid::new_v4()));\n- let worktree =\n- std::env::temp_dir().join(format!(\"fabro-test-worktree-{}\", uuid::Uuid::new_v4()));\n- std::fs::create_dir_all(&original).unwrap();\n- std::fs::create_dir_all(&worktree).unwrap();\n-\n- // Put the file ONLY in the worktree directory\n- std::fs::write(worktree.join(\"only_in_worktree.txt\"), \"worktree content\").unwrap();\n-\n- let inner: Arc = Arc::new(LocalSandbox::new(original.clone()));\n- let config = WorktreeOptions {\n- branch_name: \"test-branch\".into(),\n- base_sha: \"abc123\".into(),\n- worktree_path: worktree.to_string_lossy().to_string(),\n- skip_branch_creation: false,\n- setup_intent: None,\n- };\n- let wt = WorktreeSandbox::new(inner, config);\n-\n- let result = wt.read_file(\"only_in_worktree.txt\", None, None).await;\n- assert!(\n- result.is_ok(),\n- \"read_file with relative path should resolve against worktree, not inner sandbox's working directory. Error: {}\",\n- result.unwrap_err()\n- );\n-\n- std::fs::remove_dir_all(&original).ok();\n- std::fs::remove_dir_all(&worktree).ok();\n- }\n-\n- // -----------------------------------------------------------------------\n- // Accessors\n- // -----------------------------------------------------------------------\n-\n- #[test]\n- fn accessors_return_config_values() {\n- let (inner, _mock) = make_mock();\n- let config = WorktreeOptions {\n- branch_name: \"my-branch\".to_string(),\n- base_sha: \"sha123\".to_string(),\n- worktree_path: \"/path/to/wt\".to_string(),\n- skip_branch_creation: false,\n- setup_intent: None,\n- };\n- let wt = WorktreeSandbox::new(inner, config);\n-\n- assert_eq!(wt.branch_name(), \"my-branch\");\n- assert_eq!(wt.base_sha(), \"sha123\");\n- assert_eq!(wt.worktree_path(), \"/path/to/wt\");\n- }\n-}\ndiff --git a/lib/crates/fabro-store/src/run_state.rs b/lib/crates/fabro-store/src/run_state.rs\nindex f242acc52..44783d4fc 100644\n--- a/lib/crates/fabro-store/src/run_state.rs\n+++ b/lib/crates/fabro-store/src/run_state.rs\n@@ -505,13 +505,10 @@ impl RunProjectionReducer for RunProjection {\n )?;\n }\n EventBody::ParallelCompleted(props) => {\n- let parallel_results = serde_json::to_value(&props.results).map_err(|err| {\n- Error::InvalidEvent(format!(\"invalid parallel.completed payload: {err}\"))\n- })?;\n let Some(stage) = stage_at_stored_or_current_visit(self, stored, event.seq) else {\n return Ok(());\n };\n- stage.parallel_results = Some(parallel_results);\n+ stage.parallel_results = Some(props.results.clone());\n }\n EventBody::ParallelBranchStarted(_) => {\n // Branches bypass the engine's StageStarted/StageCompleted\n@@ -529,21 +526,17 @@ impl RunProjectionReducer for RunProjection {\n // A branch never emits its own StageCompleted, so finalize it\n // here; otherwise the stage spins Running forever after the run\n // (and the fan-in) is done.\n- let outcome =\n- StageOutcome::from_str(&props.status).unwrap_or(StageOutcome::Failed {\n- retry_requested: false,\n- });\n let Some(stage) = stage_at_stored_or_current_visit(self, stored, event.seq) else {\n return Ok(());\n };\n stage.completion = Some(StageCompletion {\n- outcome,\n- notes: None,\n+ outcome: props.status,\n+ notes: None,\n failure_reason: None,\n- timestamp: ts,\n+ timestamp: ts,\n });\n stage.timing = Some(fabro_types::StageTiming::wall_only(props.duration_ms));\n- stage.state = StageState::from(outcome);\n+ stage.state = StageState::from(props.status);\n }\n EventBody::TodoCreated(props) => {\n let Some(stage) = stage_at_stored_or_current_visit(self, stored, event.seq) else {\n@@ -2054,8 +2047,7 @@ mod tests {\n EventBody::ParallelBranchCompleted(ParallelBranchCompletedProps {\n index: 0,\n duration_ms: 1234,\n- status: \"succeeded\".to_string(),\n- head_sha: None,\n+ status: StageOutcome::Succeeded,\n }),\n branch.clone(),\n ))\n@@ -2088,8 +2080,9 @@ mod tests {\n EventBody::ParallelBranchCompleted(ParallelBranchCompletedProps {\n index: 0,\n duration_ms: 500,\n- status: \"failed\".to_string(),\n- head_sha: None,\n+ status: StageOutcome::Failed {\n+ retry_requested: false,\n+ },\n }),\n branch.clone(),\n ))\ndiff --git a/lib/crates/fabro-store/tests/serializable_projection.rs b/lib/crates/fabro-store/tests/serializable_projection.rs\nindex ffc7fbb03..66600c154 100644\n--- a/lib/crates/fabro-store/tests/serializable_projection.rs\n+++ b/lib/crates/fabro-store/tests/serializable_projection.rs\n@@ -6,9 +6,9 @@ use fabro_types::graph::Graph;\n use fabro_types::run::RunSpec;\n use fabro_types::{\n BilledModelUsage, BilledTokenCounts, Checkpoint, CheckpointRecord, InterviewQuestionRecord,\n- QuestionType, RunDiff, RunSandbox, RunSandboxInstance, RunSandboxPlan, RunSandboxRuntime,\n- RunStatus, SandboxProviderKind, StageCompletion, StageModelUsage, StageOutcome, StartRecord,\n- WorkflowSettings, first_event_seq, fixtures, test_support,\n+ ParallelBranchResult, QuestionType, RunDiff, RunSandbox, RunSandboxInstance, RunSandboxPlan,\n+ RunSandboxRuntime, RunStatus, SandboxProviderKind, StageCompletion, StageModelUsage,\n+ StageOutcome, StartRecord, WorkflowSettings, first_event_seq, fixtures, test_support,\n };\n use serde_json::json;\n \n@@ -143,7 +143,12 @@ fn serializable_projection_round_trips_and_trims_bulky_node_fields() {\n stage.diff = Some(\"diff --git a/a b/a\".to_string());\n stage.script_invocation = Some(json!({ \"command\": \"cargo test\" }));\n stage.script_timing = Some(json!({ \"duration_ms\": 10 }));\n- stage.parallel_results = Some(json!([{ \"stage\": \"fanout@1\" }]));\n+ let parallel_results = vec![ParallelBranchResult {\n+ id: \"review\".to_string(),\n+ status: StageOutcome::Succeeded,\n+ context_updates: BTreeMap::from([(\"response.review\".to_string(), json!(\"looks good\"))]),\n+ }];\n+ stage.parallel_results = Some(parallel_results.clone());\n stage.timing = Some(fabro_types::StageTiming::wall_only(1234));\n let usage = sample_usage();\n let usage_counts = BilledTokenCounts::from_billed_usage(std::slice::from_ref(&usage));\n@@ -203,10 +208,7 @@ fn serializable_projection_round_trips_and_trims_bulky_node_fields() {\n Some(json!({ \"command\": \"cargo test\" }))\n );\n assert_eq!(node.script_timing, Some(json!({ \"duration_ms\": 10 })));\n- assert_eq!(\n- node.parallel_results,\n- Some(json!([{ \"stage\": \"fanout@1\" }]))\n- );\n+ assert_eq!(node.parallel_results, Some(parallel_results));\n assert_eq!(node.timing.map(|t| t.wall_time_ms), Some(1234));\n assert_eq!(node.usage, usage_counts);\n assert_eq!(node.model.as_ref(), Some(usage.model()));\ndiff --git a/lib/crates/fabro-types/src/lib.rs b/lib/crates/fabro-types/src/lib.rs\nindex e339f285d..752d67dde 100644\n--- a/lib/crates/fabro-types/src/lib.rs\n+++ b/lib/crates/fabro-types/src/lib.rs\n@@ -19,6 +19,7 @@ pub mod manifest_path;\n pub mod mcp_store;\n pub mod outcome;\n pub mod pair;\n+pub mod parallel;\n pub mod principal;\n pub mod pull_request;\n pub mod repository;\n@@ -93,6 +94,7 @@ pub use pair::{\n PairTranscriptWarning, RunEventDetailContent, RunEventDetailContentKind,\n RunEventDetailEnvelope, RunEventDetailResponse, RunPairStatusResponse,\n };\n+pub use parallel::ParallelBranchResult;\n pub use principal::{AuthMethod, Principal, SystemActorKind, UserPrincipal};\n pub use pull_request::{\n CheckRun, CheckRunStatus, PullRequest, PullRequestDetails, PullRequestDetailsStatus,\ndiff --git a/lib/crates/fabro-types/src/parallel.rs b/lib/crates/fabro-types/src/parallel.rs\nnew file mode 100644\nindex 000000000..fc4a2563a\n--- /dev/null\n+++ b/lib/crates/fabro-types/src/parallel.rs\n@@ -0,0 +1,14 @@\n+use std::collections::BTreeMap;\n+\n+use serde::{Deserialize, Serialize};\n+use serde_json::Value;\n+\n+use crate::StageOutcome;\n+\n+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]\n+pub struct ParallelBranchResult {\n+ pub id: String,\n+ pub status: StageOutcome,\n+ #[serde(default)]\n+ pub context_updates: BTreeMap,\n+}\ndiff --git a/lib/crates/fabro-types/src/run_event/infra.rs b/lib/crates/fabro-types/src/run_event/infra.rs\nindex 3abd80c12..8af295ffa 100644\n--- a/lib/crates/fabro-types/src/run_event/infra.rs\n+++ b/lib/crates/fabro-types/src/run_event/infra.rs\n@@ -30,7 +30,6 @@ pub enum RunNoticeCode {\n GitPushFailed,\n GithubTokenFailed,\n GithubTokenRefreshLimited,\n- ParallelBaseCheckpointFailed,\n PullRequestFailed,\n SandboxCleanupFailed,\n SandboxGitUnavailable,\ndiff --git a/lib/crates/fabro-types/src/run_event/misc.rs b/lib/crates/fabro-types/src/run_event/misc.rs\nindex 3f88cdbd3..84276a3bd 100644\n--- a/lib/crates/fabro-types/src/run_event/misc.rs\n+++ b/lib/crates/fabro-types/src/run_event/misc.rs\n@@ -1,8 +1,7 @@\n use serde::{Deserialize, Serialize};\n-use serde_json::Value;\n \n use super::ExecOutputTail;\n-use crate::{CommandTermination, PullRequestLink};\n+use crate::{CommandTermination, ParallelBranchResult, PullRequestLink, StageOutcome};\n \n #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]\n pub struct InterviewOption {\n@@ -18,7 +17,6 @@ pub struct InterviewOption {\n pub struct ParallelStartedProps {\n pub visit: u32,\n pub branch_count: usize,\n- pub join_policy: String,\n }\n \n #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]\n@@ -30,9 +28,7 @@ pub struct ParallelBranchStartedProps {\n pub struct ParallelBranchCompletedProps {\n pub index: usize,\n pub duration_ms: u64,\n- pub status: String,\n- #[serde(default, skip_serializing_if = \"Option::is_none\")]\n- pub head_sha: Option,\n+ pub status: StageOutcome,\n }\n \n #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]\n@@ -42,7 +38,7 @@ pub struct ParallelCompletedProps {\n pub success_count: usize,\n pub failure_count: usize,\n #[serde(default, skip_serializing_if = \"Vec::is_empty\")]\n- pub results: Vec,\n+ pub results: Vec,\n }\n \n #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]\n@@ -106,23 +102,6 @@ pub struct GitPushProps {\n pub exec_output_tail: Option,\n }\n \n-#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]\n-pub struct GitBranchProps {\n- pub branch: String,\n- pub sha: String,\n-}\n-\n-#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]\n-pub struct GitWorktreeAddProps {\n- pub path: String,\n- pub branch: String,\n-}\n-\n-#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]\n-pub struct GitWorktreeRemoveProps {\n- pub path: String,\n-}\n-\n #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]\n pub struct GitFetchProps {\n pub branch: String,\ndiff --git a/lib/crates/fabro-types/src/run_event/mod.rs b/lib/crates/fabro-types/src/run_event/mod.rs\nindex cb1c38b81..205b17d74 100644\n--- a/lib/crates/fabro-types/src/run_event/mod.rs\n+++ b/lib/crates/fabro-types/src/run_event/mod.rs\n@@ -176,12 +176,6 @@ pub enum EventBody {\n GitCommit(GitCommitProps),\n #[serde(rename = \"git.push\")]\n GitPush(GitPushProps),\n- #[serde(rename = \"git.branch\")]\n- GitBranch(GitBranchProps),\n- #[serde(rename = \"git.worktree.added\")]\n- GitWorktreeAdd(GitWorktreeAddProps),\n- #[serde(rename = \"git.worktree.removed\")]\n- GitWorktreeRemove(GitWorktreeRemoveProps),\n #[serde(rename = \"git.fetch\")]\n GitFetch(GitFetchProps),\n #[serde(rename = \"git.reset\")]\n@@ -477,9 +471,6 @@ impl EventBody {\n Self::CheckpointFailed(_) => \"checkpoint.failed\",\n Self::GitCommit(_) => \"git.commit\",\n Self::GitPush(_) => \"git.push\",\n- Self::GitBranch(_) => \"git.branch\",\n- Self::GitWorktreeAdd(_) => \"git.worktree.added\",\n- Self::GitWorktreeRemove(_) => \"git.worktree.removed\",\n Self::GitFetch(_) => \"git.fetch\",\n Self::GitReset(_) => \"git.reset\",\n Self::EdgeSelected(_) => \"edge.selected\",\n@@ -649,9 +640,6 @@ fn is_known_event_name(event: &str) -> bool {\n | \"checkpoint.failed\"\n | \"git.commit\"\n | \"git.push\"\n- | \"git.branch\"\n- | \"git.worktree.added\"\n- | \"git.worktree.removed\"\n | \"git.fetch\"\n | \"git.reset\"\n | \"edge.selected\"\ndiff --git a/lib/crates/fabro-types/src/run_projection.rs b/lib/crates/fabro-types/src/run_projection.rs\nindex 6b67282ee..f251d6182 100644\n--- a/lib/crates/fabro-types/src/run_projection.rs\n+++ b/lib/crates/fabro-types/src/run_projection.rs\n@@ -75,7 +75,6 @@ impl StageModelUsage {\n pub const MODE_PROMPT: &'static str = \"prompt\";\n pub const MODE_AGENT: &'static str = \"agent\";\n pub const MODE_ACP: &'static str = \"acp\";\n- pub const MODE_FAN_IN: &'static str = \"fan_in\";\n \n /// Build the usage record from a `stage.prompt` event, returning `None`\n /// when the event carried no model metadata.\n@@ -328,7 +327,7 @@ pub struct StageProjection {\n pub diff: Option,\n pub script_invocation: Option,\n pub script_timing: Option,\n- pub parallel_results: Option,\n+ pub parallel_results: Option>,\n pub output: Option,\n #[serde(default, skip_serializing_if = \"Option::is_none\")]\n pub output_bytes: Option,\ndiff --git a/lib/crates/fabro-validate/src/lib.rs b/lib/crates/fabro-validate/src/lib.rs\nindex e5430c481..3747b677c 100644\n--- a/lib/crates/fabro-validate/src/lib.rs\n+++ b/lib/crates/fabro-validate/src/lib.rs\n@@ -257,6 +257,54 @@ reasoning = false\n assert!(result.is_ok());\n }\n \n+ #[test]\n+ fn validate_rejects_join_policy_on_any_node() {\n+ let mut g = minimal_valid_graph();\n+\n+ let mut fork = Node::new(\"fork\");\n+ fork.attrs.insert(\n+ \"shape\".to_string(),\n+ AttrValue::String(\"component\".to_string()),\n+ );\n+ fork.attrs.insert(\n+ \"join_policy\".to_string(),\n+ AttrValue::String(\"wait_all\".to_string()),\n+ );\n+ g.nodes.insert(\"fork\".to_string(), fork);\n+\n+ let mut custom = Node::new(\"custom\");\n+ custom.attrs.insert(\n+ \"type\".to_string(),\n+ AttrValue::String(\"custom.handler\".to_string()),\n+ );\n+ custom.attrs.insert(\n+ \"join_policy\".to_string(),\n+ AttrValue::String(\"first_success\".to_string()),\n+ );\n+ g.nodes.insert(\"custom\".to_string(), custom);\n+\n+ let diagnostics = validate(&g, &[]);\n+ let removed = diagnostics\n+ .iter()\n+ .filter(|d| d.rule == \"join_policy_removed\")\n+ .collect::>();\n+\n+ assert_eq!(removed.len(), 2, \"diagnostics: {diagnostics:?}\");\n+ assert!(removed.iter().all(|d| d.severity == Severity::Error));\n+ assert!(\n+ removed\n+ .iter()\n+ .all(|d| d.message.contains(\"Remove 'join_policy'\"))\n+ );\n+ assert_eq!(\n+ removed\n+ .iter()\n+ .filter_map(|d| d.node_id.as_deref())\n+ .collect::>(),\n+ std::collections::BTreeSet::from([\"custom\", \"fork\"]),\n+ );\n+ }\n+\n #[test]\n fn validate_or_raise_fails_for_missing_start() {\n let mut g = Graph::new(\"test\");\ndiff --git a/lib/crates/fabro-validate/src/rules/inert_attribute.rs b/lib/crates/fabro-validate/src/rules/inert_attribute.rs\nindex fef9c5454..a740d97b8 100644\n--- a/lib/crates/fabro-validate/src/rules/inert_attribute.rs\n+++ b/lib/crates/fabro-validate/src/rules/inert_attribute.rs\n@@ -18,7 +18,6 @@ const HANDLER_SPECIFIC_ATTRS: &[(&str, &[&str])] = &[\n (\"script\", &[\"command\"]),\n (\"language\", &[\"command\"]),\n (\"duration\", &[\"wait\"]),\n- (\"join_policy\", &[\"parallel\"]),\n (\"max_parallel\", &[\"parallel\"]),\n (\"output_schema\", &[\"agent\", \"prompt\"]),\n (\"prompt\", &[\"agent\", \"prompt\", \"parallel.fan_in\"]),\n@@ -140,15 +139,11 @@ mod tests {\n fn warns_on_parallel_attrs_on_agent_node() {\n let mut g = minimal_graph();\n let mut node = Node::new(\"work\");\n- node.attrs.insert(\n- \"join_policy\".to_string(),\n- AttrValue::String(\"wait_all\".to_string()),\n- );\n node.attrs\n .insert(\"max_parallel\".to_string(), AttrValue::Integer(4));\n g.nodes.insert(\"work\".to_string(), node);\n let d = Rule.apply(&g);\n- assert_eq!(d.len(), 2);\n+ assert_eq!(d.len(), 1);\n }\n \n #[test]\n@@ -180,7 +175,7 @@ mod tests {\n );\n g.nodes.insert(\n \"fork\".to_string(),\n- node_with_attr(\"fork\", \"component\", \"join_policy\", \"wait_all\"),\n+ node_with_attr(\"fork\", \"component\", \"max_parallel\", \"4\"),\n );\n g.nodes.insert(\n \"spec\".to_string(),\ndiff --git a/lib/crates/fabro-validate/src/rules/join_policy_removed.rs b/lib/crates/fabro-validate/src/rules/join_policy_removed.rs\nnew file mode 100644\nindex 000000000..b96f8b446\n--- /dev/null\n+++ b/lib/crates/fabro-validate/src/rules/join_policy_removed.rs\n@@ -0,0 +1,35 @@\n+use fabro_graphviz::graph::Graph;\n+\n+use crate::{Diagnostic, LintRule, Severity};\n+\n+pub(super) fn rule() -> Box {\n+ Box::new(Rule)\n+}\n+\n+struct Rule;\n+\n+impl LintRule for Rule {\n+ fn name(&self) -> &'static str {\n+ \"join_policy_removed\"\n+ }\n+\n+ fn apply(&self, graph: &Graph) -> Vec {\n+ graph\n+ .nodes\n+ .values()\n+ .filter(|node| node.attrs.contains_key(\"join_policy\"))\n+ .map(|node| Diagnostic {\n+ rule: self.name().to_string(),\n+ severity: Severity::Error,\n+ message: format!(\n+ \"Node '{}' sets the removed 'join_policy' attribute. Remove 'join_policy'; parallel nodes always wait for every branch to finish\",\n+ node.id,\n+ ),\n+ node_id: Some(node.id.clone()),\n+ edge: None,\n+ fix: Some(\"Remove 'join_policy' from this node\".to_string()),\n+ ..Diagnostic::default()\n+ })\n+ .collect()\n+ }\n+}\ndiff --git a/lib/crates/fabro-validate/src/rules/mod.rs b/lib/crates/fabro-validate/src/rules/mod.rs\nindex 428004240..cb4a5a18a 100644\n--- a/lib/crates/fabro-validate/src/rules/mod.rs\n+++ b/lib/crates/fabro-validate/src/rules/mod.rs\n@@ -9,6 +9,7 @@ mod freeform_edge_count;\n mod goal_gate_has_retry;\n mod import_error;\n mod inert_attribute;\n+mod join_policy_removed;\n mod model_support;\n mod node_model_known;\n mod orphan_custom_outcome;\n@@ -58,6 +59,7 @@ pub fn built_in_rules() -> Vec> {\n orphan_custom_outcome::rule(),\n script_absolute_cd::rule(),\n import_error::rule(),\n+ join_policy_removed::rule(),\n unresolved_file_ref::rule(),\n thread_id_requires_fidelity_full::rule(),\n selection_valid::rule(),\ndiff --git a/lib/crates/fabro-workflow/README.md b/lib/crates/fabro-workflow/README.md\nindex 655bca79a..21deba55b 100644\n--- a/lib/crates/fabro-workflow/README.md\n+++ b/lib/crates/fabro-workflow/README.md\n@@ -140,7 +140,7 @@ Nodes with `shape=hexagon` or `type=\"human\"` pause execution for human input. Ou\n \n ### Parallel Execution\n \n-Nodes with `shape=component` fan out to branches concurrently. Configurable join policies: `wait_all` (default), `first_success`.\n+Nodes with `shape=component` fan out to branches concurrently. Branches receive isolated context forks, share the same sandbox checkout, and always finish before the workflow continues. Use `max_parallel` to limit concurrency; concurrent workspace writes are user-managed.\n \n ### Checkpoints and Resume\n \ndiff --git a/lib/crates/fabro-workflow/src/artifact.rs b/lib/crates/fabro-workflow/src/artifact.rs\nindex 3f9f9eb7d..26de9edd8 100644\n--- a/lib/crates/fabro-workflow/src/artifact.rs\n+++ b/lib/crates/fabro-workflow/src/artifact.rs\n@@ -3,7 +3,9 @@ use std::path::{Path, PathBuf};\n \n use fabro_agent::Sandbox;\n use fabro_config::RunScratch;\n-use fabro_types::{RunBlobId, format_blob_ref, parse_blob_ref, parse_managed_blob_file_ref};\n+use fabro_types::{\n+ ParallelBranchResult, RunBlobId, format_blob_ref, parse_blob_ref, parse_managed_blob_file_ref,\n+};\n use futures::future::BoxFuture;\n use serde_json::Value;\n use tokio::fs;\n@@ -27,6 +29,10 @@ const ARTIFACT_POINTER_PREFIX: &str = \"file://\";\n /// and replaced with a `\"blob://sha256/{blob_id}\"` reference.\n /// Small values are left untouched.\n ///\n+/// `parallel.results` is offloaded leaf-wise instead of as one value so it\n+/// stays a structured array that fan-in prompts, projections, and the UI can\n+/// read without hydrating the whole payload.\n+///\n /// # Errors\n ///\n /// Returns an error if blob persistence fails.\n@@ -34,21 +40,79 @@ pub async fn offload_large_values(\n updates: &mut HashMap,\n run_store: &RunStoreHandle,\n ) -> Result<()> {\n- for value in updates.values_mut() {\n- let bytes = serde_json::to_vec(&*value)\n- .map_err(|e| Error::engine_with_source(\"artifact serialize failed\", e))?;\n+ for (key, value) in updates {\n+ if key == context::keys::PARALLEL_RESULTS {\n+ offload_large_leaves(value, run_store).await?;\n+ } else {\n+ offload_value(value, run_store).await?;\n+ }\n+ }\n+ Ok(())\n+}\n \n- if bytes.len() > BLOB_OFFLOAD_THRESHOLD {\n- let blob_id = run_store\n- .write_blob(&bytes)\n- .await\n- .map_err(|e| Error::engine_with_anyhow(\"artifact blob write failed\", e))?;\n- *value = Value::String(format_blob_ref(&blob_id));\n+/// Offload large leaves of typed parallel branch results before they are\n+/// emitted through `parallel.completed` and stored in projections.\n+///\n+/// # Errors\n+///\n+/// Returns an error if blob persistence fails.\n+pub async fn offload_parallel_branch_updates(\n+ results: &mut [ParallelBranchResult],\n+ run_store: &RunStoreHandle,\n+) -> Result<()> {\n+ for result in results.iter_mut() {\n+ for value in result.context_updates.values_mut() {\n+ offload_large_leaves(value, run_store).await?;\n }\n }\n Ok(())\n }\n \n+fn offload_large_leaves<'a>(\n+ value: &'a mut Value,\n+ run_store: &'a RunStoreHandle,\n+) -> BoxFuture<'a, Result<()>> {\n+ Box::pin(async move {\n+ match value {\n+ Value::Array(items) => {\n+ for item in items {\n+ offload_large_leaves(item, run_store).await?;\n+ }\n+ }\n+ Value::Object(map) => {\n+ for item in map.values_mut() {\n+ offload_large_leaves(item, run_store).await?;\n+ }\n+ }\n+ Value::String(_) | Value::Null | Value::Bool(_) | Value::Number(_) => {\n+ offload_value(value, run_store).await?;\n+ }\n+ }\n+ Ok(())\n+ })\n+}\n+\n+async fn offload_value(value: &mut Value, run_store: &RunStoreHandle) -> Result<()> {\n+ // JSON escaping expands a string to at most 6 bytes per char plus quotes,\n+ // so short strings can never cross the threshold — skip serializing them.\n+ if let Value::String(text) = &*value {\n+ if text.len().saturating_mul(6) + 2 <= BLOB_OFFLOAD_THRESHOLD {\n+ return Ok(());\n+ }\n+ }\n+ let bytes = serde_json::to_vec(&*value)\n+ .map_err(|e| Error::engine_with_source(\"artifact serialize failed\", e))?;\n+\n+ if bytes.len() > BLOB_OFFLOAD_THRESHOLD {\n+ let blob_id = run_store\n+ .write_blob(&bytes)\n+ .await\n+ .map_err(|e| Error::engine_with_anyhow(\"artifact blob write failed\", e))?;\n+ *value = Value::String(format_blob_ref(&blob_id));\n+ }\n+ Ok(())\n+}\n+\n /// Extract the file path from an artifact pointer value.\n ///\n /// Returns `Some(path)` if the value is a string starting with `\"file://\"`,\n@@ -264,6 +328,10 @@ fn resolve_execution_values<'a>(\n })\n }\n \n+fn is_text_context_key(key: &str) -> bool {\n+ key == context::keys::COMMAND_OUTPUT || key.starts_with(context::keys::RESPONSE_PREFIX)\n+}\n+\n fn resolve_execution_value<'a>(\n key: Option<&'a str>,\n value: &'a mut Value,\n@@ -274,7 +342,7 @@ fn resolve_execution_value<'a>(\n Box::pin(async move {\n match value {\n Value::String(current) => {\n- if matches!(key, Some(context::keys::COMMAND_OUTPUT)) {\n+ if key.is_some_and(is_text_context_key) {\n *current = resolve_text_or_blob_ref_str(current, run_store).await?;\n } else if let Some(blob_id) = parse_blob_ref(current) {\n *current = materialize_blob_ref(&blob_id, run_store, env, run_dir).await?;\n@@ -286,12 +354,19 @@ fn resolve_execution_value<'a>(\n }\n Value::Array(items) => {\n for item in items {\n- resolve_execution_value(None, item, run_store, env, run_dir).await?;\n+ resolve_execution_value(key, item, run_store, env, run_dir).await?;\n }\n }\n Value::Object(map) => {\n- for item in map.values_mut() {\n- resolve_execution_value(None, item, run_store, env, run_dir).await?;\n+ for (child_key, item) in map.iter_mut() {\n+ resolve_execution_value(\n+ Some(child_key.as_str()),\n+ item,\n+ run_store,\n+ env,\n+ run_dir,\n+ )\n+ .await?;\n }\n }\n Value::Null | Value::Bool(_) | Value::Number(_) => {}\n@@ -306,15 +381,12 @@ async fn materialize_blob_ref(\n env: &dyn Sandbox,\n run_dir: &Path,\n ) -> Result {\n- let bytes = run_store\n- .read_blob(blob_id)\n- .await\n- .map_err(|e| Error::engine_with_anyhow(\"artifact blob read failed\", e))?\n- .ok_or_else(|| Error::engine(format!(\"artifact blob missing: {blob_id}\")))?;\n-\n+ // Blobs are content-addressed, so an existing materialized file is always\n+ // current — check before paying for the store read.\n if is_local_execution(env, run_dir).await? {\n let path = local_materialized_blob_path(run_dir, blob_id);\n if !path.exists() {\n+ let bytes = read_required_blob(blob_id, run_store).await?;\n if let Some(parent) = path.parent() {\n fs::create_dir_all(parent).await.map_err(|err| {\n Error::Io(format!(\n@@ -336,6 +408,7 @@ async fn materialize_blob_ref(\n .await\n .map_err(|e| Error::engine_with_source(\"failed to check blob existence\", e))?\n {\n+ let bytes = read_required_blob(blob_id, run_store).await?;\n let content = String::from_utf8(bytes.to_vec())\n .map_err(|e| Error::engine_with_source(\"artifact blob was not valid UTF-8 JSON\", e))?;\n env.write_file(&remote_path, &content).await.map_err(|e| {\n@@ -346,6 +419,17 @@ async fn materialize_blob_ref(\n Ok(format!(\"{ARTIFACT_POINTER_PREFIX}{remote_path}\"))\n }\n \n+async fn read_required_blob(\n+ blob_id: &RunBlobId,\n+ run_store: &RunStoreHandle,\n+) -> Result {\n+ run_store\n+ .read_blob(blob_id)\n+ .await\n+ .map_err(|e| Error::engine_with_anyhow(\"artifact blob read failed\", e))?\n+ .ok_or_else(|| Error::engine(format!(\"artifact blob missing: {blob_id}\")))\n+}\n+\n async fn resolve_explicit_file_ref(value: &str, env: &dyn Sandbox) -> Result {\n let local_path = value\n .strip_prefix(ARTIFACT_POINTER_PREFIX)\n@@ -466,6 +550,47 @@ mod tests {\n assert_eq!(updates.get(\"small_key\").unwrap(), &small_value);\n }\n \n+ #[tokio::test]\n+ async fn offload_preserves_parallel_results_and_replaces_only_large_leaves() {\n+ let run_store = make_run_store(\"parallel-result-artifact-offload\").await;\n+ let large_response = \"r\".repeat(BLOB_OFFLOAD_THRESHOLD + 1);\n+ let large_output = \"o\".repeat(BLOB_OFFLOAD_THRESHOLD + 1);\n+ let mut updates = HashMap::from([(\n+ context::keys::PARALLEL_RESULTS.to_string(),\n+ serde_json::json!([{\n+ \"id\": \"branch_a\",\n+ \"status\": \"failed\",\n+ \"context_updates\": {\n+ \"response.branch_a\": large_response,\n+ \"command.output\": large_output,\n+ \"small\": \"kept inline\",\n+ }\n+ }]),\n+ )]);\n+\n+ offload_large_values(&mut updates, &run_store.clone().into())\n+ .await\n+ .unwrap();\n+\n+ let results = updates[context::keys::PARALLEL_RESULTS]\n+ .as_array()\n+ .expect(\"parallel.results must remain a structured array\");\n+ let branch_updates = results[0][\"context_updates\"]\n+ .as_object()\n+ .expect(\"context_updates must remain a structured object\");\n+ assert!(\n+ branch_updates[\"response.branch_a\"]\n+ .as_str()\n+ .is_some_and(|value| fabro_types::parse_blob_ref(value).is_some())\n+ );\n+ assert!(\n+ branch_updates[context::keys::COMMAND_OUTPUT]\n+ .as_str()\n+ .is_some_and(|value| fabro_types::parse_blob_ref(value).is_some())\n+ );\n+ assert_eq!(branch_updates[\"small\"], serde_json::json!(\"kept inline\"));\n+ }\n+\n #[test]\n fn artifact_path_extracts_path_from_pointer() {\n let value = serde_json::json!(\"file:///tmp/logs/runtime/blobs/response.plan.json\");\n@@ -487,6 +612,58 @@ mod tests {\n assert_eq!(artifact_path(&value), None);\n }\n \n+ #[tokio::test]\n+ async fn resolve_context_hydrates_nested_parallel_text_blob_references() {\n+ let run_store = make_run_store(\"parallel-result-text-resolution\").await;\n+ let response = \"full branch response\";\n+ let output = \"full command output\";\n+ let response_blob = run_store\n+ .write_blob(&serde_json::to_vec(response).unwrap())\n+ .await\n+ .unwrap();\n+ let output_blob = run_store\n+ .write_blob(&serde_json::to_vec(output).unwrap())\n+ .await\n+ .unwrap();\n+ let unrelated_blob = run_store\n+ .write_blob(&serde_json::to_vec(\"unrelated artifact\").unwrap())\n+ .await\n+ .unwrap();\n+ let context = Context::new();\n+ context.set(\n+ context::keys::PARALLEL_RESULTS,\n+ serde_json::json!([{\n+ \"id\": \"branch_a\",\n+ \"status\": \"succeeded\",\n+ \"context_updates\": {\n+ \"response.branch_a\": fabro_types::format_blob_ref(&response_blob),\n+ \"command.output\": fabro_types::format_blob_ref(&output_blob),\n+ \"report\": fabro_types::format_blob_ref(&unrelated_blob),\n+ }\n+ }]),\n+ );\n+ let env = TestSyncEnv::new(true, \"/workspace\");\n+ let run_dir = tempfile::tempdir().unwrap();\n+\n+ let resolved =\n+ resolved_context_snapshot(&context, &run_store.clone().into(), &env, run_dir.path())\n+ .await\n+ .unwrap();\n+\n+ let updates = &resolved[context::keys::PARALLEL_RESULTS][0][\"context_updates\"];\n+ assert_eq!(updates[\"response.branch_a\"], serde_json::json!(response));\n+ assert_eq!(\n+ updates[context::keys::COMMAND_OUTPUT],\n+ serde_json::json!(output)\n+ );\n+ assert!(\n+ updates[\"report\"]\n+ .as_str()\n+ .is_some_and(|value| value.starts_with(\"file://\")),\n+ \"non-textual nested values should retain artifact semantics\"\n+ );\n+ }\n+\n #[test]\n fn normalize_durable_updates_rewrites_managed_blob_file_refs_recursively() {\n let blob_id = fabro_types::RunBlobId::new(b\"hello\");\ndiff --git a/lib/crates/fabro-workflow/src/context.rs b/lib/crates/fabro-workflow/src/context.rs\nindex af1233586..1926c42db 100644\n--- a/lib/crates/fabro-workflow/src/context.rs\n+++ b/lib/crates/fabro-workflow/src/context.rs\n@@ -40,9 +40,6 @@ pub mod keys {\n // --- parallel.* keys ---\n pub const PARALLEL_RESULTS: &str = \"parallel.results\";\n pub const PARALLEL_BRANCH_COUNT: &str = \"parallel.branch_count\";\n- pub const PARALLEL_FAN_IN_BEST_ID: &str = \"parallel.fan_in.best_id\";\n- pub const PARALLEL_FAN_IN_BEST_OUTCOME: &str = \"parallel.fan_in.best_outcome\";\n- pub const PARALLEL_FAN_IN_BEST_HEAD_SHA: &str = \"parallel.fan_in.best_head_sha\";\n \n // --- Prefix constants (for filtering and dynamic keys) ---\n pub const GRAPH_PREFIX: &str = \"graph.\";\n@@ -132,18 +129,36 @@ pub mod keys {\n }\n }\n \n+use std::collections::HashMap;\n+\n pub use fabro_core::Context;\n use fabro_graphviz::Fidelity;\n-use fabro_types::{ParallelBranchId, StageId};\n+use fabro_types::{ParallelBranchId, RunId, StageId};\n \n+use crate::error::Error;\n use crate::event::StageScope;\n \n+/// Keys whose values changed or were added in `after` relative to `before`.\n+/// Takes `after` by value so changed entries move instead of clone.\n+pub(crate) fn context_diff(\n+ before: &HashMap,\n+ after: HashMap,\n+) -> HashMap {\n+ after\n+ .into_iter()\n+ .filter(|(key, value)| before.get(key) != Some(value))\n+ .collect()\n+}\n+\n /// Domain-specific typed accessors for workflow context values.\n pub trait WorkflowContext {\n fn fidelity(&self) -> Fidelity;\n fn thread_id(&self) -> Option;\n fn preamble(&self) -> String;\n fn run_id(&self) -> String;\n+ /// Parse `internal.run_id`, failing when the engine did not seed a\n+ /// valid run ID.\n+ fn parsed_run_id(&self) -> Result;\n fn parallel_group_id(&self) -> Option;\n fn parallel_branch_id(&self) -> Option;\n /// Build the stage-level emit scope from the currently-executing node and\n@@ -172,6 +187,12 @@ impl WorkflowContext for Context {\n self.get_string(keys::INTERNAL_RUN_ID, \"unknown\")\n }\n \n+ fn parsed_run_id(&self) -> Result {\n+ self.run_id()\n+ .parse()\n+ .map_err(|err| Error::handler_with_source(\"invalid internal run_id\", err))\n+ }\n+\n fn parallel_group_id(&self) -> Option {\n self.get(keys::INTERNAL_PARALLEL_GROUP_ID)\n .and_then(|value| serde_json::from_value(value).ok())\ndiff --git a/lib/crates/fabro-workflow/src/event/convert.rs b/lib/crates/fabro-workflow/src/event/convert.rs\nindex 3238e153d..5114bdac7 100644\n--- a/lib/crates/fabro-workflow/src/event/convert.rs\n+++ b/lib/crates/fabro-workflow/src/event/convert.rs\n@@ -371,12 +371,10 @@ fn event_body_from_event(event: &Event) -> EventBody {\n Event::ParallelStarted {\n visit,\n branch_count,\n- join_policy,\n ..\n } => EventBody::ParallelStarted(fabro_types::ParallelStartedProps {\n visit: *visit,\n branch_count: *branch_count,\n- join_policy: join_policy.clone(),\n }),\n Event::ParallelBranchStarted { index, .. } => {\n EventBody::ParallelBranchStarted(fabro_types::ParallelBranchStartedProps {\n@@ -387,13 +385,11 @@ fn event_body_from_event(event: &Event) -> EventBody {\n index,\n duration_ms,\n status,\n- head_sha,\n ..\n } => EventBody::ParallelBranchCompleted(fabro_types::ParallelBranchCompletedProps {\n index: *index,\n duration_ms: *duration_ms,\n- status: status.clone(),\n- head_sha: head_sha.clone(),\n+ status: *status,\n }),\n Event::ParallelCompleted {\n visit,\n@@ -516,19 +512,6 @@ fn event_body_from_event(event: &Event) -> EventBody {\n success: *success,\n exec_output_tail: exec_output_tail.clone(),\n }),\n- Event::GitBranch { branch, sha } => EventBody::GitBranch(fabro_types::GitBranchProps {\n- branch: branch.clone(),\n- sha: sha.clone(),\n- }),\n- Event::GitWorktreeAdd { path, branch } => {\n- EventBody::GitWorktreeAdd(fabro_types::GitWorktreeAddProps {\n- path: path.clone(),\n- branch: branch.clone(),\n- })\n- }\n- Event::GitWorktreeRemove { path } => {\n- EventBody::GitWorktreeRemove(fabro_types::GitWorktreeRemoveProps { path: path.clone() })\n- }\n Event::GitFetch { branch, success } => EventBody::GitFetch(fabro_types::GitFetchProps {\n branch: branch.clone(),\n success: *success,\n@@ -1716,15 +1699,96 @@ mod tests {\n }\n \n #[test]\n- fn parallel_started_populates_parallel_group_id() {\n+ fn parallel_started_populates_group_id_and_public_properties() {\n let stored = to_run_event(&fixtures::RUN_1, &Event::ParallelStarted {\n node_id: \"fanout\".to_string(),\n visit: 2,\n branch_count: 3,\n- join_policy: \"wait_all\".to_string(),\n });\n assert_eq!(stored.parallel_group_id, Some(StageId::new(\"fanout\", 2)));\n assert!(stored.parallel_branch_id.is_none());\n+ assert_eq!(\n+ stored.properties().unwrap(),\n+ serde_json::json!({\n+ \"visit\": 2,\n+ \"branch_count\": 3,\n+ })\n+ );\n+ }\n+\n+ #[test]\n+ fn parallel_branch_completed_public_properties() {\n+ let group_id = StageId::new(\"fanout\", 2);\n+ let stored = to_run_event(&fixtures::RUN_1, &Event::ParallelBranchCompleted {\n+ parallel_group_id: group_id.clone(),\n+ parallel_branch_id: ParallelBranchId::new(group_id, 1),\n+ branch: \"review\".to_string(),\n+ index: 1,\n+ duration_ms: 42,\n+ status: StageOutcome::Succeeded,\n+ });\n+\n+ assert_eq!(\n+ stored.properties().unwrap(),\n+ serde_json::json!({\n+ \"index\": 1,\n+ \"duration_ms\": 42,\n+ \"status\": \"succeeded\",\n+ })\n+ );\n+ }\n+\n+ #[test]\n+ fn parallel_completed_exposes_typed_results_in_input_order() {\n+ let stored = to_run_event(&fixtures::RUN_1, &Event::ParallelCompleted {\n+ node_id: \"fanout\".to_string(),\n+ visit: 2,\n+ duration_ms: 84,\n+ success_count: 1,\n+ failure_count: 1,\n+ results: vec![\n+ ::fabro_types::ParallelBranchResult {\n+ id: \"review_api\".to_string(),\n+ status: StageOutcome::Succeeded,\n+ context_updates: BTreeMap::from([(\n+ \"response.review_api\".to_string(),\n+ serde_json::json!(\"looks good\"),\n+ )]),\n+ },\n+ ::fabro_types::ParallelBranchResult {\n+ id: \"review_ux\".to_string(),\n+ status: StageOutcome::Failed {\n+ retry_requested: false,\n+ },\n+ context_updates: BTreeMap::from([(\n+ \"response.review_ux\".to_string(),\n+ serde_json::json!(\"needs work\"),\n+ )]),\n+ },\n+ ],\n+ });\n+\n+ assert_eq!(\n+ stored.properties().unwrap(),\n+ serde_json::json!({\n+ \"visit\": 2,\n+ \"duration_ms\": 84,\n+ \"success_count\": 1,\n+ \"failure_count\": 1,\n+ \"results\": [\n+ {\n+ \"id\": \"review_api\",\n+ \"status\": \"succeeded\",\n+ \"context_updates\": {\"response.review_api\": \"looks good\"},\n+ },\n+ {\n+ \"id\": \"review_ux\",\n+ \"status\": \"failed\",\n+ \"context_updates\": {\"response.review_ux\": \"needs work\"},\n+ },\n+ ],\n+ })\n+ );\n }\n \n #[test]\ndiff --git a/lib/crates/fabro-workflow/src/event/emitter.rs b/lib/crates/fabro-workflow/src/event/emitter.rs\nindex dc50a56dd..339919b18 100644\n--- a/lib/crates/fabro-workflow/src/event/emitter.rs\n+++ b/lib/crates/fabro-workflow/src/event/emitter.rs\n@@ -3,7 +3,6 @@ use std::sync::atomic::{AtomicI64, Ordering};\n \n use ::fabro_types::{ExecOutputTail, RunEvent, RunId, RunNoticeCode, RunNoticeLevel};\n use chrono::Utc;\n-use fabro_agent::{WorktreeEvent, WorktreeEventCallback};\n \n use super::Event;\n use super::convert::to_run_event_at;\n@@ -139,22 +138,6 @@ impl Emitter {\n pub fn touch(&self) {\n self.last_event_at.store(epoch_millis(), Ordering::Relaxed);\n }\n-\n- /// Build a [`WorktreeEventCallback`] that forwards worktree lifecycle\n- /// events as [`Event`]s on this emitter.\n- pub fn worktree_callback(self: Arc) -> WorktreeEventCallback {\n- Arc::new(move |event| match event {\n- WorktreeEvent::BranchCreated { branch, sha } => {\n- self.emit(&Event::GitBranch { branch, sha });\n- }\n- WorktreeEvent::WorktreeAdded { path, branch } => {\n- self.emit(&Event::GitWorktreeAdd { path, branch });\n- }\n- WorktreeEvent::WorktreeRemoved { path } => {\n- self.emit(&Event::GitWorktreeRemove { path });\n- }\n- })\n- }\n }\n \n #[cfg(test)]\ndiff --git a/lib/crates/fabro-workflow/src/event/events.rs b/lib/crates/fabro-workflow/src/event/events.rs\nindex 0b5afb1c7..ed940c5b1 100644\n--- a/lib/crates/fabro-workflow/src/event/events.rs\n+++ b/lib/crates/fabro-workflow/src/event/events.rs\n@@ -3,10 +3,10 @@ use std::collections::BTreeMap;\n use ::fabro_types::{\n AutomationRef, BilledTokenCounts, BlockedReason, CommandTermination, DiffSummary,\n FailureReason, ForkSourceRef, GitContext, PairId, PairMessageId, PairSystemMessageKind,\n- PairTarget, ParallelBranchId, PendingReason, PermissionLevel, Principal, PullRequestLink,\n- RunBlobId, RunFailure, RunId, RunNoticeLevel, RunPairEndedReason, RunPairFailedReason,\n- RunProvenance, RunRunnableSource, RunTiming, SandboxProviderKind, StageId, StageTiming,\n- SuccessReason, run_event as fabro_types,\n+ PairTarget, ParallelBranchId, ParallelBranchResult, PendingReason, PermissionLevel, Principal,\n+ PullRequestLink, RunBlobId, RunFailure, RunId, RunNoticeLevel, RunPairEndedReason,\n+ RunPairFailedReason, RunProvenance, RunRunnableSource, RunTiming, SandboxProviderKind, StageId,\n+ StageOutcome, StageTiming, SuccessReason, run_event as fabro_types,\n };\n use fabro_agent::{AgentEvent, SandboxEvent};\n use fabro_model::{ReasoningEffort, Speed};\n@@ -304,7 +304,6 @@ pub enum Event {\n node_id: String,\n visit: u32,\n branch_count: usize,\n- join_policy: String,\n },\n ParallelBranchStarted {\n parallel_group_id: StageId,\n@@ -318,9 +317,7 @@ pub enum Event {\n branch: String,\n index: usize,\n duration_ms: u64,\n- status: String,\n- #[serde(default, skip_serializing_if = \"Option::is_none\")]\n- head_sha: Option,\n+ status: StageOutcome,\n },\n ParallelCompleted {\n node_id: String,\n@@ -329,7 +326,7 @@ pub enum Event {\n success_count: usize,\n failure_count: usize,\n #[serde(default, skip_serializing_if = \"Vec::is_empty\")]\n- results: Vec,\n+ results: Vec,\n },\n InterviewStarted {\n question_id: String,\n@@ -414,17 +411,6 @@ pub enum Event {\n #[serde(default, skip_serializing_if = \"Option::is_none\")]\n exec_output_tail: Option,\n },\n- GitBranch {\n- branch: String,\n- sha: String,\n- },\n- GitWorktreeAdd {\n- path: String,\n- branch: String,\n- },\n- GitWorktreeRemove {\n- path: String,\n- },\n GitFetch {\n branch: String,\n success: bool,\n@@ -1116,12 +1102,8 @@ impl Event {\n \"Stage retrying\"\n );\n }\n- Self::ParallelStarted {\n- branch_count,\n- join_policy,\n- ..\n- } => {\n- debug!(branch_count, join_policy, \"Parallel execution started\");\n+ Self::ParallelStarted { branch_count, .. } => {\n+ debug!(branch_count, \"Parallel execution started\");\n }\n Self::ParallelBranchStarted { branch, index, .. } => {\n debug!(branch, index, \"Parallel branch started\");\n@@ -1135,7 +1117,10 @@ impl Event {\n } => {\n debug!(\n branch,\n- index, duration_ms, status, \"Parallel branch completed\"\n+ index,\n+ duration_ms,\n+ status = %status,\n+ \"Parallel branch completed\"\n );\n }\n Self::ParallelCompleted {\n@@ -1233,15 +1218,6 @@ impl Event {\n );\n }\n }\n- Self::GitBranch { branch, sha } => {\n- debug!(branch, sha, \"Git branch created\");\n- }\n- Self::GitWorktreeAdd { path, branch } => {\n- debug!(path, branch, \"Git worktree added\");\n- }\n- Self::GitWorktreeRemove { path } => {\n- debug!(path, \"Git worktree removed\");\n- }\n Self::GitFetch { branch, success } => {\n if *success {\n debug!(branch, \"Git fetch succeeded\");\ndiff --git a/lib/crates/fabro-workflow/src/event/names.rs b/lib/crates/fabro-workflow/src/event/names.rs\nindex acfee89e6..58abe28e7 100644\n--- a/lib/crates/fabro-workflow/src/event/names.rs\n+++ b/lib/crates/fabro-workflow/src/event/names.rs\n@@ -56,9 +56,6 @@ pub fn event_name(event: &Event) -> &'static str {\n Event::CheckpointFailed { .. } => \"checkpoint.failed\",\n Event::GitCommit { .. } => \"git.commit\",\n Event::GitPush { .. } => \"git.push\",\n- Event::GitBranch { .. } => \"git.branch\",\n- Event::GitWorktreeAdd { .. } => \"git.worktree.added\",\n- Event::GitWorktreeRemove { .. } => \"git.worktree.removed\",\n Event::GitFetch { .. } => \"git.fetch\",\n Event::GitReset { .. } => \"git.reset\",\n Event::EdgeSelected { .. } => \"edge.selected\",\ndiff --git a/lib/crates/fabro-workflow/src/git.rs b/lib/crates/fabro-workflow/src/git.rs\nindex 8fa23a208..a1f0adf99 100644\n--- a/lib/crates/fabro-workflow/src/git.rs\n+++ b/lib/crates/fabro-workflow/src/git.rs\n@@ -74,60 +74,6 @@ pub fn head_sha(repo: &Path) -> Result {\n Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())\n }\n \n-/// Create a new branch at HEAD without checking it out.\n-pub fn create_branch(repo: &Path, name: &str) -> Result<()> {\n- let output = git_cmd(repo)\n- .args([\"branch\", \"--force\", name, \"HEAD\"])\n- .output()\n- .map_err(|e| Error::engine_with_source(\"git branch failed\", e))?;\n-\n- if !output.status.success() {\n- let stderr = String::from_utf8_lossy(&output.stderr);\n- return Err(git_error(format!(\"git branch failed: {stderr}\")));\n- }\n-\n- Ok(())\n-}\n-\n-/// Add a git worktree for the given branch at `path`.\n-pub fn add_worktree(repo: &Path, path: &Path, branch: &str) -> Result<()> {\n- let output = git_cmd(repo)\n- .args([\"worktree\", \"add\"])\n- .arg(path)\n- .arg(branch)\n- .output()\n- .map_err(|e| Error::engine_with_source(\"git worktree add failed\", e))?;\n-\n- if !output.status.success() {\n- let stderr = String::from_utf8_lossy(&output.stderr);\n- return Err(git_error(format!(\"git worktree add failed: {stderr}\")));\n- }\n-\n- Ok(())\n-}\n-\n-/// Remove a git worktree.\n-pub fn remove_worktree(repo: &Path, path: &Path) -> Result<()> {\n- let output = git_cmd(repo)\n- .args([\"worktree\", \"remove\", \"--force\"])\n- .arg(path)\n- .output()\n- .map_err(|e| Error::engine_with_source(\"git worktree remove failed\", e))?;\n-\n- if !output.status.success() {\n- let stderr = String::from_utf8_lossy(&output.stderr);\n- return Err(git_error(format!(\"git worktree remove failed: {stderr}\")));\n- }\n-\n- Ok(())\n-}\n-\n-/// Remove any stale worktree at `path` (best-effort), then add a fresh one.\n-pub fn replace_worktree(repo: &Path, path: &Path, branch: &str) -> Result<()> {\n- let _ = remove_worktree(repo, path);\n- add_worktree(repo, path, branch)\n-}\n-\n /// Run a `git push` command and check for success.\n fn run_git_push(cmd: &mut Command) -> Result<()> {\n let output = cmd\n@@ -313,23 +259,6 @@ pub fn sync_status(repo: &Path, remote: &str, branch: Option<&str>) -> GitSyncSt\n }\n }\n \n-/// Sanitize a string for use as a git ref component.\n-/// Lowercases, replaces non-alphanumeric chars with dashes, collapses runs.\n-pub fn sanitize_ref_component(s: &str) -> String {\n- let mut result = String::with_capacity(s.len());\n- let mut prev_dash = false;\n- for c in s.chars() {\n- if c.is_ascii_alphanumeric() {\n- result.push(c.to_ascii_lowercase());\n- prev_dash = false;\n- } else if !prev_dash {\n- result.push('-');\n- prev_dash = true;\n- }\n- }\n- result.trim_matches('-').to_string()\n-}\n-\n /// Filenames allowed in per-node directories on the shadow branch.\n #[cfg(test)]\n #[expect(\n@@ -416,39 +345,6 @@ mod tests {\n assert!(sha.chars().all(|c| c.is_ascii_hexdigit()));\n }\n \n- #[test]\n- #[expect(\n- clippy::disallowed_methods,\n- reason = \"This synchronous test verifies git branch listing against the real git CLI.\"\n- )]\n- fn create_branch_and_list() {\n- let dir = tempfile::tempdir().unwrap();\n- init_repo(dir.path());\n- create_branch(dir.path(), \"test-branch\").unwrap();\n-\n- let output = Command::new(\"git\")\n- .args([\"branch\", \"--list\", \"test-branch\"])\n- .current_dir(dir.path())\n- .output()\n- .unwrap();\n- let stdout = String::from_utf8_lossy(&output.stdout);\n- assert!(stdout.contains(\"test-branch\"));\n- }\n-\n- #[test]\n- fn add_and_remove_worktree() {\n- let dir = tempfile::tempdir().unwrap();\n- init_repo(dir.path());\n- create_branch(dir.path(), \"wt-branch\").unwrap();\n-\n- let wt_path = dir.path().join(\"my-worktree\");\n- add_worktree(dir.path(), &wt_path, \"wt-branch\").unwrap();\n- assert!(wt_path.join(\".git\").exists());\n-\n- remove_worktree(dir.path(), &wt_path).unwrap();\n- assert!(!wt_path.exists());\n- }\n-\n #[tokio::test]\n async fn scan_node_files_from_state_reconstructs_allowlisted_entries() {\n use crate::event::{Event, append_event};\n@@ -550,7 +446,11 @@ mod tests {\n duration_ms: 100,\n success_count: 1,\n failure_count: 0,\n- results: vec![serde_json::json!({\"id\": \"a\"})],\n+ results: vec![fabro_types::ParallelBranchResult {\n+ id: \"a\".to_string(),\n+ status: fabro_types::StageOutcome::Succeeded,\n+ context_updates: std::collections::BTreeMap::new(),\n+ }],\n })\n .await\n .unwrap();\n@@ -588,44 +488,6 @@ mod tests {\n assert!(paths.contains(&\"stages/001-work@2/parallel_results.json\"));\n }\n \n- #[test]\n- fn sanitize_ref_component_lowercases() {\n- assert_eq!(sanitize_ref_component(\"Hello\"), \"hello\");\n- }\n-\n- #[test]\n- fn sanitize_ref_component_replaces_special_chars() {\n- assert_eq!(sanitize_ref_component(\"a/b:c d\"), \"a-b-c-d\");\n- }\n-\n- #[test]\n- fn sanitize_ref_component_collapses_consecutive_dashes() {\n- assert_eq!(sanitize_ref_component(\"a///b\"), \"a-b\");\n- }\n-\n- #[test]\n- fn sanitize_ref_component_trims_leading_trailing_dashes() {\n- assert_eq!(sanitize_ref_component(\"--abc--\"), \"abc\");\n- }\n-\n- #[test]\n- fn sanitize_ref_component_mixed() {\n- assert_eq!(sanitize_ref_component(\"My Node!@#123\"), \"my-node-123\");\n- }\n-\n- #[test]\n- fn replace_worktree_on_clean_path() {\n- let dir = tempfile::tempdir().unwrap();\n- init_repo(dir.path());\n- create_branch(dir.path(), \"rw-branch\").unwrap();\n-\n- let wt_path = dir.path().join(\"rw-worktree\");\n- replace_worktree(dir.path(), &wt_path, \"rw-branch\").unwrap();\n- assert!(wt_path.join(\".git\").exists());\n-\n- remove_worktree(dir.path(), &wt_path).unwrap();\n- }\n-\n #[test]\n fn push_branch_fails_for_nonexistent_remote() {\n let dir = tempfile::tempdir().unwrap();\ndiff --git a/lib/crates/fabro-workflow/src/handler/agent.rs b/lib/crates/fabro-workflow/src/handler/agent.rs\nindex 16dbe0230..5a063e68e 100644\n--- a/lib/crates/fabro-workflow/src/handler/agent.rs\n+++ b/lib/crates/fabro-workflow/src/handler/agent.rs\n@@ -4,7 +4,7 @@ use std::sync::Arc;\n use async_trait::async_trait;\n use fabro_agent::Sandbox;\n use fabro_graphviz::graph::{Graph, Node};\n-use fabro_types::{RunId, StageModelUsage, StageTiming};\n+use fabro_types::{StageModelUsage, StageTiming};\n pub(crate) use structured_output::extract_status_fields;\n use tokio_util::sync::CancellationToken;\n \n@@ -268,10 +268,7 @@ impl Handler for AgentHandler {\n \n // 3. Call LLM backend (agent loop)\n let thread_id = context.thread_id();\n- let run_id = context\n- .run_id()\n- .parse::()\n- .map_err(|err| Error::handler_with_source(\"invalid internal run_id\", err))?;\n+ let run_id = context.parsed_run_id()?;\n let tool_hooks: Option> =\n services.run.hook_runner.as_ref().map(|hr| {\n Arc::new(fabro_hooks::WorkflowToolHookCallback {\ndiff --git a/lib/crates/fabro-workflow/src/handler/fan_in.rs b/lib/crates/fabro-workflow/src/handler/fan_in.rs\nindex aa03d8f74..b6b73db05 100644\n--- a/lib/crates/fabro-workflow/src/handler/fan_in.rs\n+++ b/lib/crates/fabro-workflow/src/handler/fan_in.rs\n@@ -2,665 +2,262 @@ use std::path::Path;\n use std::sync::Arc;\n \n use async_trait::async_trait;\n-use fabro_agent::Sandbox;\n use fabro_graphviz::graph::{Graph, Node};\n-use fabro_types::{StageModelUsage, StageTiming};\n-use tokio_util::sync::CancellationToken;\n \n-use super::agent::{CodergenBackend, CodergenResult, CodergenRunRequest};\n+use super::agent::CodergenBackend;\n+use super::prompt::PromptHandler;\n use super::{EngineServices, Handler};\n use crate::context::{Context, keys};\n use crate::error::Error;\n-use crate::event::{Emitter, Event, StageScope};\n-use crate::outcome::{Outcome, OutcomeExt};\n-use crate::sandbox_git::git_merge_ff_only;\n-\n-/// Consolidates results from a preceding parallel node and selects the best\n-/// candidate.\n+use crate::event::Emitter;\n+use crate::outcome::Outcome;\n+\n+/// Joins results from a preceding parallel node.\n+///\n+/// Promptless fan-in nodes are barriers. Prompted fan-in nodes use the same\n+/// execution path as standard prompt stages and synthesize the full ordered\n+/// branch result set without selecting workspace state.\n pub struct FanInHandler {\n- backend: Option>,\n+ prompt_handler: PromptHandler,\n }\n \n impl FanInHandler {\n #[must_use]\n pub fn new(backend: Option>) -> Self {\n- Self { backend }\n+ Self {\n+ prompt_handler: PromptHandler::new(backend),\n+ }\n+ }\n+}\n+\n+impl FanInHandler {\n+ async fn run_join(\n+ &self,\n+ node: &Node,\n+ context: &Context,\n+ graph: &Graph,\n+ run_dir: &Path,\n+ services: &EngineServices,\n+ simulated: bool,\n+ ) -> Result {\n+ let branch_count = validated_branch_count(context)?;\n+ if node\n+ .prompt()\n+ .is_some_and(|prompt| !prompt.trim().is_empty())\n+ {\n+ return if simulated {\n+ self.prompt_handler\n+ .simulate(node, context, graph, run_dir, services)\n+ .await\n+ } else {\n+ self.prompt_handler\n+ .execute(node, context, graph, run_dir, services)\n+ .await\n+ };\n+ }\n+ Ok(joined_outcome(branch_count, simulated))\n }\n }\n \n #[async_trait]\n impl Handler for FanInHandler {\n async fn shutdown(&self, emitter: &Arc) {\n- if let Some(backend) = self.backend.as_ref() {\n- backend.shutdown(emitter).await;\n- }\n+ self.prompt_handler.shutdown(emitter).await;\n }\n \n async fn simulate(\n &self,\n node: &Node,\n context: &Context,\n- _graph: &Graph,\n- _run_dir: &Path,\n- _services: &EngineServices,\n+ graph: &Graph,\n+ run_dir: &Path,\n+ services: &EngineServices,\n ) -> Result {\n- let results = context.get(keys::PARALLEL_RESULTS);\n- let Some(results) = results else {\n- return Ok(Outcome::fail_deterministic(\n- \"No parallel results to evaluate\",\n- ));\n- };\n-\n- let best = heuristic_select(&results);\n-\n- let mut outcome = Outcome::simulated(&node.id);\n- outcome.context_updates.insert(\n- keys::PARALLEL_FAN_IN_BEST_ID.to_string(),\n- serde_json::json!(best.id),\n- );\n- outcome.context_updates.insert(\n- keys::PARALLEL_FAN_IN_BEST_OUTCOME.to_string(),\n- serde_json::json!(best.status),\n- );\n- // Override the generic simulated notes with handler-specific detail.\n- outcome.notes = Some(format!(\"[Simulated] Selected best candidate: {}\", best.id));\n- Ok(outcome)\n+ self.run_join(node, context, graph, run_dir, services, true)\n+ .await\n }\n \n async fn execute(\n &self,\n node: &Node,\n context: &Context,\n- _graph: &Graph,\n+ graph: &Graph,\n run_dir: &Path,\n services: &EngineServices,\n ) -> Result {\n- let results = context.get(keys::PARALLEL_RESULTS);\n- let Some(results) = results else {\n- return Ok(Outcome::fail_deterministic(\n- \"No parallel results to evaluate\",\n- ));\n- };\n-\n- let prompt = node.prompt().filter(|p| !p.is_empty());\n-\n- let best = if let (Some(prompt_text), Some(backend)) = (prompt, &self.backend) {\n- llm_evaluate(\n- backend.as_ref(),\n- prompt_text,\n- &results,\n- context,\n- run_dir,\n- &node.id,\n- &services.run.emitter,\n- &services.run.sandbox,\n- services.run.cancel_token(),\n- )\n- .await?\n- } else {\n- heuristic_select(&results)\n- };\n-\n- // Check if all candidates failed — if so, return fail\n- let all_failed = if best.status == \"failed\" {\n- let empty_vec = vec![];\n- let arr = results.as_array().unwrap_or(&empty_vec);\n- arr.iter()\n- .all(|v| v.get(\"status\").and_then(|v| v.as_str()).unwrap_or(\"failed\") == \"failed\")\n- } else {\n- false\n- };\n-\n- if all_failed {\n- let mut outcome = Outcome::fail_deterministic(\"all candidates failed\");\n- outcome.timing = Some(best.timing);\n- return Ok(outcome);\n- }\n-\n- // --- Fast-forward to winner's HEAD when git isolation is active ---\n- let best_head_sha = {\n- let empty_vec = vec![];\n- let arr = results.as_array().unwrap_or(&empty_vec);\n- arr.iter()\n- .find(|v| v.get(\"id\").and_then(|v| v.as_str()) == Some(&best.id))\n- .and_then(|v| v.get(\"head_sha\").and_then(|v| v.as_str()).map(String::from))\n- };\n-\n- if let (Some(ref sha), Some(_)) = (&best_head_sha, services.git_state()) {\n- git_merge_ff_only(&*services.run.sandbox, sha).await;\n- }\n-\n- let mut outcome = Outcome::success();\n- outcome.context_updates.insert(\n- keys::PARALLEL_FAN_IN_BEST_ID.to_string(),\n- serde_json::json!(best.id),\n- );\n- outcome.context_updates.insert(\n- keys::PARALLEL_FAN_IN_BEST_OUTCOME.to_string(),\n- serde_json::json!(best.status),\n- );\n- if let Some(ref sha) = best_head_sha {\n- outcome.context_updates.insert(\n- keys::PARALLEL_FAN_IN_BEST_HEAD_SHA.to_string(),\n- serde_json::json!(sha),\n- );\n- }\n- outcome.notes = Some(format!(\"Selected best candidate: {}\", best.id));\n- outcome.timing = Some(best.timing);\n-\n- Ok(outcome)\n- }\n-}\n-\n-struct Candidate {\n- id: String,\n- status: String,\n- score: f64,\n- timing: StageTiming,\n-}\n-\n-fn status_rank(status: &str) -> u32 {\n- match status {\n- \"succeeded\" => 0,\n- \"partially_succeeded\" => 1,\n- \"failed\" => 2,\n- _ => 4,\n+ self.run_join(node, context, graph, run_dir, services, false)\n+ .await\n }\n }\n \n-fn heuristic_select(results: &serde_json::Value) -> Candidate {\n- let empty_vec = vec![];\n- let arr = results.as_array().unwrap_or(&empty_vec);\n- if arr.is_empty() {\n- return Candidate {\n- id: \"unknown\".to_string(),\n- status: \"failed\".to_string(),\n- score: 0.0,\n- timing: StageTiming::default(),\n- };\n+/// Validate that `parallel.results` exists and has the typed shape without\n+/// cloning the (potentially hydrated) branch payloads into a full\n+/// [`ParallelBranchResult`] vec that would go unused.\n+fn validated_branch_count(context: &Context) -> Result {\n+ #[derive(serde::Deserialize)]\n+ struct BranchShape {\n+ #[expect(dead_code, reason = \"deserialized only to validate the shape\")]\n+ id: String,\n+ #[expect(dead_code, reason = \"deserialized only to validate the shape\")]\n+ status: fabro_types::StageOutcome,\n }\n \n- let mut candidates: Vec = arr\n- .iter()\n- .map(|v| Candidate {\n- id: v\n- .get(\"id\")\n- .and_then(|v| v.as_str())\n- .unwrap_or(\"unknown\")\n- .to_string(),\n- status: v\n- .get(\"status\")\n- .and_then(|v| v.as_str())\n- .unwrap_or(\"failed\")\n- .to_string(),\n- score: v\n- .get(\"score\")\n- .and_then(serde_json::Value::as_f64)\n- .unwrap_or(0.0),\n- timing: StageTiming::default(),\n- })\n- .collect();\n-\n- candidates.sort_by(|a, b| {\n- let rank_cmp = status_rank(&a.status).cmp(&status_rank(&b.status));\n- if rank_cmp != std::cmp::Ordering::Equal {\n- return rank_cmp;\n- }\n- // Higher score is better, so reverse the comparison\n- let score_cmp = b\n- .score\n- .partial_cmp(&a.score)\n- .unwrap_or(std::cmp::Ordering::Equal);\n- if score_cmp != std::cmp::Ordering::Equal {\n- return score_cmp;\n- }\n- a.id.cmp(&b.id)\n- });\n-\n- candidates.into_iter().next().unwrap_or_else(|| Candidate {\n- id: \"unknown\".to_string(),\n- status: \"failed\".to_string(),\n- score: 0.0,\n- timing: StageTiming::default(),\n- })\n+ let value = context\n+ .get(keys::PARALLEL_RESULTS)\n+ .ok_or_else(|| Error::handler(\"No parallel results to join\"))?;\n+ let results: Vec = serde_json::from_value(value)\n+ .map_err(|err| Error::handler_with_source(\"Invalid parallel results\", err))?;\n+ Ok(results.len())\n }\n \n-/// Use an LLM backend to evaluate and rank parallel branch results.\n-#[allow(\n- clippy::too_many_arguments,\n- reason = \"Fan-in evaluation passes prompt, results, context, and runtime handles separately.\"\n-)]\n-async fn llm_evaluate(\n- backend: &dyn CodergenBackend,\n- prompt: &str,\n- results: &serde_json::Value,\n- context: &Context,\n- _run_dir: &Path,\n- node_id: &str,\n- emitter: &Arc,\n- sandbox: &Arc,\n- cancel_token: CancellationToken,\n-) -> Result {\n- let results_text =\n- serde_json::to_string_pretty(results).unwrap_or_else(|_| results.to_string());\n-\n- let full_prompt = format!(\n- \"{prompt}\\n\\nParallel branch results:\\n{results_text}\\n\\n\\\n- Respond with the ID of the best candidate.\"\n- );\n-\n- let stage_scope = StageScope::for_handler(context, node_id);\n-\n- emitter.emit_scoped(\n- &Event::Prompt {\n- stage: node_id.to_string(),\n- visit: stage_scope.visit,\n- text: full_prompt.clone(),\n- mode: Some(StageModelUsage::MODE_FAN_IN.to_string()),\n- provider: None,\n- model: None,\n- reasoning_effort: None,\n- speed: None,\n- },\n- &stage_scope,\n- );\n-\n- // Build a synthetic node for the backend call\n- let eval_node = Node::new(\"fan_in_eval\");\n-\n- // Fan-in evaluation runs outside a thread context, so pass None\n- match backend\n- .run(CodergenRunRequest {\n- node: &eval_node,\n- prompt: &full_prompt,\n- context,\n- thread_id: None,\n- emitter,\n- sandbox,\n- tool_hooks: None,\n- cancel_token,\n- agent_tool_runtime: fabro_agent::AgentToolRuntime::default(),\n- })\n- .await\n- {\n- Ok(CodergenResult::Full(outcome)) => {\n- let timing = outcome.timing.unwrap_or_default();\n- // If the backend returned a full Outcome, extract best_id from context_updates\n- let best_id = outcome\n- .context_updates\n- .get(keys::PARALLEL_FAN_IN_BEST_ID)\n- .and_then(|v| v.as_str())\n- .map(String::from)\n- .or_else(|| outcome.notes.clone())\n- .unwrap_or_else(|| \"unknown\".to_string());\n- let response_text =\n- serde_json::to_string_pretty(&outcome).unwrap_or_else(|_| \"{}\".to_string());\n- emitter.emit_scoped(\n- &Event::PromptCompleted {\n- node_id: node_id.to_string(),\n- response: response_text.clone(),\n- model: String::new(),\n- provider: String::new(),\n- billing: None,\n- },\n- &stage_scope,\n- );\n- Ok(Candidate {\n- id: best_id,\n- status: outcome.status.to_string(),\n- score: 0.0,\n- timing,\n- })\n- }\n- Ok(CodergenResult::Text { text, timing, .. }) => {\n- emitter.emit_scoped(\n- &Event::PromptCompleted {\n- node_id: node_id.to_string(),\n- response: text.clone(),\n- model: String::new(),\n- provider: String::new(),\n- billing: None,\n- },\n- &stage_scope,\n- );\n-\n- // The LLM responded with text; try to find a matching candidate ID\n- let text = text.trim().to_string();\n- let empty_vec = vec![];\n- let arr = results.as_array().unwrap_or(&empty_vec);\n-\n- // Check if the response text matches any candidate ID\n- for v in arr {\n- if let Some(id) = v.get(\"id\").and_then(|v| v.as_str()) {\n- if text.contains(id) {\n- let status = v\n- .get(\"status\")\n- .and_then(|v| v.as_str())\n- .unwrap_or(\"succeeded\")\n- .to_string();\n- let score = v\n- .get(\"score\")\n- .and_then(serde_json::Value::as_f64)\n- .unwrap_or(0.0);\n- return Ok(Candidate {\n- id: id.to_string(),\n- status,\n- score,\n- timing,\n- });\n- }\n- }\n- }\n-\n- // No match found; fall back to heuristic\n- let mut fallback = heuristic_select(results);\n- fallback.timing = timing;\n- Ok(fallback)\n- }\n- Err(_) => {\n- // LLM call failed; fall back to heuristic\n- Ok(heuristic_select(results))\n+fn joined_outcome(branch_count: usize, simulated: bool) -> Outcome {\n+ let mut outcome = Outcome::success();\n+ let prefix = if simulated { \"[Simulated] \" } else { \"\" };\n+ outcome.notes = Some(format!(\n+ \"{prefix}Joined {branch_count} parallel {}\",\n+ if branch_count == 1 {\n+ \"branch\"\n+ } else {\n+ \"branches\"\n }\n- }\n+ ));\n+ outcome\n }\n \n #[cfg(test)]\n mod tests {\n+ use fabro_graphviz::graph::AttrValue;\n+ use fabro_types::StageTiming;\n+ use tempfile::TempDir;\n+\n use super::*;\n+ use crate::handler::agent::{CodergenResult, CodergenRunRequest, OneShotRequest};\n use crate::outcome::StageOutcome;\n \n fn make_services() -> EngineServices {\n EngineServices::test_default()\n }\n \n- #[tokio::test]\n- async fn fan_in_no_results() {\n- let handler = FanInHandler::new(None);\n- let node = Node::new(\"fan_in\");\n- let context = Context::new();\n- let graph = Graph::new(\"test\");\n- let run_dir = Path::new(\"/tmp/test\");\n-\n- let outcome = handler\n- .execute(&node, &context, &graph, run_dir, &make_services())\n- .await\n- .unwrap();\n- assert_eq!(outcome.status, StageOutcome::Failed {\n- retry_requested: false,\n- });\n- }\n-\n- #[tokio::test]\n- async fn fan_in_selects_best() {\n- let handler = FanInHandler::new(None);\n- let node = Node::new(\"fan_in\");\n+ fn context_with_results() -> Context {\n let context = Context::new();\n context.set(\n keys::PARALLEL_RESULTS,\n serde_json::json!([\n- {\"id\": \"branch_a\", \"status\": \"failed\"},\n- {\"id\": \"branch_b\", \"status\": \"succeeded\"},\n+ {\n+ \"id\": \"branch_a\",\n+ \"status\": \"failed\",\n+ \"context_updates\": {\"command.output\": \"failure details\"}\n+ },\n+ {\n+ \"id\": \"branch_b\",\n+ \"status\": \"succeeded\",\n+ \"context_updates\": {\"response.branch_b\": \"complete response\"}\n+ }\n ]),\n );\n- let graph = Graph::new(\"test\");\n- let run_dir = Path::new(\"/tmp/test\");\n-\n- let outcome = handler\n- .execute(&node, &context, &graph, run_dir, &make_services())\n- .await\n- .unwrap();\n- assert_eq!(outcome.status, StageOutcome::Succeeded);\n- assert_eq!(\n- outcome.context_updates.get(keys::PARALLEL_FAN_IN_BEST_ID),\n- Some(&serde_json::json!(\"branch_b\"))\n- );\n+ context\n }\n \n #[tokio::test]\n- async fn fan_in_lexical_tiebreak() {\n- let handler = FanInHandler::new(None);\n- let node = Node::new(\"fan_in\");\n- let context = Context::new();\n- context.set(\n- keys::PARALLEL_RESULTS,\n- serde_json::json!([\n- {\"id\": \"c\", \"status\": \"succeeded\"},\n- {\"id\": \"a\", \"status\": \"succeeded\"},\n- {\"id\": \"b\", \"status\": \"succeeded\"},\n- ]),\n- );\n- let graph = Graph::new(\"test\");\n- let run_dir = Path::new(\"/tmp/test\");\n-\n- let outcome = handler\n- .execute(&node, &context, &graph, run_dir, &make_services())\n+ async fn promptless_fan_in_is_a_noop_barrier() {\n+ let outcome = FanInHandler::new(None)\n+ .execute(\n+ &Node::new(\"fan_in\"),\n+ &context_with_results(),\n+ &Graph::new(\"test\"),\n+ Path::new(\"/tmp/test\"),\n+ &make_services(),\n+ )\n .await\n .unwrap();\n- assert_eq!(\n- outcome.context_updates.get(keys::PARALLEL_FAN_IN_BEST_ID),\n- Some(&serde_json::json!(\"a\"))\n- );\n- }\n \n- #[test]\n- fn status_rank_ordering() {\n- assert!(status_rank(\"succeeded\") < status_rank(\"partially_succeeded\"));\n- assert!(status_rank(\"partially_succeeded\") < status_rank(\"failed\"));\n- assert!(status_rank(\"failed\") < status_rank(\"unknown\"));\n+ assert_eq!(outcome.status, StageOutcome::Succeeded);\n+ assert_eq!(outcome.notes.as_deref(), Some(\"Joined 2 parallel branches\"));\n+ assert!(outcome.context_updates.is_empty());\n }\n \n #[tokio::test]\n- async fn fan_in_no_backend_ignores_prompt() {\n- // When there's a prompt but no backend, it should fall back to heuristic\n- let handler = FanInHandler::new(None);\n- let mut node = Node::new(\"fan_in\");\n- node.attrs.insert(\n- \"prompt\".to_string(),\n- fabro_graphviz::graph::AttrValue::String(\"Pick the best branch\".to_string()),\n- );\n+ async fn fan_in_requires_typed_parallel_results() {\n let context = Context::new();\n- context.set(\n- keys::PARALLEL_RESULTS,\n- serde_json::json!([\n- {\"id\": \"branch_a\", \"status\": \"succeeded\"},\n- {\"id\": \"branch_b\", \"status\": \"failed\"},\n- ]),\n- );\n- let graph = Graph::new(\"test\");\n- let run_dir = Path::new(\"/tmp/test\");\n-\n- let outcome = handler\n- .execute(&node, &context, &graph, run_dir, &make_services())\n- .await\n- .unwrap();\n- assert_eq!(outcome.status, StageOutcome::Succeeded);\n- // Should still pick branch_a via heuristic (success beats fail)\n- assert_eq!(\n- outcome.context_updates.get(keys::PARALLEL_FAN_IN_BEST_ID),\n- Some(&serde_json::json!(\"branch_a\"))\n- );\n+ let missing = FanInHandler::new(None)\n+ .execute(\n+ &Node::new(\"fan_in\"),\n+ &context,\n+ &Graph::new(\"test\"),\n+ Path::new(\"/tmp/test\"),\n+ &make_services(),\n+ )\n+ .await;\n+ assert!(missing.is_err());\n+\n+ context.set(keys::PARALLEL_RESULTS, serde_json::json!([{\"id\": \"a\"}]));\n+ let invalid = FanInHandler::new(None)\n+ .execute(\n+ &Node::new(\"fan_in\"),\n+ &context,\n+ &Graph::new(\"test\"),\n+ Path::new(\"/tmp/test\"),\n+ &make_services(),\n+ )\n+ .await;\n+ assert!(invalid.is_err());\n }\n \n #[tokio::test]\n- async fn fan_in_with_backend_llm_eval() {\n- use tempfile::TempDir;\n-\n- use crate::handler::agent::{CodergenBackend, CodergenRunRequest};\n-\n- struct MockBackend;\n+ async fn prompted_fan_in_uses_standard_prompt_response_fields() {\n+ struct ReducerBackend;\n \n #[async_trait]\n- impl CodergenBackend for MockBackend {\n+ impl CodergenBackend for ReducerBackend {\n async fn run(&self, _request: CodergenRunRequest<'_>) -> Result {\n- // Return text that contains the ID \"branch_b\"\n- Ok(CodergenResult::Text {\n- text: \"The best candidate is branch_b\".to_string(),\n- usage: None,\n- files_touched: Vec::new(),\n- last_file_touched: None,\n- timing: StageTiming::default(),\n- })\n+ panic!(\"prompted fan-in must use one_shot like a standard prompt\")\n }\n- }\n \n- let handler = FanInHandler::new(Some(Box::new(MockBackend)));\n- let mut node = Node::new(\"fan_in\");\n- node.attrs.insert(\n- \"prompt\".to_string(),\n- fabro_graphviz::graph::AttrValue::String(\"Pick the best branch\".to_string()),\n- );\n- let context = Context::new();\n- context.set(\n- keys::PARALLEL_RESULTS,\n- serde_json::json!([\n- {\"id\": \"branch_a\", \"status\": \"succeeded\"},\n- {\"id\": \"branch_b\", \"status\": \"succeeded\"},\n- ]),\n- );\n- let graph = Graph::new(\"test\");\n- let tmp = TempDir::new().unwrap();\n-\n- let outcome = handler\n- .execute(&node, &context, &graph, tmp.path(), &make_services())\n- .await\n- .unwrap();\n- assert_eq!(outcome.status, StageOutcome::Succeeded);\n- // LLM chose branch_b\n- assert_eq!(\n- outcome.context_updates.get(keys::PARALLEL_FAN_IN_BEST_ID),\n- Some(&serde_json::json!(\"branch_b\"))\n- );\n- }\n-\n- #[tokio::test]\n- async fn fan_in_with_backend_copies_llm_timing_to_outcome() {\n- use tempfile::TempDir;\n-\n- use crate::handler::agent::{CodergenBackend, CodergenRunRequest};\n-\n- struct TimingBackend;\n-\n- #[async_trait]\n- impl CodergenBackend for TimingBackend {\n- async fn run(&self, _request: CodergenRunRequest<'_>) -> Result {\n+ async fn one_shot(&self, request: OneShotRequest<'_>) -> Result {\n+ assert!(request.prompt.contains(\"Synthesize every result\"));\n Ok(CodergenResult::Text {\n- text: \"branch_b\".to_string(),\n+ text: \"combined result\".to_string(),\n usage: None,\n files_touched: Vec::new(),\n last_file_touched: None,\n- timing: StageTiming::new(0, 200, 300),\n+ timing: StageTiming::new(0, 20, 30),\n })\n }\n }\n \n- let handler = FanInHandler::new(Some(Box::new(TimingBackend)));\n+ let handler = FanInHandler::new(Some(Box::new(ReducerBackend)));\n let mut node = Node::new(\"fan_in\");\n node.attrs.insert(\n \"prompt\".to_string(),\n- fabro_graphviz::graph::AttrValue::String(\"Pick the best branch\".to_string()),\n+ AttrValue::String(\"Synthesize every result\".to_string()),\n );\n- let context = Context::new();\n- context.set(\n- keys::PARALLEL_RESULTS,\n- serde_json::json!([\n- {\"id\": \"branch_a\", \"status\": \"succeeded\"},\n- {\"id\": \"branch_b\", \"status\": \"succeeded\"},\n- ]),\n- );\n- let graph = Graph::new(\"test\");\n- let tmp = TempDir::new().unwrap();\n-\n+ let run_dir = TempDir::new().unwrap();\n let outcome = handler\n- .execute(&node, &context, &graph, tmp.path(), &make_services())\n- .await\n- .unwrap();\n-\n- assert_eq!(outcome.timing, Some(StageTiming::new(0, 200, 300)));\n- }\n-\n- #[tokio::test]\n- async fn fan_in_all_fail_returns_fail() {\n- let handler = FanInHandler::new(None);\n- let node = Node::new(\"fan_in\");\n- let context = Context::new();\n- context.set(\n- keys::PARALLEL_RESULTS,\n- serde_json::json!([\n- {\"id\": \"branch_a\", \"status\": \"failed\"},\n- {\"id\": \"branch_b\", \"status\": \"failed\"},\n- {\"id\": \"branch_c\", \"status\": \"failed\"},\n- ]),\n- );\n- let graph = Graph::new(\"test\");\n- let run_dir = Path::new(\"/tmp/test\");\n-\n- let outcome = handler\n- .execute(&node, &context, &graph, run_dir, &make_services())\n+ .execute(\n+ &node,\n+ &context_with_results(),\n+ &Graph::new(\"test\"),\n+ run_dir.path(),\n+ &make_services(),\n+ )\n .await\n .unwrap();\n- assert_eq!(outcome.status, StageOutcome::Failed {\n- retry_requested: false,\n- });\n- assert!(\n- outcome\n- .failure_reason()\n- .unwrap()\n- .contains(\"all candidates failed\")\n- );\n- }\n-\n- #[tokio::test]\n- async fn fan_in_score_tiebreak() {\n- let handler = FanInHandler::new(None);\n- let node = Node::new(\"fan_in\");\n- let context = Context::new();\n- context.set(\n- keys::PARALLEL_RESULTS,\n- serde_json::json!([\n- {\"id\": \"branch_a\", \"status\": \"succeeded\", \"score\": 0.5},\n- {\"id\": \"branch_b\", \"status\": \"succeeded\", \"score\": 0.9},\n- {\"id\": \"branch_c\", \"status\": \"succeeded\", \"score\": 0.7},\n- ]),\n- );\n- let graph = Graph::new(\"test\");\n- let run_dir = Path::new(\"/tmp/test\");\n \n- let outcome = handler\n- .execute(&node, &context, &graph, run_dir, &make_services())\n- .await\n- .unwrap();\n assert_eq!(outcome.status, StageOutcome::Succeeded);\n- // branch_b has highest score\n assert_eq!(\n- outcome.context_updates.get(keys::PARALLEL_FAN_IN_BEST_ID),\n- Some(&serde_json::json!(\"branch_b\"))\n+ outcome.context_updates.get(&keys::response_key(\"fan_in\")),\n+ Some(&serde_json::json!(\"combined result\"))\n );\n- }\n-\n- #[tokio::test]\n- async fn fan_in_simulate_uses_heuristic() {\n- let handler = FanInHandler::new(None);\n- let node = Node::new(\"fan_in\");\n- let context = Context::new();\n- context.set(\n- keys::PARALLEL_RESULTS,\n- serde_json::json!([\n- {\"id\": \"branch_a\", \"status\": \"failed\"},\n- {\"id\": \"branch_b\", \"status\": \"succeeded\"},\n- ]),\n- );\n- let graph = Graph::new(\"test\");\n- let run_dir = Path::new(\"/tmp/test\");\n-\n- let outcome = handler\n- .simulate(&node, &context, &graph, run_dir, &make_services())\n- .await\n- .unwrap();\n- assert_eq!(outcome.status, StageOutcome::Succeeded);\n- assert!(outcome.notes.as_deref().unwrap().contains(\"[Simulated]\"));\n assert_eq!(\n- outcome.context_updates.get(keys::PARALLEL_FAN_IN_BEST_ID),\n- Some(&serde_json::json!(\"branch_b\"))\n+ outcome.context_updates.get(keys::LAST_RESPONSE),\n+ Some(&serde_json::json!(\"combined result\"))\n+ );\n+ assert_eq!(outcome.timing, Some(StageTiming::new(0, 20, 30)));\n+ assert!(\n+ outcome\n+ .context_updates\n+ .keys()\n+ .all(|key| !key.starts_with(\"parallel.fan_in.best_\"))\n );\n }\n }\ndiff --git a/lib/crates/fabro-workflow/src/handler/manager_loop.rs b/lib/crates/fabro-workflow/src/handler/manager_loop.rs\nindex d16291b68..431ed5d76 100644\n--- a/lib/crates/fabro-workflow/src/handler/manager_loop.rs\n+++ b/lib/crates/fabro-workflow/src/handler/manager_loop.rs\n@@ -14,7 +14,7 @@ use tokio::time::{sleep, timeout};\n use super::{EngineServices, Handler};\n use crate::artifact_upload::ArtifactSink;\n use crate::condition::evaluate_condition;\n-use crate::context::{Context, WorkflowContext, keys};\n+use crate::context::{Context, WorkflowContext, context_diff, keys};\n use crate::error::Error;\n use crate::operations::{ValidateInput, WorkflowInput, validate};\n use crate::outcome::{Outcome, OutcomeExt, StageOutcome};\n@@ -133,21 +133,6 @@ fn parse_child_graph(node: &Node, services: &EngineServices) -> Result,\n- after: &HashMap,\n-) -> HashMap {\n- let mut diff = HashMap::new();\n- for (key, value) in after {\n- if before.get(key) != Some(value) {\n- diff.insert(key.clone(), value.clone());\n- }\n- }\n- diff\n-}\n-\n #[async_trait]\n impl Handler for SubWorkflowHandler {\n async fn execute(\n@@ -273,7 +258,6 @@ impl Handler for SubWorkflowHandler {\n run: child_run,\n registry,\n interviewer,\n- git_state: std::sync::RwLock::new(None),\n base_env,\n github_token,\n inputs,\n@@ -299,8 +283,8 @@ impl Handler for SubWorkflowHandler {\n };\n \n // Compute context diff, filtering engine-internal keys\n- let after_snapshot = child_final_context.snapshot();\n- let raw_diff = context_diff(&before_snapshot, &after_snapshot);\n+ let raw_diff =\n+ context_diff(&before_snapshot, child_final_context.snapshot());\n let diff: HashMap = raw_diff\n .into_iter()\n .filter(|(key, _)| !keys::is_engine_internal_key(key))\n@@ -824,7 +808,7 @@ mod tests {\n let before = HashMap::new();\n let mut after = HashMap::new();\n after.insert(\"key\".to_string(), serde_json::json!(\"value\"));\n- let diff = context_diff(&before, &after);\n+ let diff = context_diff(&before, after);\n assert_eq!(diff.len(), 1);\n assert_eq!(diff.get(\"key\"), Some(&serde_json::json!(\"value\")));\n }\n@@ -835,7 +819,7 @@ mod tests {\n before.insert(\"key\".to_string(), serde_json::json!(\"old\"));\n let mut after = HashMap::new();\n after.insert(\"key\".to_string(), serde_json::json!(\"new\"));\n- let diff = context_diff(&before, &after);\n+ let diff = context_diff(&before, after);\n assert_eq!(diff.len(), 1);\n assert_eq!(diff.get(\"key\"), Some(&serde_json::json!(\"new\")));\n }\n@@ -846,7 +830,7 @@ mod tests {\n before.insert(\"key\".to_string(), serde_json::json!(\"same\"));\n let mut after = HashMap::new();\n after.insert(\"key\".to_string(), serde_json::json!(\"same\"));\n- let diff = context_diff(&before, &after);\n+ let diff = context_diff(&before, after);\n assert!(diff.is_empty());\n }\n \n@@ -855,7 +839,7 @@ mod tests {\n let mut before = HashMap::new();\n before.insert(\"removed\".to_string(), serde_json::json!(\"gone\"));\n let after = HashMap::new();\n- let diff = context_diff(&before, &after);\n+ let diff = context_diff(&before, after);\n assert!(diff.is_empty());\n }\n \n@@ -876,7 +860,7 @@ mod tests {\n after.insert(\"response.plan\".to_string(), serde_json::json!(\"the plan\"));\n after.insert(\"review.result\".to_string(), serde_json::json!(\"approved\"));\n \n- let raw_diff = context_diff(&before, &after);\n+ let raw_diff = context_diff(&before, after);\n let filtered: HashMap = raw_diff\n .into_iter()\n .filter(|(key, _)| !keys::is_engine_internal_key(key))\ndiff --git a/lib/crates/fabro-workflow/src/handler/parallel.rs b/lib/crates/fabro-workflow/src/handler/parallel.rs\nindex 22aa2aa9c..307647193 100644\n--- a/lib/crates/fabro-workflow/src/handler/parallel.rs\n+++ b/lib/crates/fabro-workflow/src/handler/parallel.rs\n@@ -1,59 +1,39 @@\n-use std::path::{Path, PathBuf};\n+use std::collections::{BTreeMap, HashMap, HashSet};\n+use std::path::Path;\n use std::sync::Arc;\n use std::time::Instant;\n \n use async_trait::async_trait;\n-use fabro_agent::{Sandbox, WorktreeOptions, WorktreeSandbox};\n use fabro_graphviz::graph::{AttrValue, Graph, Node};\n use fabro_hooks::{HookContext, HookEvent};\n-use fabro_types::{ParallelBranchId, RunId, StageId};\n+use fabro_types::{ParallelBranchId, ParallelBranchResult, StageId, StageOutcome};\n+use futures::FutureExt;\n use tokio::sync::Semaphore;\n+use tokio::task::JoinHandle;\n \n use super::{EngineServices, Handler};\n-use crate::context::{Context, WorkflowContext, keys};\n+use crate::context::{Context, WorkflowContext, context_diff, keys};\n use crate::error::Error;\n use crate::event::{Event, RunNoticeCode, RunNoticeLevel, StageScope};\n-use crate::git::sanitize_ref_component;\n use crate::hook_context::set_hook_node;\n-use crate::millis_u64;\n-use crate::outcome::{FailureCategory, FailureDetail, Outcome, OutcomeExt, StageOutcome};\n-use crate::run_dir::visit_from_context;\n-use crate::sandbox_git::{\n- GIT_REMOTE, checked_git_checkpoint, git_merge_ff_only, git_remove_worktree,\n-};\n+use crate::outcome::{FailureCategory, FailureDetail, Outcome, OutcomeExt};\n+use crate::{artifact, millis_u64};\n \n /// Fans out execution to multiple branches concurrently.\n-/// Each branch gets an isolated context clone and runs independently.\n+/// Each branch gets an isolated context fork and shares the run sandbox.\n pub struct ParallelHandler;\n \n-/// Parse join policy from node attributes.\n-#[derive(Debug, Clone)]\n-enum JoinPolicy {\n- WaitAll,\n- FirstSuccess,\n-}\n-\n-impl std::fmt::Display for JoinPolicy {\n- fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {\n- match self {\n- Self::WaitAll => write!(f, \"wait_all\"),\n- Self::FirstSuccess => write!(f, \"first_success\"),\n- }\n- }\n+struct BranchResult {\n+ result: ParallelBranchResult,\n+ outcome: Outcome,\n }\n \n-fn parse_join_policy(raw: &str) -> JoinPolicy {\n- if raw == \"first_success\" {\n- return JoinPolicy::FirstSuccess;\n- }\n- JoinPolicy::WaitAll\n-}\n-\n-struct BranchResult {\n- id: String,\n- outcome: Outcome,\n- head_sha: Option,\n- worktree_path: Option,\n+struct BranchDispatch {\n+ index: usize,\n+ target_id: String,\n+ branch_id: ParallelBranchId,\n+ scope: StageScope,\n+ handle: JoinHandle>,\n }\n \n #[async_trait]\n@@ -66,58 +46,7 @@ impl Handler for ParallelHandler {\n run_dir: &Path,\n services: &EngineServices,\n ) -> Result {\n- let branches = graph.outgoing_edges(&node.id);\n- if branches.is_empty() {\n- return Ok(Outcome::fail_classify(\"No branches for parallel node\"));\n- }\n-\n- // Dispatch each branch child via dispatch_handler (which will call simulate)\n- let mut branch_results: Vec = Vec::new();\n- for edge in &branches {\n- let target_id = &edge.to;\n- if let Some(target_node) = graph.nodes.get(target_id) {\n- let handler = services.registry.resolve(target_node);\n- let branch_context = context.fork();\n- let outcome = super::dispatch_handler(\n- handler,\n- target_node,\n- &branch_context,\n- graph,\n- run_dir,\n- services,\n- )\n- .await?;\n- branch_results.push(BranchResult {\n- id: target_id.clone(),\n- outcome,\n- head_sha: None,\n- worktree_path: None,\n- });\n- }\n- }\n-\n- let total = branch_results.len();\n- context.set(keys::PARALLEL_BRANCH_COUNT, serde_json::json!(total));\n-\n- let results_json: Vec = branch_results\n- .iter()\n- .map(|r| {\n- serde_json::json!({\n- \"id\": r.id,\n- \"status\": r.outcome.status.to_string(),\n- })\n- })\n- .collect();\n- context.set(keys::PARALLEL_RESULTS, serde_json::json!(results_json));\n-\n- let join_node = find_join_node(&branch_results, graph);\n-\n- let mut outcome = Outcome::simulated(&node.id);\n- outcome.notes = Some(format!(\n- \"[Simulated] Parallel node dispatched {total} branches\"\n- ));\n- outcome.jump_to_node = join_node;\n- Ok(outcome)\n+ run_branches(node, context, graph, run_dir, services, true).await\n }\n \n async fn execute(\n@@ -128,567 +57,380 @@ impl Handler for ParallelHandler {\n run_dir: &Path,\n services: &EngineServices,\n ) -> Result {\n- // Build per-branch sandboxes (sequentially for git setup)\n- struct BranchSetup {\n- target_id: String,\n- branch_index: usize,\n- parallel_branch_id: ParallelBranchId,\n- branch_context: Context,\n- sandbox: Arc,\n- worktree_path: Option,\n- }\n-\n- let parallel_start = Instant::now();\n- let branches = graph.outgoing_edges(&node.id);\n- if branches.is_empty() {\n- return Ok(Outcome::fail_classify(\"No branches for parallel node\"));\n- }\n+ run_branches(node, context, graph, run_dir, services, false).await\n+ }\n+}\n \n- let join_policy = parse_join_policy(\n- node.attrs\n- .get(\"join_policy\")\n- .and_then(|v| v.as_str())\n- .unwrap_or(\"wait_all\"),\n+async fn run_branches(\n+ node: &Node,\n+ context: &Context,\n+ graph: &Graph,\n+ run_dir: &Path,\n+ services: &EngineServices,\n+ simulated: bool,\n+) -> Result {\n+ let parallel_start = Instant::now();\n+ let branches = graph.outgoing_edges(&node.id);\n+\n+ let parallel_stage_scope = StageScope::for_handler(context, &node.id);\n+ let parallel_group_id = StageId::new(node.id.clone(), parallel_stage_scope.visit);\n+ services.run.emitter.emit_scoped(\n+ &Event::ParallelStarted {\n+ node_id: node.id.clone(),\n+ visit: parallel_stage_scope.visit,\n+ branch_count: branches.len(),\n+ },\n+ ¶llel_stage_scope,\n+ );\n+ emit_parallel_hook(services, context, graph, node, HookEvent::ParallelStart).await?;\n+\n+ let max_parallel = node\n+ .attrs\n+ .get(\"max_parallel\")\n+ .and_then(AttrValue::as_i64)\n+ .unwrap_or(4);\n+ let max_parallel = usize::try_from(max_parallel).unwrap_or(4).max(1);\n+ let semaphore = Arc::new(Semaphore::new(max_parallel));\n+ let shared_graph = Arc::new(graph.clone());\n+ let parent_snapshot = Arc::new(context.snapshot());\n+\n+ let mut dispatches = Vec::with_capacity(branches.len());\n+ for (branch_index, edge) in branches.iter().enumerate() {\n+ let target_id = edge.to.clone();\n+ let parallel_branch_id = ParallelBranchId::new(\n+ parallel_group_id.clone(),\n+ u32::try_from(branch_index).unwrap_or(u32::MAX),\n );\n-\n- let parallel_stage_scope = StageScope::for_handler(context, &node.id);\n- let parallel_group_id = StageId::new(node.id.clone(), parallel_stage_scope.visit);\n-\n- services.run.emitter.emit_scoped(\n- &Event::ParallelStarted {\n- node_id: node.id.clone(),\n- visit: parallel_stage_scope.visit,\n- branch_count: branches.len(),\n- join_policy: join_policy.to_string(),\n- },\n- ¶llel_stage_scope,\n+ let branch_context = Context::from_values(parent_snapshot.as_ref().clone());\n+ branch_context.set(\n+ keys::INTERNAL_PARALLEL_GROUP_ID,\n+ serde_json::Value::String(parallel_group_id.to_string()),\n+ );\n+ branch_context.set(\n+ keys::INTERNAL_PARALLEL_BRANCH_ID,\n+ serde_json::Value::String(parallel_branch_id.to_string()),\n );\n- {\n- let run_id = context\n- .run_id()\n- .parse::()\n- .map_err(|err| Error::handler_with_source(\"invalid internal run_id\", err))?;\n- let mut hook_ctx =\n- HookContext::new(HookEvent::ParallelStart, run_id, graph.name.clone());\n- set_hook_node(&mut hook_ctx, node);\n- let _ = services.run.run_hooks(&hook_ctx).await;\n- }\n- let max_parallel = node\n- .attrs\n- .get(\"max_parallel\")\n- .and_then(AttrValue::as_i64)\n- .unwrap_or(4);\n- let max_parallel = usize::try_from(max_parallel).unwrap_or(4).max(1);\n-\n- let semaphore = Arc::new(Semaphore::new(max_parallel));\n- let git_state = services.git_state();\n-\n- // --- Git isolation: checkpoint \"parallel base\" before fan-out ---\n- let base_sha: Option = if let Some(ref gs) = git_state {\n- let result = checked_git_checkpoint(\n- &services.run.sandbox_git,\n- &*services.run.sandbox,\n- &gs.run_id.to_string(),\n- &node.id,\n- \"parallel_base\",\n- 0,\n- None,\n- &gs.checkpoint,\n- &gs.git_author,\n- )\n- .await;\n- match result {\n- Ok(sha) => Some(sha),\n- Err(e) if e.to_string() == \"sandbox git unavailable\" => {\n- return Err(Error::handler_with_source(\"sandbox git unavailable\", e));\n- }\n- Err(e) => {\n- tracing::warn!(\n- error = %fabro_sandbox::display_for_log(&e),\n- \"parallel base checkpoint failed\"\n- );\n- services.run.emitter.notice_with_tail(\n- RunNoticeLevel::Warn,\n- RunNoticeCode::ParallelBaseCheckpointFailed,\n- format!(\"Could not checkpoint base state before parallel branches: {e}\"),\n- fabro_sandbox::default_redacted_output_tail(&e),\n- );\n- None\n- }\n- }\n- } else {\n- None\n- };\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- let branch_context = context.fork();\n- let parallel_branch_id = ParallelBranchId::new(\n- parallel_group_id.clone(),\n- u32::try_from(branch_index).unwrap_or(u32::MAX),\n- );\n- branch_context.set(\n- keys::INTERNAL_PARALLEL_GROUP_ID,\n- serde_json::Value::String(parallel_group_id.to_string()),\n- );\n- branch_context.set(\n- keys::INTERNAL_PARALLEL_BRANCH_ID,\n- serde_json::Value::String(parallel_branch_id.to_string()),\n- );\n-\n- let (branch_sandbox, worktree_path): (Arc, Option) = if let (\n- Some(ref gs),\n- Some(ref bsha),\n- ) =\n- (&git_state, &base_sha)\n- {\n- let branch_key = &target_id;\n- let visit = visit_from_context(&branch_context);\n- let branch_name = format!(\n- \"fabro/run/parallel/{}/{}/pass{}/{}\",\n- gs.run_id,\n- sanitize_ref_component(&node.id),\n- visit,\n- sanitize_ref_component(branch_key),\n- );\n-\n- // Compute worktree path (each sandbox type knows its own path scheme)\n- let wt_path_str = services.run.sandbox.parallel_worktree_path(\n- run_dir,\n- &gs.run_id.to_string(),\n- &node.id,\n- branch_key,\n- );\n- tracing::debug!(branch = %branch_name, path = %wt_path_str, \"Creating worktree for parallel branch\");\n-\n- // Set up worktree via WorktreeSandbox\n- let wt_config = WorktreeOptions {\n- branch_name: branch_name.clone(),\n- base_sha: bsha.clone(),\n- worktree_path: wt_path_str.clone(),\n- skip_branch_creation: false,\n- setup_intent: None,\n- };\n- let mut wt_sandbox =\n- WorktreeSandbox::new(Arc::clone(&services.run.sandbox), wt_config);\n- wt_sandbox\n- .set_event_callback(Arc::clone(&services.run.emitter).worktree_callback());\n- wt_sandbox\n- .initialize()\n- .await\n- .map_err(|e| Error::handler_with_source(\"worktree setup failed\", e))?;\n-\n- branch_context.set(keys::INTERNAL_WORK_DIR, serde_json::json!(&wt_path_str));\n-\n- let wt_path = PathBuf::from(&wt_path_str);\n- let env: Arc = Arc::new(wt_sandbox);\n- (env, Some(wt_path))\n- } else {\n- (Arc::clone(&services.run.sandbox), None)\n- };\n-\n- branch_setups.push(BranchSetup {\n- target_id,\n- branch_index,\n- parallel_branch_id,\n- branch_context,\n- sandbox: branch_sandbox,\n- worktree_path,\n- });\n- }\n \n- // --- Fan out: concurrent execution ---\n- let mut handles = Vec::new();\n- for setup in branch_setups {\n- let parent_run = Arc::clone(&services.run);\n- let registry = Arc::clone(&services.registry);\n- let interviewer = Arc::clone(&services.interviewer);\n- let base_env = services.base_env.clone();\n- let github_token = services.github_token.clone();\n- let inputs = services.inputs.clone();\n- let dry_run = services.dry_run;\n- let workflow_path = services.workflow_path.clone();\n- let workflow_bundle = services.workflow_bundle.clone();\n- let graph = graph.clone();\n- let run_dir = run_dir.to_path_buf();\n- let sem = Arc::clone(&semaphore);\n- let has_git = git_state.is_some();\n- let run_id = git_state.as_ref().map(|gs| gs.run_id);\n- let git_author = git_state\n- .as_ref()\n- .map(|gs| gs.git_author.clone())\n- .unwrap_or_default();\n- let checkpoint = git_state\n- .as_ref()\n- .map(|gs| gs.checkpoint.clone())\n- .unwrap_or_default();\n- let group_id = parallel_group_id.clone();\n- let branch_scope = StageScope::for_parallel_branch(\n- setup.target_id.clone(),\n- 1,\n- group_id.clone(),\n- setup.parallel_branch_id.clone(),\n- );\n+ let mut branch_services = services.clone();\n+ branch_services.dry_run = simulated || services.dry_run;\n+ let parent_snapshot = Arc::clone(&parent_snapshot);\n+ let graph = Arc::clone(&shared_graph);\n+ let run_dir = run_dir.to_path_buf();\n+ let semaphore = Arc::clone(&semaphore);\n+ let group_id = parallel_group_id.clone();\n+ let branch_scope = StageScope::for_parallel_branch(\n+ target_id.clone(),\n+ 1,\n+ group_id.clone(),\n+ parallel_branch_id.clone(),\n+ );\n \n- let handle = tokio::spawn(async move {\n- let _permit = sem\n- .acquire()\n- .await\n- .map_err(|e| Error::handler_with_source(\"semaphore error\", e))?;\n-\n- parent_run.emitter.emit_scoped(\n- &Event::ParallelBranchStarted {\n- parallel_group_id: group_id.clone(),\n- parallel_branch_id: setup.parallel_branch_id.clone(),\n- branch: setup.target_id.clone(),\n- index: setup.branch_index,\n- },\n- &branch_scope,\n- );\n+ dispatches.push(BranchDispatch {\n+ index: branch_index,\n+ target_id: target_id.clone(),\n+ branch_id: parallel_branch_id.clone(),\n+ scope: branch_scope.clone(),\n+ handle: tokio::spawn(async move {\n let branch_start = Instant::now();\n+ let task = async {\n+ let permit = semaphore.acquire();\n+ tokio::pin!(permit);\n+ let cancel_token = branch_services.run.cancel_token();\n+ let _permit = tokio::select! {\n+ biased;\n+ () = cancel_token.cancelled() => {\n+ return Err(Error::Cancelled);\n+ }\n+ permit = &mut permit => permit\n+ .map_err(|err| Error::handler_with_source(\"semaphore error\", err))?,\n+ };\n+ branch_services.run.emitter.emit_scoped(\n+ &Event::ParallelBranchStarted {\n+ parallel_group_id: group_id.clone(),\n+ parallel_branch_id: parallel_branch_id.clone(),\n+ branch: target_id.clone(),\n+ index: branch_index,\n+ },\n+ &branch_scope,\n+ );\n \n- let Some(target_node) = graph.nodes.get(&setup.target_id) else {\n- let outcome = Outcome::fail_classify(format!(\n- \"branch target node not found: {}\",\n- setup.target_id\n- ));\n- parent_run.emitter.emit_scoped(\n+ let outcome = match graph.nodes.get(&target_id) {\n+ Some(target_node) => {\n+ let handler = branch_services.registry.resolve(target_node);\n+ match super::dispatch_handler(\n+ handler,\n+ target_node,\n+ &branch_context,\n+ &graph,\n+ &run_dir,\n+ &branch_services,\n+ )\n+ .await\n+ {\n+ Ok(outcome) => outcome,\n+ Err(Error::Cancelled) => return Err(Error::Cancelled),\n+ Err(err) => err.to_fail_outcome(),\n+ }\n+ }\n+ None => Outcome::fail_classify(format!(\n+ \"branch target node not found: {target_id}\"\n+ )),\n+ };\n+\n+ let context_updates = branch_context_updates(\n+ &parent_snapshot,\n+ branch_context.snapshot(),\n+ &outcome.context_updates,\n+ );\n+ let result = ParallelBranchResult {\n+ id: target_id.clone(),\n+ status: outcome.status,\n+ context_updates,\n+ };\n+ branch_services.run.emitter.emit_scoped(\n &Event::ParallelBranchCompleted {\n parallel_group_id: group_id.clone(),\n- parallel_branch_id: setup.parallel_branch_id.clone(),\n- branch: setup.target_id.clone(),\n- index: setup.branch_index,\n+ parallel_branch_id: parallel_branch_id.clone(),\n+ branch: target_id.clone(),\n+ index: branch_index,\n duration_ms: millis_u64(branch_start.elapsed()),\n- status: \"failed\".to_string(),\n- head_sha: None,\n+ status: result.status,\n },\n &branch_scope,\n );\n- return Ok(BranchResult {\n- id: setup.target_id.clone(),\n- outcome,\n- head_sha: None,\n- worktree_path: setup.worktree_path,\n- });\n+ Ok::(BranchResult { result, outcome })\n };\n \n- let branch_services = EngineServices {\n- run: parent_run.with_sandbox(Arc::clone(&setup.sandbox)),\n- registry: Arc::clone(®istry),\n- interviewer,\n- git_state: std::sync::RwLock::new(None),\n- base_env: base_env.clone(),\n- github_token: github_token.clone(),\n- inputs: inputs.clone(),\n- dry_run,\n- workflow_path,\n- workflow_bundle,\n- };\n- let handler = registry.resolve(target_node);\n- let outcome = super::dispatch_handler(\n- handler,\n- target_node,\n- &setup.branch_context,\n- &graph,\n- &run_dir,\n- &branch_services,\n- )\n- .await?;\n-\n- // Checkpoint commit after branch execution (capture head_sha)\n- let head_sha = if has_git {\n- let rid =\n- run_id.map_or_else(|| \"unknown\".to_string(), |run_id| run_id.to_string());\n- let nid = &setup.target_id;\n- let status_str = outcome.status.to_string();\n- // Use exec_command to commit and capture HEAD in the branch worktree\n- let git_r = GIT_REMOTE;\n- let add_cmd = format!(\"{git_r} add -A\");\n- let add_result = setup\n- .sandbox\n- .exec_command(&add_cmd, checkpoint.commit_timeout_ms, None, None, None)\n- .await;\n- if add_result\n- .as_ref()\n- .is_ok_and(fabro_sandbox::ExecResult::is_success)\n- {\n- let msg = format!(\"fabro({rid}): {nid} ({status_str})\");\n- let commit_cmd = parallel_branch_commit_cmd(\n- git_r,\n- &git_author.name,\n- &git_author.email,\n- &msg,\n- checkpoint.skip_git_hooks,\n+ match std::panic::AssertUnwindSafe(task).catch_unwind().await {\n+ Ok(result) => result,\n+ Err(payload) => {\n+ let result =\n+ failed_branch_result(&target_id, super::format_panic_message(&payload));\n+ branch_services.run.emitter.emit_scoped(\n+ &Event::ParallelBranchCompleted {\n+ parallel_group_id: group_id,\n+ parallel_branch_id,\n+ branch: target_id,\n+ index: branch_index,\n+ duration_ms: millis_u64(branch_start.elapsed()),\n+ status: result.result.status,\n+ },\n+ &branch_scope,\n );\n- let _ = setup\n- .sandbox\n- .exec_command(\n- &commit_cmd,\n- checkpoint.commit_timeout_ms,\n- None,\n- None,\n- None,\n- )\n- .await;\n+ Ok(result)\n }\n- let sha_cmd = format!(\"{git_r} rev-parse HEAD\");\n- let sha_result = setup\n- .sandbox\n- .exec_command(&sha_cmd, 10_000, None, None, None)\n- .await;\n- match sha_result {\n- Ok(r) if r.is_success() => {\n- let sha = r.stdout.trim().to_string();\n- parent_run.emitter.emit_scoped(\n- &Event::GitCommit {\n- node_id: Some(setup.target_id.clone()),\n- sha: sha.clone(),\n- },\n- &branch_scope,\n- );\n- Some(sha)\n- }\n- _ => None,\n- }\n- } else {\n- None\n- };\n-\n- parent_run.emitter.emit_scoped(\n- &Event::ParallelBranchCompleted {\n- parallel_group_id: group_id.clone(),\n- parallel_branch_id: setup.parallel_branch_id.clone(),\n- branch: setup.target_id.clone(),\n- index: setup.branch_index,\n- duration_ms: millis_u64(branch_start.elapsed()),\n- status: outcome.status.to_string(),\n- head_sha: head_sha.clone(),\n- },\n- &branch_scope,\n- );\n-\n- Ok::(BranchResult {\n- id: setup.target_id,\n- outcome,\n- head_sha,\n- worktree_path: setup.worktree_path,\n- })\n- });\n- handles.push(handle);\n- }\n-\n- // Collect results\n- let mut results: Vec = Vec::new();\n- let mut handles = handles.into_iter();\n- while let Some(handle) = handles.next() {\n- match handle.await {\n- Ok(Ok(result)) => {\n- results.push(result);\n- }\n- Ok(Err(Error::Cancelled)) => {\n- for handle in handles {\n- handle.abort();\n- }\n- return Err(Error::Cancelled);\n- }\n- Ok(Err(e)) => {\n- results.push(BranchResult {\n- id: String::new(),\n- outcome: e.to_fail_outcome(),\n- head_sha: None,\n- worktree_path: None,\n- });\n- }\n- Err(join_err) => {\n- results.push(BranchResult {\n- id: String::new(),\n- outcome: Outcome::fail_classify(format!(\n- \"task join error: {join_err}\"\n- )),\n- head_sha: None,\n- worktree_path: None,\n- });\n- }\n- }\n- }\n-\n- // --- Git isolation: clean up worktrees, then ff-merge winner ---\n- if git_state.is_some() {\n- // Clean up worktrees first\n- for result in &results {\n- if let Some(ref wt_path) = result.worktree_path {\n- let wt_str = wt_path.to_string_lossy().into_owned();\n- git_remove_worktree(&*services.run.sandbox, &wt_str).await;\n- services\n- .run\n- .emitter\n- .emit(&Event::GitWorktreeRemove { path: wt_str });\n }\n- }\n-\n- // Fast-forward main branch to first successful branch (lexically sorted).\n- // This must happen here — before the engine creates its own checkpoint commit\n- // on the main branch — so that subsequent commits are descendants of the\n- // winner.\n- let mut successful: Vec<_> = results\n- .iter()\n- .filter(|r| r.outcome.status == StageOutcome::Succeeded && r.head_sha.is_some())\n- .collect();\n- successful.sort_by(|a, b| a.id.cmp(&b.id));\n- if let Some(winner) = successful.first() {\n- if let Some(sha) = winner.head_sha.as_ref() {\n- git_merge_ff_only(&*services.run.sandbox, sha).await;\n- }\n- }\n- }\n-\n- // Count successes and failures\n- let success_count = results\n- .iter()\n- .filter(|r| r.outcome.status == StageOutcome::Succeeded)\n- .count();\n- let fail_count = results\n- .iter()\n- .filter(|r| r.outcome.status.is_failure())\n- .count();\n- let total = results.len();\n-\n- // Store results as JSON in context for downstream fan-in\n- let results_json: Vec = results\n- .iter()\n- .map(|r| {\n- let mut entry = serde_json::json!({\n- \"id\": r.id,\n- \"status\": r.outcome.status.to_string(),\n- });\n- if let Some(ref sha) = r.head_sha {\n- entry[\"head_sha\"] = serde_json::json!(sha);\n- }\n- entry\n- })\n- .collect();\n- context.set(keys::PARALLEL_RESULTS, serde_json::json!(results_json));\n- context.set(keys::PARALLEL_BRANCH_COUNT, serde_json::json!(total));\n-\n- services.run.emitter.emit_scoped(\n- &Event::ParallelCompleted {\n- node_id: node.id.clone(),\n- visit: parallel_stage_scope.visit,\n- duration_ms: millis_u64(parallel_start.elapsed()),\n- success_count,\n- failure_count: fail_count,\n- results: results_json.clone(),\n- },\n- ¶llel_stage_scope,\n- );\n- {\n- let run_id = context\n- .run_id()\n- .parse::()\n- .map_err(|err| Error::handler_with_source(\"invalid internal run_id\", err))?;\n- let mut hook_ctx =\n- HookContext::new(HookEvent::ParallelComplete, run_id, graph.name.clone());\n- set_hook_node(&mut hook_ctx, node);\n- let _ = services.run.run_hooks(&hook_ctx).await;\n- }\n+ }),\n+ });\n+ }\n \n- // Evaluate join policy\n- let status = match join_policy {\n- JoinPolicy::WaitAll => {\n- if fail_count == 0 {\n- StageOutcome::Succeeded\n- } else {\n- StageOutcome::PartiallySucceeded\n- }\n- }\n- JoinPolicy::FirstSuccess => {\n- if success_count > 0 {\n- StageOutcome::Succeeded\n- } else {\n- StageOutcome::Failed {\n- retry_requested: false,\n- }\n- }\n+ // Awaiting in dispatch order keeps `results` aligned with the node's\n+ // outgoing-edge order regardless of branch completion order.\n+ let mut results = Vec::with_capacity(dispatches.len());\n+ let mut cancelled = false;\n+ for dispatch in dispatches {\n+ let (result, emit_completion) = match dispatch.handle.await {\n+ Ok(Ok(result)) => (result, false),\n+ Ok(Err(Error::Cancelled)) => {\n+ cancelled = true;\n+ (\n+ failed_branch_result(&dispatch.target_id, \"branch cancelled\"),\n+ true,\n+ )\n }\n+ Ok(Err(err)) => (\n+ failed_branch_result(&dispatch.target_id, err.to_string()),\n+ true,\n+ ),\n+ Err(join_err) => (\n+ failed_branch_result(&dispatch.target_id, format!(\"task join error: {join_err}\")),\n+ true,\n+ ),\n };\n-\n- // Find the join/convergence node: follow each branch's outgoing edges\n- // and find the common downstream target (typically the fan-in node).\n- let join_node = find_join_node(&results, graph);\n-\n- let is_fail = status.is_failure();\n- let mut outcome = Outcome {\n- status,\n- notes: Some(format!(\n- \"Parallel node dispatched {total} branches ({success_count} succeeded, {fail_count} failed)\"\n- )),\n- failure: if is_fail {\n- Some(FailureDetail::new(\n- format!(\"Join policy not satisfied: {success_count}/{total} succeeded\"),\n- FailureCategory::Deterministic,\n- ))\n- } else {\n- None\n- },\n- jump_to_node: if is_fail { None } else { join_node },\n- ..Outcome::success()\n- };\n-\n- if is_fail {\n- outcome.suggested_next_ids.clear();\n+ if emit_completion {\n+ services.run.emitter.emit_scoped(\n+ &Event::ParallelBranchCompleted {\n+ parallel_group_id: parallel_group_id.clone(),\n+ parallel_branch_id: dispatch.branch_id,\n+ branch: dispatch.target_id,\n+ index: dispatch.index,\n+ duration_ms: 0,\n+ status: result.result.status,\n+ },\n+ &dispatch.scope,\n+ );\n+ }\n+ if result.outcome.failure_category() == Some(FailureCategory::Canceled) {\n+ cancelled = true;\n }\n+ results.push(result);\n+ }\n+ if cancelled {\n+ return Err(Error::Cancelled);\n+ }\n \n- Ok(outcome)\n+ let success_count = results\n+ .iter()\n+ .filter(|branch| branch.outcome.status == StageOutcome::Succeeded)\n+ .count();\n+ let failure_count = results\n+ .iter()\n+ .filter(|branch| branch.outcome.status.is_failure())\n+ .count();\n+ let total = results.len();\n+ let status = aggregate_status(&results);\n+ let is_failure = status.is_failure();\n+ let jump_to_node = if is_failure {\n+ None\n+ } else {\n+ find_join_node(&results, graph)\n+ };\n+\n+ let mut typed_results = results\n+ .into_iter()\n+ .map(|branch| branch.result)\n+ .collect::>();\n+ // Offload large leaves before the results reach the event log and\n+ // projection: the artifact lifecycle's offload pass runs only after the\n+ // handler returns, too late for the `parallel.completed` payload.\n+ if let Err(err) =\n+ artifact::offload_parallel_branch_updates(&mut typed_results, &services.run.run_store).await\n+ {\n+ services.run.emitter.notice(\n+ RunNoticeLevel::Warn,\n+ RunNoticeCode::ArtifactOffloadFailed,\n+ format!(\"[node: {}] parallel result offload failed: {err}\", node.id),\n+ );\n }\n+ let results_value = serde_json::to_value(&typed_results)\n+ .map_err(|err| Error::handler_with_source(\"parallel result serialization failed\", err))?;\n+ let context_updates = HashMap::from([\n+ (keys::PARALLEL_RESULTS.to_string(), results_value),\n+ (\n+ keys::PARALLEL_BRANCH_COUNT.to_string(),\n+ serde_json::json!(total),\n+ ),\n+ ]);\n+\n+ services.run.emitter.emit_scoped(\n+ &Event::ParallelCompleted {\n+ node_id: node.id.clone(),\n+ visit: parallel_stage_scope.visit,\n+ duration_ms: millis_u64(parallel_start.elapsed()),\n+ success_count,\n+ failure_count,\n+ results: typed_results,\n+ },\n+ ¶llel_stage_scope,\n+ );\n+ emit_parallel_hook(services, context, graph, node, HookEvent::ParallelComplete).await?;\n+\n+ let prefix = if simulated { \"[Simulated] \" } else { \"\" };\n+ let mut outcome = Outcome {\n+ status,\n+ notes: Some(format!(\n+ \"{prefix}Parallel node dispatched {total} branches ({success_count} succeeded, {failure_count} failed)\"\n+ )),\n+ failure: is_failure.then(|| {\n+ FailureDetail::new(\n+ \"All parallel branches failed\",\n+ FailureCategory::Deterministic,\n+ )\n+ }),\n+ jump_to_node,\n+ context_updates,\n+ ..Outcome::success()\n+ };\n+ if is_failure {\n+ outcome.suggested_next_ids.clear();\n+ }\n+ Ok(outcome)\n }\n \n-/// Find the convergence (join/fan-in) node by following each branch's outgoing\n-/// edges and finding the first node reachable from all branches.\n-fn find_join_node(results: &[BranchResult], graph: &Graph) -> Option {\n- if results.is_empty() {\n- return None;\n- }\n+async fn emit_parallel_hook(\n+ services: &EngineServices,\n+ context: &Context,\n+ graph: &Graph,\n+ node: &Node,\n+ hook_event: HookEvent,\n+) -> Result<(), Error> {\n+ let run_id = context.parsed_run_id()?;\n+ let mut hook_context = HookContext::new(hook_event, run_id, graph.name.clone());\n+ set_hook_node(&mut hook_context, node);\n+ let _ = services.run.run_hooks(&hook_context).await;\n+ Ok(())\n+}\n \n- // Collect outgoing targets for each branch\n- let mut target_sets: Vec> = Vec::new();\n- for result in results {\n- let targets: std::collections::HashSet = graph\n- .outgoing_edges(&result.id)\n+fn branch_context_updates(\n+ before: &HashMap,\n+ after: HashMap,\n+ outcome_updates: &HashMap,\n+) -> BTreeMap {\n+ let mut updates = outcome_updates\n+ .iter()\n+ .map(|(key, value)| (key.clone(), value.clone()))\n+ .collect::>();\n+ updates.extend(\n+ context_diff(before, after)\n .into_iter()\n- .map(|e| e.to.clone())\n- .collect();\n- target_sets.push(targets);\n+ .filter(|(key, _)| !keys::is_engine_internal_key(key)),\n+ );\n+ updates\n+}\n+\n+fn failed_branch_result(id: &str, reason: impl Into) -> BranchResult {\n+ let outcome = Outcome::fail_classify(reason);\n+ BranchResult {\n+ result: ParallelBranchResult {\n+ id: id.to_string(),\n+ status: outcome.status,\n+ context_updates: BTreeMap::new(),\n+ },\n+ outcome,\n }\n+}\n \n- // Find the intersection — nodes reachable from ALL branches\n- let first = target_sets.first()?;\n- let common: std::collections::HashSet<&String> = first\n+fn aggregate_status(results: &[BranchResult]) -> StageOutcome {\n+ if results.is_empty() {\n+ StageOutcome::PartiallySucceeded\n+ } else if results\n .iter()\n- .filter(|id| target_sets.iter().all(|set| set.contains(*id)))\n- .collect();\n-\n- // Return the first common target (lexically sorted for determinism)\n- let mut common_sorted: Vec<&String> = common.into_iter().collect();\n- common_sorted.sort();\n- common_sorted.first().map(|id| (*id).clone())\n+ .all(|result| result.outcome.status == StageOutcome::Succeeded)\n+ {\n+ StageOutcome::Succeeded\n+ } else if results\n+ .iter()\n+ .all(|result| result.outcome.status.is_failure())\n+ {\n+ StageOutcome::Failed {\n+ retry_requested: false,\n+ }\n+ } else {\n+ StageOutcome::PartiallySucceeded\n+ }\n }\n \n-/// Build the parallel-branch checkpoint commit command. Appends\n-/// `--no-verify` when `skip_git_hooks` is true so the commit bypasses the\n-/// repository's local Git commit hooks (e.g. `pre-commit`, `commit-msg`).\n-fn parallel_branch_commit_cmd(\n- git_remote: &str,\n- author_name: &str,\n- author_email: &str,\n- message: &str,\n- skip_git_hooks: bool,\n-) -> String {\n- let no_verify = if skip_git_hooks { \" --no-verify\" } else { \"\" };\n- let name = fabro_sandbox::shell_quote(&format!(\"user.name={author_name}\"));\n- let email = fabro_sandbox::shell_quote(&format!(\"user.email={author_email}\"));\n- let msg = fabro_sandbox::shell_quote(message);\n- format!(\"{git_remote} -c {name} -c {email} commit --allow-empty{no_verify} -m {msg}\")\n+/// Find the convergence node by finding a common direct target of every branch.\n+fn find_join_node(results: &[BranchResult], graph: &Graph) -> Option {\n+ let first_result = results.first()?;\n+ let first_targets = graph\n+ .outgoing_edges(&first_result.result.id)\n+ .into_iter()\n+ .map(|edge| edge.to.clone())\n+ .collect::>();\n+ let mut common = first_targets\n+ .into_iter()\n+ .filter(|target| {\n+ results.iter().skip(1).all(|result| {\n+ graph\n+ .outgoing_edges(&result.result.id)\n+ .into_iter()\n+ .any(|edge| &edge.to == target)\n+ })\n+ })\n+ .collect::>();\n+ common.sort();\n+ common.into_iter().next()\n }\n \n #[cfg(test)]\n@@ -728,7 +470,7 @@ mod tests {\n graph: serde_json::to_value(fabro_types::Graph::new(\"test\")).unwrap(),\n workflow_source: None,\n workflow_config: None,\n- labels: std::collections::BTreeMap::default(),\n+ labels: BTreeMap::default(),\n run_dir: \"/tmp\".to_string(),\n source_directory: None,\n workflow_slug: None,\n@@ -750,47 +492,18 @@ mod tests {\n fn test_context() -> Context {\n let context = Context::new();\n context.set(\n- crate::context::keys::INTERNAL_RUN_ID,\n+ keys::INTERNAL_RUN_ID,\n serde_json::json!(fixtures::RUN_1.to_string()),\n );\n context\n }\n \n- #[tokio::test]\n- async fn parallel_handler_no_branches() {\n- let services = make_services();\n- let node = Node::new(\"par\");\n- let context = test_context();\n- let graph = Graph::new(\"test\");\n- let run_dir = Path::new(\"/tmp/test\");\n-\n- let outcome = ParallelHandler\n- .execute(&node, &context, &graph, run_dir, &services)\n- .await\n- .unwrap();\n- assert_eq!(outcome.status, StageOutcome::Failed {\n- retry_requested: false,\n- });\n- }\n-\n- #[tokio::test]\n- async fn parallel_handler_with_branches() {\n- let store = test_store();\n- let run_store = store.create_run(&fixtures::RUN_1).await.unwrap();\n- seed_created(&run_store).await;\n- let mut services = EngineServices::test_default();\n- services.run = services\n- .run\n- .with_emitter(Arc::new(crate::event::Emitter::new(fixtures::RUN_1)))\n- .with_run_store(run_store.clone().into());\n- let logger = crate::event::StoreProgressLogger::new(run_store.clone());\n- logger.register(services.run.emitter.as_ref());\n+ fn parallel_graph() -> (Node, Graph) {\n let mut node = Node::new(\"par\");\n node.attrs.insert(\n \"shape\".to_string(),\n AttrValue::String(\"component\".to_string()),\n );\n- let context = test_context();\n let mut graph = Graph::new(\"test\");\n graph.nodes.insert(\"par\".to_string(), node.clone());\n graph\n@@ -801,189 +514,172 @@ mod tests {\n .insert(\"branch_b\".to_string(), Node::new(\"branch_b\"));\n graph.edges.push(Edge::new(\"par\", \"branch_a\"));\n graph.edges.push(Edge::new(\"par\", \"branch_b\"));\n+ (node, graph)\n+ }\n \n- let tmp = tempfile::tempdir().unwrap();\n+ #[tokio::test]\n+ async fn parallel_handler_no_branches() {\n let outcome = ParallelHandler\n- .execute(&node, &context, &graph, tmp.path(), &services)\n+ .execute(\n+ &Node::new(\"par\"),\n+ &test_context(),\n+ &Graph::new(\"test\"),\n+ Path::new(\"/tmp/test\"),\n+ &make_services(),\n+ )\n .await\n .unwrap();\n- logger.flush().await;\n-\n- assert_eq!(outcome.status, StageOutcome::Succeeded);\n- assert!(outcome.notes.as_deref().unwrap().contains(\"2 branches\"));\n-\n- // Check context was set\n- let results = context.get(keys::PARALLEL_RESULTS);\n- assert!(results.is_some());\n-\n- let state = run_store.state().await.unwrap();\n- let node_state = state.stage(&StageId::new(\"par\", 1)).unwrap();\n- let parsed = node_state.parallel_results.as_ref().unwrap();\n- assert!(\n- parsed.is_array(),\n- \"parallel_results.json should be a JSON array\"\n+ assert_eq!(outcome.status, StageOutcome::PartiallySucceeded);\n+ assert_eq!(\n+ outcome.context_updates[keys::PARALLEL_RESULTS],\n+ serde_json::json!([])\n+ );\n+ assert_eq!(\n+ outcome.context_updates[keys::PARALLEL_BRANCH_COUNT],\n+ serde_json::json!(0)\n );\n- assert_eq!(parsed.as_array().unwrap().len(), 2);\n }\n \n #[tokio::test]\n- async fn parallel_handler_stores_results_in_run_store() {\n+ async fn parallel_handler_returns_typed_ordered_results() {\n let store = test_store();\n let run_store = store.create_run(&fixtures::RUN_1).await.unwrap();\n seed_created(&run_store).await;\n- let mut services = EngineServices::test_default();\n+ let mut services = make_services();\n services.run = services\n .run\n .with_emitter(Arc::new(crate::event::Emitter::new(fixtures::RUN_1)))\n .with_run_store(run_store.clone().into());\n let logger = crate::event::StoreProgressLogger::new(run_store.clone());\n logger.register(services.run.emitter.as_ref());\n- let mut node = Node::new(\"par\");\n- node.attrs.insert(\n- \"shape\".to_string(),\n- AttrValue::String(\"component\".to_string()),\n- );\n+ let (node, graph) = parallel_graph();\n let context = test_context();\n- let mut graph = Graph::new(\"test\");\n- graph.nodes.insert(\"par\".to_string(), node.clone());\n- graph\n- .nodes\n- .insert(\"branch_a\".to_string(), Node::new(\"branch_a\"));\n- graph\n- .nodes\n- .insert(\"branch_b\".to_string(), Node::new(\"branch_b\"));\n- graph.edges.push(Edge::new(\"par\", \"branch_a\"));\n- graph.edges.push(Edge::new(\"par\", \"branch_b\"));\n \n- let tmp = tempfile::tempdir().unwrap();\n- ParallelHandler\n- .execute(&node, &context, &graph, tmp.path(), &services)\n+ let outcome = ParallelHandler\n+ .execute(&node, &context, &graph, Path::new(\"/tmp/test\"), &services)\n .await\n .unwrap();\n logger.flush().await;\n \n+ assert_eq!(outcome.status, StageOutcome::Succeeded);\n+ let results: Vec =\n+ serde_json::from_value(outcome.context_updates[keys::PARALLEL_RESULTS].clone())\n+ .unwrap();\n+ assert_eq!(\n+ results\n+ .iter()\n+ .map(|result| result.id.as_str())\n+ .collect::>(),\n+ [\"branch_a\", \"branch_b\"]\n+ );\n+ assert!(\n+ results\n+ .iter()\n+ .all(|result| result.status == StageOutcome::Succeeded)\n+ );\n let state = run_store.state().await.unwrap();\n- let node_state = state.stage(&fabro_store::StageId::new(\"par\", 1)).unwrap();\n- let results = node_state.parallel_results.as_ref().unwrap();\n- assert!(results.is_array());\n- assert_eq!(results.as_array().unwrap().len(), 2);\n- }\n-\n- #[tokio::test]\n- async fn parallel_handler_first_success_policy() {\n- let services = make_services();\n- let mut node = Node::new(\"par\");\n- node.attrs.insert(\n- \"join_policy\".to_string(),\n- AttrValue::String(\"first_success\".to_string()),\n+ assert_eq!(\n+ state\n+ .stage(&StageId::new(\"par\", 1))\n+ .unwrap()\n+ .parallel_results\n+ .as_ref()\n+ .unwrap()\n+ .len(),\n+ 2\n );\n- let context = test_context();\n- let mut graph = Graph::new(\"test\");\n- graph.nodes.insert(\"par\".to_string(), node.clone());\n- graph\n- .nodes\n- .insert(\"branch_a\".to_string(), Node::new(\"branch_a\"));\n- graph.edges.push(Edge::new(\"par\", \"branch_a\"));\n-\n- let run_dir = Path::new(\"/tmp/test\");\n- let outcome = ParallelHandler\n- .execute(&node, &context, &graph, run_dir, &services)\n- .await\n- .unwrap();\n-\n- assert_eq!(outcome.status, StageOutcome::Succeeded);\n- }\n-\n- #[test]\n- fn join_policy_display() {\n- assert_eq!(JoinPolicy::WaitAll.to_string(), \"wait_all\");\n- assert_eq!(JoinPolicy::FirstSuccess.to_string(), \"first_success\");\n- }\n-\n- #[test]\n- fn parse_join_policy_variants() {\n- assert!(matches!(parse_join_policy(\"wait_all\"), JoinPolicy::WaitAll));\n- assert!(matches!(\n- parse_join_policy(\"first_success\"),\n- JoinPolicy::FirstSuccess\n- ));\n- // Invalid falls back to WaitAll\n- assert!(matches!(parse_join_policy(\"invalid\"), JoinPolicy::WaitAll));\n }\n \n #[tokio::test]\n- async fn parallel_handler_simulate() {\n- let services = make_services();\n- let mut node = Node::new(\"par\");\n- node.attrs.insert(\n- \"shape\".to_string(),\n- AttrValue::String(\"component\".to_string()),\n- );\n+ async fn parallel_handler_simulate_returns_results_as_outcome_updates() {\n+ let (node, graph) = parallel_graph();\n let context = test_context();\n- let mut graph = Graph::new(\"test\");\n- graph.nodes.insert(\"par\".to_string(), node.clone());\n- graph\n- .nodes\n- .insert(\"branch_a\".to_string(), Node::new(\"branch_a\"));\n- graph\n- .nodes\n- .insert(\"branch_b\".to_string(), Node::new(\"branch_b\"));\n- // Add a fan_in node reachable from both branches\n- graph\n- .nodes\n- .insert(\"fan_in\".to_string(), Node::new(\"fan_in\"));\n- graph.edges.push(Edge::new(\"par\", \"branch_a\"));\n- graph.edges.push(Edge::new(\"par\", \"branch_b\"));\n- graph.edges.push(Edge::new(\"branch_a\", \"fan_in\"));\n- graph.edges.push(Edge::new(\"branch_b\", \"fan_in\"));\n-\n- let run_dir = Path::new(\"/tmp/test\");\n- let mut dry_services = services;\n- dry_services.dry_run = true;\n-\n let outcome = ParallelHandler\n- .simulate(&node, &context, &graph, run_dir, &dry_services)\n+ .simulate(\n+ &node,\n+ &context,\n+ &graph,\n+ Path::new(\"/tmp/test\"),\n+ &make_services(),\n+ )\n .await\n .unwrap();\n \n assert_eq!(outcome.status, StageOutcome::Succeeded);\n assert!(outcome.notes.as_deref().unwrap().contains(\"[Simulated]\"));\n- assert!(outcome.notes.as_deref().unwrap().contains(\"2 branches\"));\n- assert_eq!(outcome.jump_to_node, Some(\"fan_in\".to_string()));\n-\n- let branch_count = context.get(keys::PARALLEL_BRANCH_COUNT);\n- assert_eq!(branch_count, Some(serde_json::json!(2)));\n+ assert_eq!(\n+ outcome.context_updates[keys::PARALLEL_BRANCH_COUNT],\n+ serde_json::json!(2)\n+ );\n }\n \n #[test]\n- fn parallel_branch_commit_cmd_includes_no_verify_when_skip_hooks_enabled() {\n- let cmd = super::parallel_branch_commit_cmd(\n- super::GIT_REMOTE,\n- \"Fabro\",\n- \"fabro@example.com\",\n- \"fabro(r1): branch_a (succeeded)\",\n- true,\n+ fn aggregate_status_follows_parallel_truth_table() {\n+ let success = |index: usize| BranchResult {\n+ result: ParallelBranchResult {\n+ id: format!(\"branch_{index}\"),\n+ status: StageOutcome::Succeeded,\n+ context_updates: BTreeMap::new(),\n+ },\n+ outcome: Outcome::success(),\n+ };\n+ let failure = |index: usize| failed_branch_result(&format!(\"branch_{index}\"), \"failed\");\n+ let partial = |index: usize| BranchResult {\n+ result: ParallelBranchResult {\n+ id: format!(\"branch_{index}\"),\n+ status: StageOutcome::PartiallySucceeded,\n+ context_updates: BTreeMap::new(),\n+ },\n+ outcome: Outcome {\n+ status: StageOutcome::PartiallySucceeded,\n+ ..Outcome::success()\n+ },\n+ };\n+\n+ assert_eq!(aggregate_status(&[]), StageOutcome::PartiallySucceeded);\n+ assert_eq!(\n+ aggregate_status(&[success(0), success(1)]),\n+ StageOutcome::Succeeded\n );\n- assert!(\n- cmd.contains(\"--no-verify\"),\n- \"expected --no-verify when skip_git_hooks=true; got {cmd:?}\"\n+ assert!(aggregate_status(&[failure(0), failure(1)]).is_failure());\n+ assert_eq!(\n+ aggregate_status(&[success(0), failure(1)]),\n+ StageOutcome::PartiallySucceeded\n+ );\n+ assert_eq!(\n+ aggregate_status(&[success(0), partial(1)]),\n+ StageOutcome::PartiallySucceeded\n+ );\n+ assert_eq!(\n+ aggregate_status(&[failure(0), partial(1)]),\n+ StageOutcome::PartiallySucceeded\n );\n- assert!(cmd.contains(\"commit --allow-empty\"));\n }\n \n #[test]\n- fn parallel_branch_commit_cmd_omits_no_verify_when_skip_hooks_disabled() {\n- let cmd = super::parallel_branch_commit_cmd(\n- super::GIT_REMOTE,\n- \"Fabro\",\n- \"fabro@example.com\",\n- \"fabro(r1): branch_a (succeeded)\",\n- false,\n- );\n- assert!(\n- !cmd.contains(\"--no-verify\"),\n- \"expected no --no-verify when skip_git_hooks=false; got {cmd:?}\"\n+ fn branch_context_updates_include_failed_outcome_updates_without_internal_keys() {\n+ let before = HashMap::from([(\"shared\".to_string(), serde_json::json!(\"parent\"))]);\n+ let after = HashMap::from([\n+ (\"shared\".to_string(), serde_json::json!(\"branch\")),\n+ (\n+ keys::INTERNAL_WORK_DIR.to_string(),\n+ serde_json::json!(\"/workspace\"),\n+ ),\n+ ]);\n+ let outcome = HashMap::from([(\n+ keys::COMMAND_OUTPUT.to_string(),\n+ serde_json::json!({\"stdout\": \"failure output\"}),\n+ )]);\n+\n+ assert_eq!(\n+ branch_context_updates(&before, after, &outcome),\n+ BTreeMap::from([\n+ (\n+ keys::COMMAND_OUTPUT.to_string(),\n+ serde_json::json!({\"stdout\": \"failure output\"})\n+ ),\n+ (\"shared\".to_string(), serde_json::json!(\"branch\")),\n+ ])\n );\n- assert!(cmd.contains(\"commit --allow-empty\"));\n }\n }\ndiff --git a/lib/crates/fabro-workflow/src/pipeline/execute.rs b/lib/crates/fabro-workflow/src/pipeline/execute.rs\nindex 0be068e88..cf02a736d 100644\n--- a/lib/crates/fabro-workflow/src/pipeline/execute.rs\n+++ b/lib/crates/fabro-workflow/src/pipeline/execute.rs\n@@ -17,7 +17,6 @@ use crate::lifecycle::WorkflowLifecycle;\n use crate::node_handler::WorkflowNodeHandler;\n use crate::outcome::Outcome;\n use crate::records::Checkpoint;\n-use crate::sandbox_git::GitState;\n \n fn seed_context_from_checkpoint(checkpoint: Option<&Checkpoint>) -> Context {\n let context = Context::new();\n@@ -55,19 +54,6 @@ pub async fn execute(init: Initialized) -> Executed {\n let graph_arc = Arc::new(graph.clone());\n let wf_graph = WorkflowGraph(Arc::clone(&graph_arc));\n \n- let git_state = run_options.git.as_ref().and_then(|git| {\n- let base_sha = git.base_sha.clone()?;\n- Some(Arc::new(GitState {\n- run_id: run_options.run_id,\n- base_sha,\n- run_branch: git.run_branch.clone(),\n- meta_branch: git.meta_branch.clone(),\n- checkpoint: run_options.checkpoint().clone(),\n- git_author: run_options.git_author(),\n- }))\n- });\n- engine.set_git_state(git_state);\n-\n let handler = Arc::new(WorkflowNodeHandler {\n services: Arc::clone(&engine),\n run_dir: run_options.run_dir.clone(),\ndiff --git a/lib/crates/fabro-workflow/src/pipeline/initialize.rs b/lib/crates/fabro-workflow/src/pipeline/initialize.rs\nindex 54a6f1f45..8f1e56ee2 100644\n--- a/lib/crates/fabro-workflow/src/pipeline/initialize.rs\n+++ b/lib/crates/fabro-workflow/src/pipeline/initialize.rs\n@@ -611,7 +611,6 @@ pub async fn initialize(\n run: Arc::clone(&run_services),\n registry,\n interviewer: Arc::clone(&options.interviewer),\n- git_state: std::sync::RwLock::new(None),\n base_env,\n github_token,\n inputs: options.run_options.settings.run.inputs.clone(),\ndiff --git a/lib/crates/fabro-workflow/src/sandbox_git.rs b/lib/crates/fabro-workflow/src/sandbox_git.rs\nindex 68b7e1bed..914153955 100644\n--- a/lib/crates/fabro-workflow/src/sandbox_git.rs\n+++ b/lib/crates/fabro-workflow/src/sandbox_git.rs\n@@ -4,7 +4,6 @@ use fabro_agent::Sandbox;\n use fabro_checkpoint::trailer as trailerlink;\n use fabro_checkpoint::trailer::Trailer;\n use fabro_sandbox::shell_quote;\n-use fabro_types::RunId;\n use fabro_types::settings::run::RunCheckpointSettings;\n use fabro_util::error::SharedError;\n \n@@ -20,17 +19,6 @@ pub struct GitCommandError {\n pub source: fabro_sandbox::Error,\n }\n \n-/// Captured git state for a workflow run, shared with handlers.\n-#[derive(Debug, Clone)]\n-pub struct GitState {\n- pub run_id: RunId,\n- pub base_sha: String,\n- pub run_branch: Option,\n- pub meta_branch: Option,\n- pub checkpoint: RunCheckpointSettings,\n- pub git_author: GitAuthor,\n-}\n-\n pub const GIT_REMOTE: &str =\n \"git -c maintenance.auto=0 -c gc.auto=0 -c commit.gpgsign=false -c tag.gpgsign=false\";\n \n@@ -237,48 +225,6 @@ pub(crate) async fn git_diff_with_timeout(\n }\n }\n \n-/// Create a branch at a specific SHA via the sandbox.\n-pub async fn git_create_branch_at(sandbox: &dyn Sandbox, name: &str, sha: &str) -> bool {\n- let cmd = format!(\"{GIT_REMOTE} branch --force {name} {sha}\");\n- matches!(\n- sandbox.exec_command(&cmd, 30_000, None, None, None).await,\n- Ok(r) if r.is_success()\n- )\n-}\n-\n-/// Add a git worktree via the sandbox.\n-pub async fn git_add_worktree(sandbox: &dyn Sandbox, path: &str, branch: &str) -> bool {\n- let cmd = format!(\"{GIT_REMOTE} worktree add {path} {branch}\");\n- matches!(\n- sandbox.exec_command(&cmd, 30_000, None, None, None).await,\n- Ok(r) if r.is_success()\n- )\n-}\n-\n-/// Remove a git worktree via the sandbox.\n-pub async fn git_remove_worktree(sandbox: &dyn Sandbox, path: &str) -> bool {\n- let cmd = format!(\"{GIT_REMOTE} worktree remove --force {path}\");\n- matches!(\n- sandbox.exec_command(&cmd, 30_000, None, None, None).await,\n- Ok(r) if r.is_success()\n- )\n-}\n-\n-/// Fast-forward merge to a given SHA via the sandbox.\n-pub async fn git_merge_ff_only(sandbox: &dyn Sandbox, sha: &str) -> bool {\n- let cmd = format!(\"{GIT_REMOTE} merge --ff-only {sha}\");\n- matches!(\n- sandbox.exec_command(&cmd, 30_000, None, None, None).await,\n- Ok(r) if r.is_success()\n- )\n-}\n-\n-/// Remove any stale worktree at `path` (best-effort), then add a fresh one.\n-pub async fn git_replace_worktree(sandbox: &dyn Sandbox, path: &str, branch: &str) -> bool {\n- let _ = git_remove_worktree(sandbox, path).await;\n- git_add_worktree(sandbox, path, branch).await\n-}\n-\n // ── Machine-readable diff enumeration (Run Files endpoint) ─────────────────\n \n /// Hardened git-command prefix for the Run Files endpoint.\ndiff --git a/lib/crates/fabro-workflow/src/services.rs b/lib/crates/fabro-workflow/src/services.rs\nindex 44e0eca21..506206f45 100644\n--- a/lib/crates/fabro-workflow/src/services.rs\n+++ b/lib/crates/fabro-workflow/src/services.rs\n@@ -20,7 +20,6 @@ use crate::handler::HandlerRegistry;\n use crate::interview_runtime::RunInterviewBlocker;\n use crate::run_metadata::{RunMetadataRuntime, RunMetadataWriterHandle};\n use crate::runtime_store::RunStoreHandle;\n-use crate::sandbox_git::GitState;\n use crate::sandbox_git_runtime::SandboxGitRuntime;\n use crate::workflow_bundle::WorkflowBundle;\n \n@@ -225,26 +224,24 @@ impl RunServices {\n }\n \n /// Services available only while executing workflow nodes.\n+#[derive(Clone)]\n pub struct EngineServices {\n- pub run: Arc,\n- pub registry: Arc,\n- pub interviewer: Arc,\n- /// Git state for the current run. Set via `set_git_state` at the start of\n- /// `execute` and read by parallel/fan-in handlers.\n- pub(crate) git_state: std::sync::RwLock>>,\n+ pub run: Arc,\n+ pub registry: Arc,\n+ pub interviewer: Arc,\n /// Environment variables from `[sandbox.env]` config.\n- pub base_env: HashMap,\n+ pub base_env: HashMap,\n /// GitHub token source used to inject `GITHUB_TOKEN` at the point of use.\n- pub github_token: Option>,\n+ pub github_token: Option>,\n /// Typed values from `[run.inputs]`, available to prompt templates.\n- pub inputs: HashMap,\n+ pub inputs: HashMap,\n /// When true, handlers should skip real execution and return simulated\n /// results.\n- pub dry_run: bool,\n+ pub dry_run: bool,\n /// Manifest path of the current workflow when running from a bundle.\n- pub workflow_path: Option,\n+ pub workflow_path: Option,\n /// Bundled workflows available for child-workflow resolution.\n- pub workflow_bundle: Option>,\n+ pub workflow_bundle: Option>,\n }\n \n impl EngineServices {\n@@ -252,23 +249,6 @@ impl EngineServices {\n resolve_workflow_env(&self.base_env, self.github_token.as_ref()).await\n }\n \n- /// Read the current git state (if any).\n- pub fn git_state(&self) -> Option> {\n- self.git_state\n- .read()\n- .expect(\"git_state lock is never poisoned: no code panics while holding this lock\")\n- .clone()\n- }\n-\n- /// Set the git state for the current run.\n- pub fn set_git_state(&self, state: Option>) {\n- *self\n- .git_state\n- .write()\n- .expect(\"git_state lock is never poisoned: no code panics while holding this lock\") =\n- state;\n- }\n-\n /// Test-only default: empty registry and cross-phase services.\n #[cfg(test)]\n #[expect(\n@@ -343,7 +323,6 @@ impl EngineServices {\n ),\n registry: Arc::new(HandlerRegistry::new(Box::new(start::StartHandler))),\n interviewer: Arc::new(fabro_interview::AutoApproveInterviewer::engine()),\n- git_state: std::sync::RwLock::new(None),\n base_env: HashMap::new(),\n github_token: None,\n inputs: HashMap::new(),\ndiff --git a/lib/crates/fabro-workflow/src/stage_scope.rs b/lib/crates/fabro-workflow/src/stage_scope.rs\nindex fa309044b..396e84156 100644\n--- a/lib/crates/fabro-workflow/src/stage_scope.rs\n+++ b/lib/crates/fabro-workflow/src/stage_scope.rs\n@@ -37,8 +37,7 @@ impl StageScope {\n }\n \n /// Build scope for the branch-lifecycle events emitted by the parallel\n- /// handler (`ParallelBranchStarted`, `ParallelBranchCompleted`, and the\n- /// pre-dispatch `GitCommit` for the branch worktree).\n+ /// handler (`ParallelBranchStarted` and `ParallelBranchCompleted`).\n ///\n /// `target_visit` is the visit count of `target_node_id` for this\n /// particular branch dispatch. The parallel handler currently passes\ndiff --git a/lib/crates/fabro-workflow/src/test_support.rs b/lib/crates/fabro-workflow/src/test_support.rs\nindex 1acf410a4..35c341a83 100644\n--- a/lib/crates/fabro-workflow/src/test_support.rs\n+++ b/lib/crates/fabro-workflow/src/test_support.rs\n@@ -241,7 +241,6 @@ async fn initialized(\n ),\n registry: Arc::new(registry),\n interviewer: Arc::new(AutoApproveInterviewer::engine()),\n- git_state: std::sync::RwLock::new(None),\n base_env: options.env,\n github_token: None,\n inputs: run_options.settings.run.inputs.clone(),\ndiff --git a/lib/crates/fabro-workflow/tests/it/daytona_integration.rs b/lib/crates/fabro-workflow/tests/it/daytona_integration.rs\nindex 692911491..ba72bd221 100644\n--- a/lib/crates/fabro-workflow/tests/it/daytona_integration.rs\n+++ b/lib/crates/fabro-workflow/tests/it/daytona_integration.rs\n@@ -35,7 +35,7 @@ use fabro_workflow::event::Emitter;\n use fabro_workflow::handler::exit::ExitHandler;\n use fabro_workflow::handler::start::StartHandler;\n use fabro_workflow::handler::{Handler, HandlerRegistry};\n-use fabro_workflow::outcome::{Outcome, OutcomeExt, StageOutcome};\n+use fabro_workflow::outcome::{Outcome, StageOutcome};\n use fabro_workflow::records::Checkpoint;\n use fabro_workflow::run_options::{GitCheckpointOptions, RunOptions};\n use fabro_workflow::test_support::{WorkflowRunner, test_store_dir};\n@@ -767,234 +767,6 @@ async fn daytona_git_checkpoint_remote_emits_events() {\n env.cleanup().await.unwrap();\n }\n \n-// ---------------------------------------------------------------------------\n-// Parallel git branching on Daytona\n-// ---------------------------------------------------------------------------\n-\n-use fabro_workflow::handler::fan_in::FanInHandler;\n-use fabro_workflow::handler::parallel::ParallelHandler;\n-\n-/// End-to-end: parallel branches get isolated worktrees in Daytona sandbox,\n-/// fan-in fast-forwards to winner.\n-#[fabro_macros::e2e_test(live(\"DAYTONA_API_KEY\"), live(\"GITHUB_APP_PRIVATE_KEY\"))]\n-async fn daytona_parallel_git_branching_e2e() {\n- let env = create_env().await;\n- env.initialize().await.unwrap();\n- let env: Arc = Arc::new(env);\n-\n- // Install git if not available\n- let git_check = env\n- .exec_command(\"git --version\", 10_000, None, None, None)\n- .await;\n- if git_check.as_ref().map_or(true, |r| !r.is_success()) {\n- let install = env\n- .exec_command(\n- \"apt-get update -qq && apt-get install -y -qq git >/dev/null 2>&1\",\n- 120_000,\n- None,\n- None,\n- None,\n- )\n- .await\n- .expect(\"apt-get install git should not error\");\n- assert_eq!(\n- install.exit_code,\n- Some(0),\n- \"git install failed: {}\",\n- install.stderr\n- );\n- }\n-\n- // Set up git in the sandbox (uses existing repo from Daytona project clone)\n- let (run_id, base_sha, branch_name) = setup_daytona_git(&*env).await;\n-\n- // Pipeline: start -> fan_out -> {branch_a, branch_b} -> fan_in -> exit\n- let mut graph = Graph::new(\"DaytonaParallelGitBranching\");\n- graph.attrs.insert(\n- \"goal\".to_string(),\n- AttrValue::String(\"Test parallel git branching on Daytona\".to_string()),\n- );\n-\n- let mut start = Node::new(\"start\");\n- start.attrs.insert(\n- \"shape\".to_string(),\n- AttrValue::String(\"Mdiamond\".to_string()),\n- );\n- graph.nodes.insert(\"start\".to_string(), start);\n-\n- let mut fan_out = Node::new(\"fan_out\");\n- fan_out.attrs.insert(\n- \"shape\".to_string(),\n- AttrValue::String(\"component\".to_string()),\n- );\n- graph.nodes.insert(\"fan_out\".to_string(), fan_out);\n-\n- let branch_a = Node::new(\"branch_a\");\n- graph.nodes.insert(\"branch_a\".to_string(), branch_a);\n-\n- let branch_b = Node::new(\"branch_b\");\n- graph.nodes.insert(\"branch_b\".to_string(), branch_b);\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- graph.nodes.insert(\"fan_in\".to_string(), fan_in);\n-\n- let mut exit_node = Node::new(\"exit\");\n- exit_node.attrs.insert(\n- \"shape\".to_string(),\n- AttrValue::String(\"Msquare\".to_string()),\n- );\n- graph.nodes.insert(\"exit\".to_string(), exit_node);\n-\n- graph.edges.push(Edge::new(\"start\", \"fan_out\"));\n- graph.edges.push(Edge::new(\"fan_out\", \"branch_a\"));\n- graph.edges.push(Edge::new(\"fan_out\", \"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 run_tmp = tempfile::tempdir().unwrap();\n- let emitter = Emitter::default();\n- let events = Arc::new(std::sync::Mutex::new(Vec::new()));\n- {\n- let events_clone = Arc::clone(&events);\n- emitter.on_event(move |event| {\n- events_clone.lock().unwrap().push(event.clone());\n- });\n- }\n-\n- let mut registry = HandlerRegistry::new(Box::new(FileWriterHandler));\n- registry.register(\"start\", Box::new(StartHandler));\n- registry.register(\"exit\", Box::new(ExitHandler));\n- registry.register(\"parallel\", Box::new(ParallelHandler));\n- registry.register(\"parallel.fan_in\", Box::new(FanInHandler::new(None)));\n-\n- let engine = WorkflowRunner::new(registry, Arc::new(emitter), Arc::clone(&env));\n-\n- let run_options = RunOptions {\n- settings: WorkflowSettings::default(),\n- run_dir: run_tmp.path().to_path_buf(),\n- cancel_token: CancellationToken::new(),\n- run_id,\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: Some(GitCheckpointOptions {\n- base_sha: Some(base_sha),\n- run_branch: Some(branch_name),\n- meta_branch: None,\n- }),\n- };\n- let outcome = engine\n- .run(&graph, &run_options)\n- .await\n- .expect(\"daytona parallel pipeline should succeed\");\n- assert_eq!(\n- outcome.status,\n- StageOutcome::Succeeded,\n- \"pipeline failed: {:?}\",\n- outcome.failure_reason()\n- );\n-\n- // Verify parallel.results has head_sha for each branch\n- let checkpoint = load_run_checkpoint(run_tmp.path()).expect(\"checkpoint should load\");\n- let parallel_results = checkpoint\n- .context_values\n- .get(\"parallel.results\")\n- .expect(\"parallel.results should be in context\");\n- let results_arr = parallel_results.as_array().expect(\"should be an array\");\n- assert_eq!(results_arr.len(), 2, \"should have 2 branch results\");\n-\n- // Both branches should have head_sha (40-char hex)\n- let has_sha = results_arr.iter().all(|v| {\n- v.get(\"head_sha\")\n- .and_then(|v| v.as_str())\n- .is_some_and(|s| s.len() == 40 && s.chars().all(|c| c.is_ascii_hexdigit()))\n- });\n- assert!(has_sha, \"all branches should have 40-char hex head_sha\");\n-\n- // Branch SHAs should differ (each branch made unique changes)\n- let sha_a = results_arr\n- .iter()\n- .find(|v| v.get(\"id\").and_then(|v| v.as_str()) == Some(\"branch_a\"))\n- .and_then(|v| v.get(\"head_sha\").and_then(|v| v.as_str()))\n- .unwrap();\n- let sha_b = results_arr\n- .iter()\n- .find(|v| v.get(\"id\").and_then(|v| v.as_str()) == Some(\"branch_b\"))\n- .and_then(|v| v.get(\"head_sha\").and_then(|v| v.as_str()))\n- .unwrap();\n- assert_ne!(sha_a, sha_b, \"branch SHAs should differ\");\n-\n- // Verify fan_in selected a winner and set best_head_sha\n- let best_id = checkpoint\n- .context_values\n- .get(\"parallel.fan_in.best_id\")\n- .and_then(|v| v.as_str().map(String::from))\n- .expect(\"fan_in should have selected a best_id\");\n- assert_eq!(\n- best_id, \"branch_a\",\n- \"heuristic should pick branch_a (lexical)\"\n- );\n-\n- let best_head_sha = checkpoint\n- .context_values\n- .get(\"parallel.fan_in.best_head_sha\")\n- .and_then(|v| v.as_str().map(String::from));\n- assert!(\n- best_head_sha.is_some(),\n- \"fan_in should have set best_head_sha\"\n- );\n-\n- // Verify winner's file exists in sandbox\n- let winner_check = env\n- .exec_command(\"cat branch_a.txt\", 10_000, None, None, None)\n- .await\n- .expect(\"cat should succeed\");\n- assert_eq!(\n- winner_check.exit_code,\n- Some(0),\n- \"winner's file should exist\"\n- );\n- assert!(\n- winner_check.stdout.contains(\"branch_a\"),\n- \"winner's file should have correct content, got: {}\",\n- winner_check.stdout\n- );\n-\n- // Verify events\n- {\n- let events = events.lock().unwrap();\n- let parallel_started: Vec<_> = events\n- .iter()\n- .filter(|e| e.event_name() == \"parallel.started\")\n- .collect();\n- assert_eq!(\n- parallel_started.len(),\n- 1,\n- \"should have exactly one ParallelStarted event\"\n- );\n- let parallel_completed: Vec<_> = events\n- .iter()\n- .filter(|e| e.event_name() == \"parallel.completed\")\n- .collect();\n- assert_eq!(\n- parallel_completed.len(),\n- 1,\n- \"should have exactly one ParallelCompleted event\"\n- );\n- }\n-\n- env.cleanup().await.expect(\"Daytona cleanup should succeed\");\n-}\n-\n // ---------------------------------------------------------------------------\n // Daytona shadow commit E2E with sandbox-native metadata\n // ---------------------------------------------------------------------------\ndiff --git a/lib/crates/fabro-workflow/tests/it/git_integration.rs b/lib/crates/fabro-workflow/tests/it/git_integration.rs\nindex 8ff8f342d..bcdf8f9e0 100644\n--- a/lib/crates/fabro-workflow/tests/it/git_integration.rs\n+++ b/lib/crates/fabro-workflow/tests/it/git_integration.rs\n@@ -12,10 +12,7 @@ use fabro_agent::Sandbox;\n use fabro_graphviz::graph::{AttrValue, Edge, Graph, Node};\n use fabro_types::{RunEvent, WorkflowSettings, fixtures};\n use fabro_workflow::event::Emitter;\n-use fabro_workflow::git::{\n- add_worktree, branch_needs_push, create_branch, push_branch, push_ref, remove_worktree,\n- replace_worktree,\n-};\n+use fabro_workflow::git::{branch_needs_push, push_branch, push_ref};\n use fabro_workflow::handler::HandlerRegistry;\n use fabro_workflow::handler::exit::ExitHandler;\n use fabro_workflow::handler::start::StartHandler;\n@@ -169,22 +166,6 @@ fn test_run_options(run_dir: &Path) -> RunOptions {\n }\n }\n \n-#[test]\n-fn replace_worktree_replaces_stale() {\n- let dir = tempfile::tempdir().unwrap();\n- init_repo(dir.path());\n- create_branch(dir.path(), \"stale-branch\").unwrap();\n-\n- let wt_path = dir.path().join(\"stale-wt\");\n- add_worktree(dir.path(), &wt_path, \"stale-branch\").unwrap();\n- assert!(wt_path.join(\".git\").exists());\n-\n- replace_worktree(dir.path(), &wt_path, \"stale-branch\").unwrap();\n- assert!(wt_path.join(\".git\").exists());\n-\n- remove_worktree(dir.path(), &wt_path).unwrap();\n-}\n-\n #[test]\n fn push_ref_to_bare_remote() {\n let dir = tempfile::tempdir().unwrap();\n@@ -195,7 +176,7 @@ fn push_ref_to_bare_remote() {\n init_repo(&repo_dir);\n add_origin(&repo_dir, &remote_dir);\n \n- create_branch(&repo_dir, \"test-push\").unwrap();\n+ rename_branch(&repo_dir, \"test-push\");\n let url = format!(\"file://{}\", remote_dir.display());\n push_ref(&repo_dir, &url, \"refs/heads/test-push\").unwrap();\n \ndiff --git a/lib/crates/fabro-workflow/tests/it/integration.rs b/lib/crates/fabro-workflow/tests/it/integration.rs\nindex 87ebeee4d..3f26b21f9 100644\n--- a/lib/crates/fabro-workflow/tests/it/integration.rs\n+++ b/lib/crates/fabro-workflow/tests/it/integration.rs\n@@ -9755,7 +9755,7 @@ async fn artifact_pointers_rewritten_for_remote_sandbox() {\n }\n \n #[tokio::test]\n-async fn downstream_local_execution_materializes_blob_refs_to_runtime_files() {\n+async fn downstream_local_execution_resolves_response_blob_refs_as_text() {\n let mut graph = make_graph_with_start_exit(\"ArtifactMaterializeLocal\");\n graph.attrs.insert(\n \"goal\".to_string(),\n@@ -9818,31 +9818,19 @@ async fn downstream_local_execution_materializes_blob_refs_to_runtime_files() {\n .expect(\"pipeline should succeed\");\n assert_eq!(outcome.status, StageOutcome::Succeeded);\n \n- let expected_blob_id = fabro_types::RunBlobId::new(\n- &serde_json::to_vec(&serde_json::json!(\"x\".repeat(150 * 1024)))\n- .expect(\"large value should serialize\"),\n- );\n let captured_value = captured.lock().unwrap().first().cloned().unwrap();\n- let expected_path = RunScratch::new(dir.path())\n- .runtime_dir()\n- .join(\"blobs\")\n- .join(format!(\"{expected_blob_id}.json\"));\n- assert_eq!(\n- captured_value,\n- format!(\"file://{}\", expected_path.display()),\n- \"downstream handlers should receive a local file ref\"\n- );\n- let artifact_content = std::fs::read_to_string(&expected_path).expect(\"should read artifact\");\n- let artifact_value: serde_json::Value =\n- serde_json::from_str(&artifact_content).expect(\"should parse artifact JSON\");\n- let artifact_str = artifact_value\n- .as_str()\n- .expect(\"artifact should be a string\");\n- assert_eq!(artifact_str.len(), 150 * 1024);\n+ assert_eq!(captured_value, \"x\".repeat(150 * 1024));\n+ assert!(\n+ !RunScratch::new(dir.path())\n+ .runtime_dir()\n+ .join(\"blobs\")\n+ .exists(),\n+ \"textual response values should resolve without file materialization\"\n+ );\n }\n \n #[tokio::test]\n-async fn downstream_remote_execution_materializes_blob_refs_to_sandbox_files() {\n+async fn downstream_remote_execution_resolves_response_blob_refs_as_text() {\n let mut graph = make_graph_with_start_exit(\"ArtifactMaterializeRemote\");\n graph.attrs.insert(\n \"goal\".to_string(),\n@@ -9906,27 +9894,11 @@ async fn downstream_remote_execution_materializes_blob_refs_to_sandbox_files() {\n .expect(\"pipeline should succeed\");\n assert_eq!(outcome.status, StageOutcome::Succeeded);\n \n- let expected_blob_id = fabro_types::RunBlobId::new(\n- &serde_json::to_vec(&serde_json::json!(\"x\".repeat(150 * 1024)))\n- .expect(\"large value should serialize\"),\n- );\n let captured_value = captured.lock().unwrap().first().cloned().unwrap();\n- assert_eq!(\n- captured_value,\n- format!(\"file:///sandbox/.fabro/blobs/{expected_blob_id}.json\"),\n- \"downstream handlers should receive a sandbox-local file ref\"\n- );\n-\n- let written = remote_env.written.lock().unwrap();\n- assert_eq!(written.len(), 1, \"should materialize the blob once\");\n- assert_eq!(\n- written[0].0,\n- format!(\"/sandbox/.fabro/blobs/{expected_blob_id}.json\")\n- );\n+ assert_eq!(captured_value, \"x\".repeat(150 * 1024));\n assert!(\n- written[0].1.len() > 100 * 1024,\n- \"written content should be >100KB, got {} bytes\",\n- written[0].1.len()\n+ remote_env.written.lock().unwrap().is_empty(),\n+ \"textual response values should resolve without sandbox file materialization\"\n );\n }\n \n@@ -10065,7 +10037,7 @@ use fabro_workflow::handler::fan_in::FanInHandler;\n use fabro_workflow::handler::parallel::ParallelHandler;\n \n /// A handler that writes a file named `{node_id}.txt` into the sandbox's\n-/// working directory. Used to verify git worktree isolation in parallel\n+/// working directory. Used to verify shared-checkout writes from parallel\n /// branches.\n struct FileWriterHandler;\n \n@@ -10413,18 +10385,13 @@ async fn git_checkpoint_host_skips_metadata_branch_without_writer_prereqs() {\n }\n \n // ---------------------------------------------------------------------------\n-// Host e2e: parallel git branching with worktree isolation\n+// Host e2e: shared-checkout parallel execution\n // ---------------------------------------------------------------------------\n \n-/// End-to-end: parallel branches get isolated worktrees, fan-in fast-forwards\n-/// to winner.\n-///\n-/// Pipeline: start -> fan_out -> {branch_a, branch_b} -> fan_in -> exit\n-///\n-/// Each branch writes a unique file. After fan-in, only the winner's file\n-/// should be present in the main worktree.\n+/// End-to-end: parallel branches write to one shared checkout and normal\n+/// run-level checkpointing captures all branch changes after the parallel node.\n #[tokio::test]\n-async fn parallel_git_branching_host_e2e() {\n+async fn parallel_shared_checkout_host_e2e() {\n // 1. Create a temporary git repo with an initial commit\n let repo = tempfile::tempdir().unwrap();\n std::process::Command::new(\"git\")\n@@ -10532,10 +10499,7 @@ async fn parallel_git_branching_host_e2e() {\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(None)), // heuristic select — picks branch_a (lexical tiebreak)\n- );\n+ registry.register(\"parallel.fan_in\", Box::new(FanInHandler::new(None)));\n \n let engine = WorkflowRunner::new(registry, Arc::new(emitter), env);\n \n@@ -10569,113 +10533,107 @@ async fn parallel_git_branching_host_e2e() {\n outcome.failure_reason()\n );\n \n- // 6. Verify parallel.results has head_sha for each branch\n+ // 6. Verify ordered typed results and that no fan-in selection state exists.\n let checkpoint = load_run_checkpoint(run_dir.path()).expect(\"checkpoint should load\");\n let parallel_results = checkpoint\n .context_values\n .get(\"parallel.results\")\n .expect(\"parallel.results should be in context\");\n- let results_arr = parallel_results.as_array().expect(\"should be an array\");\n- assert_eq!(results_arr.len(), 2, \"should have 2 branch results\");\n-\n- // Both branches should have head_sha\n- let branch_a_result = results_arr\n- .iter()\n- .find(|v| v.get(\"id\").and_then(|v| v.as_str()) == Some(\"branch_a\"))\n- .expect(\"branch_a result should exist\");\n- let branch_b_result = results_arr\n- .iter()\n- .find(|v| v.get(\"id\").and_then(|v| v.as_str()) == Some(\"branch_b\"))\n- .expect(\"branch_b result should exist\");\n-\n- let sha_a = branch_a_result\n- .get(\"head_sha\")\n- .and_then(|v| v.as_str())\n- .expect(\"branch_a should have head_sha\");\n- let sha_b = branch_b_result\n- .get(\"head_sha\")\n- .and_then(|v| v.as_str())\n- .expect(\"branch_b should have head_sha\");\n-\n- assert_eq!(sha_a.len(), 40, \"SHA should be 40 hex chars\");\n- assert_eq!(sha_b.len(), 40, \"SHA should be 40 hex chars\");\n- assert_ne!(sha_a, sha_b, \"branch SHAs should differ\");\n-\n- // 7. Verify fan_in selected a winner and set best_head_sha\n- let best_id = checkpoint\n- .context_values\n- .get(\"parallel.fan_in.best_id\")\n- .and_then(|v| v.as_str().map(String::from))\n- .expect(\"fan_in should have selected a best_id\");\n- let best_head_sha = checkpoint\n- .context_values\n- .get(\"parallel.fan_in.best_head_sha\")\n- .and_then(|v| v.as_str().map(String::from))\n- .expect(\"fan_in should have set best_head_sha\");\n-\n- // Heuristic select with both success: lexical tiebreak picks \"branch_a\"\n+ let results: Vec =\n+ serde_json::from_value(parallel_results.clone()).expect(\"results should be typed\");\n assert_eq!(\n- best_id, \"branch_a\",\n- \"heuristic should pick branch_a (lexical)\"\n+ results\n+ .iter()\n+ .map(|result| (result.id.as_str(), result.status))\n+ .collect::>(),\n+ [\n+ (\"branch_a\", fabro_types::StageOutcome::Succeeded),\n+ (\"branch_b\", fabro_types::StageOutcome::Succeeded),\n+ ]\n );\n-\n- // 8. Verify winner's file is in the main worktree, loser's is NOT\n- let winner_file = worktree_path.join(format!(\"{best_id}.txt\"));\n assert!(\n- winner_file.exists(),\n- \"winner's file ({best_id}.txt) should exist in main worktree after ff-merge\"\n+ results\n+ .iter()\n+ .all(|result| result.context_updates.is_empty())\n );\n- let winner_content = std::fs::read_to_string(&winner_file).unwrap();\n assert!(\n- winner_content.contains(&format!(\"written by {best_id}\")),\n- \"winner's file should have correct content\"\n+ checkpoint\n+ .context_values\n+ .keys()\n+ .all(|key| !key.starts_with(\"parallel.fan_in.\"))\n );\n \n- let loser_id = if best_id == \"branch_a\" {\n- \"branch_b\"\n- } else {\n- \"branch_a\"\n- };\n- let loser_file = worktree_path.join(format!(\"{loser_id}.txt\"));\n- assert!(\n- !loser_file.exists(),\n- \"loser's file ({loser_id}.txt) should NOT exist in main worktree\"\n- );\n+ // 7. Both branches wrote into the one shared checkout.\n+ for branch in [\"branch_a\", \"branch_b\"] {\n+ let file = worktree_path.join(format!(\"{branch}.txt\"));\n+ assert!(\n+ file.exists(),\n+ \"{branch} output should remain in the checkout\"\n+ );\n+ assert_eq!(\n+ std::fs::read_to_string(file).unwrap(),\n+ format!(\"written by {branch}\")\n+ );\n+ }\n \n- // 9. Verify the main worktree HEAD matches the winner's head_sha\n- let main_head = {\n- let out = std::process::Command::new(\"git\")\n- .args([\"rev-parse\", \"HEAD\"])\n- .current_dir(&worktree_path)\n- .output()\n- .unwrap();\n- String::from_utf8_lossy(&out.stdout).trim().to_string()\n- };\n- // After fan-in ff-only + engine's own checkpoint commits, HEAD should be a\n- // descendant of best_head_sha.\n- let is_ancestor = std::process::Command::new(\"git\")\n- .args([\"merge-base\", \"--is-ancestor\", &best_head_sha, &main_head])\n+ // 8. Normal run-level checkpointing captured both files together.\n+ let committed_files = std::process::Command::new(\"git\")\n+ .args([\"ls-tree\", \"-r\", \"--name-only\", \"HEAD\"])\n .current_dir(&worktree_path)\n .output()\n .unwrap();\n+ assert!(committed_files.status.success());\n+ let committed_files = String::from_utf8_lossy(&committed_files.stdout);\n+ assert!(committed_files.lines().any(|path| path == \"branch_a.txt\"));\n+ assert!(committed_files.lines().any(|path| path == \"branch_b.txt\"));\n+\n+ // 9. Fabro created no branch-specific refs, commits, or worktrees.\n+ let parallel_refs = std::process::Command::new(\"git\")\n+ .args([\n+ \"for-each-ref\",\n+ \"--format=%(refname)\",\n+ \"refs/heads/fabro/run/parallel/\",\n+ ])\n+ .current_dir(repo.path())\n+ .output()\n+ .unwrap();\n+ assert!(parallel_refs.status.success());\n assert!(\n- is_ancestor.status.success(),\n- \"best_head_sha ({best_head_sha}) should be an ancestor of current HEAD ({main_head})\"\n+ parallel_refs.stdout.is_empty(),\n+ \"parallel refs must not exist\"\n );\n \n- // 10. Verify parallel branch refs still exist (for debugging)\n- let branch_ref_a = format!(\"fabro/run/parallel/{run_id}/fan-out/pass1/branch-a\");\n- let ref_check = std::process::Command::new(\"git\")\n- .args([\"rev-parse\", \"--verify\", &branch_ref_a])\n+ let worktrees = std::process::Command::new(\"git\")\n+ .args([\"worktree\", \"list\", \"--porcelain\"])\n .current_dir(repo.path())\n .output()\n .unwrap();\n+ assert!(worktrees.status.success());\n+ let worktree_count = String::from_utf8_lossy(&worktrees.stdout)\n+ .lines()\n+ .filter(|line| line.starts_with(\"worktree \"))\n+ .count();\n+ assert_eq!(\n+ worktree_count, 2,\n+ \"parallel branches must not add worktrees\"\n+ );\n+\n+ let commit_count = std::process::Command::new(\"git\")\n+ .args([\"rev-list\", \"--count\", &format!(\"{base_sha}..HEAD\")])\n+ .current_dir(&worktree_path)\n+ .output()\n+ .unwrap();\n+ assert!(commit_count.status.success());\n+ let commit_count: usize = String::from_utf8_lossy(&commit_count.stdout)\n+ .trim()\n+ .parse()\n+ .unwrap();\n assert!(\n- ref_check.status.success(),\n- \"parallel branch ref should still exist for debugging\"\n+ (1..=2).contains(&commit_count),\n+ \"only run-level parallel/fan-in checkpoints should be committed, got {commit_count}\"\n );\n \n- // 11. Verify events\n+ // 10. Verify lifecycle events without parallel Git/worktree events.\n let events = events.lock().unwrap();\n let parallel_started: Vec<_> = events\n .iter()\n@@ -10696,6 +10654,13 @@ async fn parallel_git_branching_host_e2e() {\n 1,\n \"should have exactly one ParallelCompleted event\"\n );\n+ assert!(\n+ events.iter().all(|event| !matches!(\n+ event.event_name(),\n+ \"git.branch\" | \"git.worktree.added\" | \"git.worktree.removed\"\n+ )),\n+ \"parallel execution must not emit Git branch or worktree lifecycle events\"\n+ );\n \n // Cleanup\n let _ = std::process::Command::new(\"git\")\ndiff --git a/lib/packages/fabro-api-client/src/.openapi-generator/FILES b/lib/packages/fabro-api-client/src/.openapi-generator/FILES\nindex 6c4bc5e4e..e7d9f3584 100644\n--- a/lib/packages/fabro-api-client/src/.openapi-generator/FILES\n+++ b/lib/packages/fabro-api-client/src/.openapi-generator/FILES\n@@ -265,6 +265,7 @@ models/pair-transcript-system-message.ts\n models/pair-transcript-tool-call.ts\n models/pair-transcript-user-message.ts\n models/pair-transcript-warning.ts\n+models/parallel-branch-result.ts\n models/pending-interview-record.ts\n models/pending-reason.ts\n models/permission-level.ts\n@@ -293,6 +294,8 @@ models/principal-webhook.ts\n models/principal-worker.ts\n models/principal.ts\n models/project-namespace.ts\n+models/provider-credential-test-request.ts\n+models/provider-credential-test-response.ts\n models/provider-list.ts\n models/provider-test-list.ts\n models/provider-test-result.ts\ndiff --git a/lib/packages/fabro-api-client/src/api/models-api.ts b/lib/packages/fabro-api-client/src/api/models-api.ts\nindex 6b19c88c3..783f597ad 100644\n--- a/lib/packages/fabro-api-client/src/api/models-api.ts\n+++ b/lib/packages/fabro-api-client/src/api/models-api.ts\n@@ -30,6 +30,10 @@ import type { ModelTestResult } from '../models';\n // @ts-ignore\n import type { PaginatedModelList } from '../models';\n // @ts-ignore\n+import type { ProviderCredentialTestRequest } from '../models';\n+// @ts-ignore\n+import type { ProviderCredentialTestResponse } from '../models';\n+// @ts-ignore\n import type { ProviderList } from '../models';\n // @ts-ignore\n import type { ProviderTestList } from '../models';\n@@ -175,6 +179,51 @@ export const ModelsApiAxiosParamCreator = function (configuration?: Configuratio\n options: localVarRequestOptions,\n };\n },\n+ /**\n+ * Validates an LLM provider API key against the server\\'s effective catalog without persisting it.\n+ * @summary Test Provider Credentials\n+ * @param {string} provider The provider identifier.\n+ * @param {ProviderCredentialTestRequest} providerCredentialTestRequest\n+ * @param {*} [options] Override http request option.\n+ * @throws {RequiredError}\n+ */\n+ testProviderCredentials: async (provider: string, providerCredentialTestRequest: ProviderCredentialTestRequest, options: RawAxiosRequestConfig = {}): Promise => {\n+ // verify required parameter 'provider' is not null or undefined\n+ assertParamExists('testProviderCredentials', 'provider', provider)\n+ // verify required parameter 'providerCredentialTestRequest' is not null or undefined\n+ assertParamExists('testProviderCredentials', 'providerCredentialTestRequest', providerCredentialTestRequest)\n+ const localVarPath = `/api/v1/providers/{provider}/credentials/test`\n+ .replace(`{${\"provider\"}}`, encodeURIComponent(String(provider)));\n+ // use dummy base URL string because the URL constructor only accepts absolute URLs.\n+ const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);\n+ let baseOptions;\n+ if (configuration) {\n+ baseOptions = configuration.baseOptions;\n+ }\n+\n+ const localVarRequestOptions = { method: 'POST', ...baseOptions, ...options};\n+ const localVarHeaderParameter = {} as any;\n+ const localVarQueryParameter = {} as any;\n+\n+ // authentication SessionCookie required\n+\n+ // authentication BearerAuth required\n+ // http bearer authentication required\n+ await setBearerAuthToObject(localVarHeaderParameter, configuration)\n+\n+ localVarHeaderParameter['Content-Type'] = 'application/json';\n+ localVarHeaderParameter['Accept'] = 'application/json';\n+\n+ setSearchParams(localVarUrlObj, localVarQueryParameter);\n+ let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};\n+ localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};\n+ localVarRequestOptions.data = serializeDataIfNeeded(providerCredentialTestRequest, localVarRequestOptions, configuration)\n+\n+ return {\n+ url: toPathString(localVarUrlObj),\n+ options: localVarRequestOptions,\n+ };\n+ },\n /**\n * Tests every configured LLM provider once using the catalog probe model. Provider-level failures are returned in the response body with HTTP 200.\n * @summary Test Providers\n@@ -262,6 +311,20 @@ export const ModelsApiFp = function(configuration?: Configuration) {\n const localVarOperationServerBasePath = operationServerMap['ModelsApi.testModel']?.[localVarOperationServerIndex]?.url;\n return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath);\n },\n+ /**\n+ * Validates an LLM provider API key against the server\\'s effective catalog without persisting it.\n+ * @summary Test Provider Credentials\n+ * @param {string} provider The provider identifier.\n+ * @param {ProviderCredentialTestRequest} providerCredentialTestRequest\n+ * @param {*} [options] Override http request option.\n+ * @throws {RequiredError}\n+ */\n+ async testProviderCredentials(provider: string, providerCredentialTestRequest: ProviderCredentialTestRequest, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> {\n+ const localVarAxiosArgs = await localVarAxiosParamCreator.testProviderCredentials(provider, providerCredentialTestRequest, options);\n+ const localVarOperationServerIndex = configuration?.serverIndex ?? 0;\n+ const localVarOperationServerBasePath = operationServerMap['ModelsApi.testProviderCredentials']?.[localVarOperationServerIndex]?.url;\n+ return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath);\n+ },\n /**\n * Tests every configured LLM provider once using the catalog probe model. Provider-level failures are returned in the response body with HTTP 200.\n * @summary Test Providers\n@@ -316,6 +379,17 @@ export const ModelsApiFactory = function (configuration?: Configuration, basePat\n testModel(id: string, mode?: ModelTestMode, options?: RawAxiosRequestConfig): AxiosPromise {\n return localVarFp.testModel(id, mode, options).then((request) => request(axios, basePath));\n },\n+ /**\n+ * Validates an LLM provider API key against the server\\'s effective catalog without persisting it.\n+ * @summary Test Provider Credentials\n+ * @param {string} provider The provider identifier.\n+ * @param {ProviderCredentialTestRequest} providerCredentialTestRequest\n+ * @param {*} [options] Override http request option.\n+ * @throws {RequiredError}\n+ */\n+ testProviderCredentials(provider: string, providerCredentialTestRequest: ProviderCredentialTestRequest, options?: RawAxiosRequestConfig): AxiosPromise {\n+ return localVarFp.testProviderCredentials(provider, providerCredentialTestRequest, options).then((request) => request(axios, basePath));\n+ },\n /**\n * Tests every configured LLM provider once using the catalog probe model. Provider-level failures are returned in the response body with HTTP 200.\n * @summary Test Providers\n@@ -368,6 +442,18 @@ export class ModelsApi extends BaseAPI {\n return ModelsApiFp(this.configuration).testModel(id, mode, options).then((request) => request(this.axios, this.basePath));\n }\n \n+ /**\n+ * Validates an LLM provider API key against the server\\'s effective catalog without persisting it.\n+ * @summary Test Provider Credentials\n+ * @param {string} provider The provider identifier.\n+ * @param {ProviderCredentialTestRequest} providerCredentialTestRequest\n+ * @param {*} [options] Override http request option.\n+ * @throws {RequiredError}\n+ */\n+ public testProviderCredentials(provider: string, providerCredentialTestRequest: ProviderCredentialTestRequest, options?: RawAxiosRequestConfig) {\n+ return ModelsApiFp(this.configuration).testProviderCredentials(provider, providerCredentialTestRequest, options).then((request) => request(this.axios, this.basePath));\n+ }\n+\n /**\n * Tests every configured LLM provider once using the catalog probe model. Provider-level failures are returned in the response body with HTTP 200.\n * @summary Test Providers\ndiff --git a/lib/packages/fabro-api-client/src/models/hook-definition.ts b/lib/packages/fabro-api-client/src/models/hook-definition.ts\nindex a7d2f7cf1..85092d14f 100644\n--- a/lib/packages/fabro-api-client/src/models/hook-definition.ts\n+++ b/lib/packages/fabro-api-client/src/models/hook-definition.ts\n@@ -27,6 +27,9 @@ export interface HookDefinition {\n 'type'?: HookDefinitionTypeEnum | null;\n 'url'?: string | null;\n 'headers'?: { [key: string]: string; } | null;\n+ /**\n+ * Allowlist of environment variable names that an http hook header may read via `{{ env.NAME }}`. An empty list (the default) permits no env vars in headers.\n+ */\n 'allowed_env_vars'?: Array;\n 'tls'?: TlsMode;\n 'prompt'?: string | null;\ndiff --git a/lib/packages/fabro-api-client/src/models/index.ts b/lib/packages/fabro-api-client/src/models/index.ts\nindex 7b51ee78c..937ec7900 100644\n--- a/lib/packages/fabro-api-client/src/models/index.ts\n+++ b/lib/packages/fabro-api-client/src/models/index.ts\n@@ -235,6 +235,7 @@ export * from './pair-transcript-system-message';\n export * from './pair-transcript-tool-call';\n export * from './pair-transcript-user-message';\n export * from './pair-transcript-warning';\n+export * from './parallel-branch-result';\n export * from './pending-interview-record';\n export * from './pending-reason';\n export * from './permission-level';\n@@ -264,6 +265,8 @@ export * from './principal-webhook';\n export * from './principal-worker';\n export * from './project-namespace';\n export * from './provider';\n+export * from './provider-credential-test-request';\n+export * from './provider-credential-test-response';\n export * from './provider-list';\n export * from './provider-test-list';\n export * from './provider-test-result';\ndiff --git a/lib/packages/fabro-api-client/src/models/parallel-branch-result.ts b/lib/packages/fabro-api-client/src/models/parallel-branch-result.ts\nnew file mode 100644\nindex 000000000..994117dec\n--- /dev/null\n+++ b/lib/packages/fabro-api-client/src/models/parallel-branch-result.ts\n@@ -0,0 +1,27 @@\n+/* tslint:disable */\n+/* eslint-disable */\n+/**\n+ * Fabro Run API\n+ * HTTP API for managing Fabro workflow run executions.\n+ *\n+ * The version of the OpenAPI document: 0.1.0\n+ *\n+ *\n+ * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).\n+ * https://openapi-generator.tech\n+ * Do not edit the class manually.\n+ */\n+\n+\n+// May contain unused imports in some cases\n+// @ts-ignore\n+import type { StageOutcome } from './stage-outcome';\n+\n+/**\n+ * The outcome and isolated context updates from one parallel branch.\n+ */\n+export interface ParallelBranchResult {\n+ 'id': string;\n+ 'status': StageOutcome;\n+ 'context_updates': { [key: string]: any; };\n+}\ndiff --git a/lib/packages/fabro-api-client/src/models/provider-credential-test-request.ts b/lib/packages/fabro-api-client/src/models/provider-credential-test-request.ts\nnew file mode 100644\nindex 000000000..390b00848\n--- /dev/null\n+++ b/lib/packages/fabro-api-client/src/models/provider-credential-test-request.ts\n@@ -0,0 +1,22 @@\n+/* tslint:disable */\n+/* eslint-disable */\n+/**\n+ * Fabro Run API\n+ * HTTP API for managing Fabro workflow run executions.\n+ *\n+ * The version of the OpenAPI document: 0.1.0\n+ *\n+ *\n+ * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).\n+ * https://openapi-generator.tech\n+ * Do not edit the class manually.\n+ */\n+\n+\n+\n+/**\n+ * API key to validate against an LLM provider without persisting it.\n+ */\n+export interface ProviderCredentialTestRequest {\n+ 'api_key': string;\n+}\ndiff --git a/lib/packages/fabro-api-client/src/models/provider-credential-test-response.ts b/lib/packages/fabro-api-client/src/models/provider-credential-test-response.ts\nnew file mode 100644\nindex 000000000..b74a688eb\n--- /dev/null\n+++ b/lib/packages/fabro-api-client/src/models/provider-credential-test-response.ts\n@@ -0,0 +1,22 @@\n+/* tslint:disable */\n+/* eslint-disable */\n+/**\n+ * Fabro Run API\n+ * HTTP API for managing Fabro workflow run executions.\n+ *\n+ * The version of the OpenAPI document: 0.1.0\n+ *\n+ *\n+ * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).\n+ * https://openapi-generator.tech\n+ * Do not edit the class manually.\n+ */\n+\n+\n+\n+/**\n+ * Successful response from provider credential validation.\n+ */\n+export interface ProviderCredentialTestResponse {\n+ 'ok': boolean;\n+}\ndiff --git a/lib/packages/fabro-api-client/src/models/stage-projection.ts b/lib/packages/fabro-api-client/src/models/stage-projection.ts\nindex b49e864ca..6e9f8c446 100644\n--- a/lib/packages/fabro-api-client/src/models/stage-projection.ts\n+++ b/lib/packages/fabro-api-client/src/models/stage-projection.ts\n@@ -30,6 +30,9 @@ import type { CommandTermination } from './command-termination';\n import type { McpServerProjection } from './mcp-server-projection';\n // May contain unused imports in some cases\n // @ts-ignore\n+import type { ParallelBranchResult } from './parallel-branch-result';\n+// May contain unused imports in some cases\n+// @ts-ignore\n import type { PermissionLevel } from './permission-level';\n // May contain unused imports in some cases\n // @ts-ignore\n@@ -75,9 +78,9 @@ export interface StageProjection {\n */\n 'script_timing'?: object | null;\n /**\n- * Per-branch result objects produced by a parallel stage.\n+ * Ordered per-branch results produced by a parallel stage.\n */\n- 'parallel_results'?: Array | null;\n+ 'parallel_results'?: Array | null;\n 'output'?: string | null;\n 'output_bytes'?: number | null;\n 'live_streaming'?: boolean | null;\ndiff --git a/test/attractor/reference_template.dot b/test/attractor/reference_template.dot\nindex 92767f3b3..35c2fac31 100644\n--- a/test/attractor/reference_template.dot\n+++ b/test/attractor/reference_template.dot\n@@ -106,8 +106,8 @@ digraph reference_template {\n // PROMPT: consolidate_dod\n // Role: synthesize dod_a/b/c into consensus DoD\n // Must address:\n- // - Read branch outputs via parallel_results.json + worktree_dir\n- // - Fall back to current worktree if parallel_results.json missing\n+ // - Review every branch result in parallel.results from prompt context\n+ // - Read branch files from the shared checkout\n // - Read .ai/spec.md for context\n // - Resolve contradictions, apply DoD rubric + coverage checklist\n // Writes: .ai/definition_of_done.md\n@@ -138,8 +138,8 @@ digraph reference_template {\n // PROMPT: debate_consolidate\n // Role: synthesize plan_a/b/c into best-of-breed final plan\n // Must address:\n- // - Read branch outputs via parallel_results.json + worktree_dir\n- // - Fall back to current worktree if parallel_results.json missing\n+ // - Review every branch result in parallel.results from prompt context\n+ // - Read branch files from the shared checkout\n // - If .ai/postmortem_latest.md exists, verify plan addresses\n // every identified issue\n // - Resolve conflicts, ensure dependency order\n@@ -269,8 +269,8 @@ digraph reference_template {\n // PROMPT: review_consensus\n // Role: synthesize reviews into consensus verdict\n // Must address:\n- // - Read branch outputs via parallel_results.json + worktree_dir\n- // - Fall back to current worktree if parallel_results.json missing\n+ // - Review every branch result in parallel.results from prompt context\n+ // - Read branch files from the shared checkout\n // - Read .ai/definition_of_done.md for criteria\n // - Consensus: 2+ APPROVED with no critical gaps -> success;\n // otherwise -> retry with specific issues\n@@ -287,8 +287,8 @@ digraph reference_template {\n // Must address:\n // - Read .ai/review_consensus.md (if review stage reached)\n // - Read .ai/verify_fidelity.md (if semantic verify ran)\n- // - Read branch review outputs via parallel_results.json +\n- // worktree_dir if available\n+ // - Review branch status and context updates in parallel.results\n+ // from prompt context if available\n // - Read .ai/implementation_log.md\n // - Output: root causes, what worked (preserve), what failed\n // (fix), concrete next changes\ndiff --git a/test/docs/examples/clone-substack/clone-substack.fabro b/test/docs/examples/clone-substack/clone-substack.fabro\nindex 7c06e36a7..f3404383c 100644\n--- a/test/docs/examples/clone-substack/clone-substack.fabro\n+++ b/test/docs/examples/clone-substack/clone-substack.fabro\n@@ -130,8 +130,8 @@ Write to .workflow/plan_b.md.\"\n label=\"Debate & Consolidate\",\n prompt=\"Synthesize the two implementation plans into a single best-of-breed \\\n final plan.\\n\\n\\\n-Read branch outputs via parallel_results.json. If parallel_results.json is missing, \\\n-fall back to reading .workflow/plan_a.md and .workflow/plan_b.md.\\n\\n\\\n+Review every branch result in parallel.results from the prompt context, then read \\\n+.workflow/plan_a.md and .workflow/plan_b.md from the shared checkout.\\n\\n\\\n If .workflow/postmortem_latest.md exists, read it FIRST. The postmortem contains \\\n root-cause analysis and concrete fixes from the previous iteration. The final plan \\\n MUST be adjusted to address every issue identified in the postmortem — add new \\\n@@ -458,8 +458,8 @@ Write to .workflow/review_b.md.\"\n goal_gate=true,\n retry_target=\"postmortem\",\n prompt=\"Synthesize the two reviews into a consensus verdict.\\n\\n\\\n-Read branch outputs via parallel_results.json. If parallel_results.json is \\\n-missing, fall back to reading .workflow/review_a.md and .workflow/review_b.md.\\n\\n\\\n+Review every branch result in parallel.results from the prompt context, then read \\\n+.workflow/review_a.md and .workflow/review_b.md from the shared checkout.\\n\\n\\\n Read .workflow/definition_of_done.md for acceptance criteria reference.\\n\\n\\\n Consensus rules:\\n\\\n - Both APPROVED with no critical gaps: the implementation passes\\n\\\n@@ -490,7 +490,7 @@ Read (if they exist):\\n\\\n - .workflow/test-evidence/latest/manifest.json\\n\\\n - Evidence files referenced by manifest entries for failed or suspicious IT \\\n scenarios\\n\\\n-- Branch review outputs via parallel_results.json (if available)\\n\\n\\\n+- Parallel branch review status and context updates from parallel.results (if available)\\n\\n\\\n Output to .workflow/postmortem_latest.md (overwrite previous):\\n\\\n - Root causes of failure\\n\\\n - What works and must be preserved\\n\\\ndiff --git a/test/docs/tutorials/ensemble/ensemble.fabro b/test/docs/tutorials/ensemble/ensemble.fabro\nindex 3a8655cde..87aadf36c 100644\n--- a/test/docs/tutorials/ensemble/ensemble.fabro\n+++ b/test/docs/tutorials/ensemble/ensemble.fabro\n@@ -14,7 +14,7 @@ digraph Ensemble {\n start [shape=Mdiamond, label=\"Start\"]\n exit [shape=Msquare, label=\"Exit\"]\n \n- fork [label=\"Fan Out\", shape=component, join_policy=\"wait_all\"]\n+ fork [label=\"Fan Out\", shape=component]\n \n opus [label=\"Opus\", prompt=\"Analyze the goal. Provide your independent assessment, recommendations, and any code or prose needed. Be thorough.\", shape=tab]\n gemini [label=\"Gemini\", prompt=\"Analyze the goal. Provide your independent assessment, recommendations, and any code or prose needed. Be thorough.\", shape=tab]\ndiff --git a/test/docs/tutorials/parallel-review/parallel.fabro b/test/docs/tutorials/parallel-review/parallel.fabro\nindex a4cab30e9..c699750bc 100644\n--- a/test/docs/tutorials/parallel-review/parallel.fabro\n+++ b/test/docs/tutorials/parallel-review/parallel.fabro\n@@ -5,7 +5,7 @@ digraph Parallel {\n start [shape=Mdiamond, label=\"Start\"]\n exit [shape=Msquare, label=\"Exit\"]\n \n- fork [label=\"Fork Analysis\", shape=component, join_policy=\"wait_all\"]\n+ fork [label=\"Fork Analysis\", shape=component]\n \n security [label=\"Security Audit\", prompt=\"Examine the codebase for security concerns: hardcoded secrets, injection risks, unsafe dependencies. List findings as bullet points.\", shape=tab, reasoning_effort=\"low\"]\n architecture [label=\"Architecture Review\", prompt=\"Assess the codebase architecture: separation of concerns, dependency structure, modularity. List findings as bullet points.\", shape=tab, reasoning_effort=\"low\"]\ndiff --git a/test/docs/workflows/stages-and-nodes/all-node-types.fabro b/test/docs/workflows/stages-and-nodes/all-node-types.fabro\nindex 75d4f3fc5..1970c147e 100644\n--- a/test/docs/workflows/stages-and-nodes/all-node-types.fabro\n+++ b/test/docs/workflows/stages-and-nodes/all-node-types.fabro\n@@ -7,7 +7,7 @@ digraph AllNodeTypes {\n test [label=\"Run Tests\", shape=parallelogram, script=\"cargo test 2>&1 || true\"]\n gate [shape=diamond, label=\"Tests passing?\"]\n cooldown [label=\"Wait 30s\", shape=insulator, duration=\"30s\"]\n- fork [label=\"Fan Out\", shape=component, join_policy=\"wait_all\"]\n+ fork [label=\"Fan Out\", shape=component]\n security [label=\"Security Review\"]\n architecture [label=\"Architecture Review\"]\n quality [label=\"Quality Review\"]\ndiff --git a/test/parallel.fabro b/test/parallel.fabro\nindex 96cd323fa..381a9f2c5 100644\n--- a/test/parallel.fabro\n+++ b/test/parallel.fabro\n@@ -4,7 +4,7 @@ digraph Parallel {\n start [shape=Mdiamond]\n exit [shape=Msquare]\n \n- fork [label=\"Fork Work\", shape=component, join_policy=\"wait_all\"]\n+ fork [label=\"Fork Work\", shape=component]\n branch1 [label=\"Branch 1\"]\n branch2 [label=\"Branch 2\"]\n merge [label=\"Merge Results\", shape=tripleoctagon]\n", + "summary": { + "files_changed": 83, + "additions": 2081, + "deletions": 3898 + } + } } ], - "conclusion": null, + "conclusion": { + "timestamp": "2026-07-24T10:14:36.743498050Z", + "status": "failed", + "timing": { + "wall_time_ms": 24841026, + "inference_time_ms": 9699187, + "tool_time_ms": 9117707, + "active_time_ms": 18816894 + }, + "failure": { + "reason": "cancelled", + "detail": { + "message": "Pipeline cancelled", + "category": "canceled" + } + }, + "final_git_commit_sha": "b013ffb709e7d064b2164a81116ad63510b7bd0b", + "stages": [ + { + "stage_id": "start", + "stage_label": "start", + "timing": { + "wall_time_ms": 0, + "inference_time_ms": 0, + "tool_time_ms": 0, + "active_time_ms": 0 + }, + "retries": 0 + }, + { + "stage_id": "toolchain", + "stage_label": "toolchain", + "timing": { + "wall_time_ms": 1289, + "inference_time_ms": 0, + "tool_time_ms": 1285, + "active_time_ms": 1285 + }, + "retries": 0 + }, + { + "stage_id": "preflight_compile", + "stage_label": "preflight_compile", + "timing": { + "wall_time_ms": 137032, + "inference_time_ms": 0, + "tool_time_ms": 137028, + "active_time_ms": 137028 + }, + "retries": 0 + }, + { + "stage_id": "preflight_lint", + "stage_label": "preflight_lint", + "timing": { + "wall_time_ms": 165285, + "inference_time_ms": 0, + "tool_time_ms": 165282, + "active_time_ms": 165282 + }, + "retries": 0 + }, + { + "stage_id": "implement", + "stage_label": "implement", + "timing": { + "wall_time_ms": 6614041, + "inference_time_ms": 3405782, + "tool_time_ms": 2880638, + "active_time_ms": 6286420 + }, + "retries": 0 + }, + { + "stage_id": "simplify_fable", + "stage_label": "simplify_fable", + "timing": { + "wall_time_ms": 12228277, + "inference_time_ms": 6293405, + "tool_time_ms": 5933474, + "active_time_ms": 12226879 + }, + "billing_usd_micros": 60153414, + "retries": 0 + } + ], + "billing": { + "input_tokens": 2593345, + "output_tokens": 527061, + "total_tokens": 497852444, + "reasoning_tokens": 819762, + "cache_read_tokens": 488200219, + "cache_write_tokens": 5712057, + "total_usd_micros": 531421262 + }, + "total_retries": 0, + "diff": {} + }, "sandbox": { "kind": "ready", "plan": { @@ -1696,6 +1801,321 @@ "agent_control": "running", "state": "succeeded" }, + "simplify_sol@1": { + "first_event_seq": 11715, + "prompt": null, + "response": null, + "completion": null, + "provider_used": { + "mode": "agent", + "provider": "openrouter", + "model": "openai/gpt-5.6-sol", + "reasoning_effort": "max" + }, + "diff": null, + "script_invocation": null, + "script_timing": null, + "parallel_results": null, + "output": null, + "started_at": "2026-07-24T06:45:20.260350450Z", + "handler": "agent", + "usage": { + "input_tokens": 2381243, + "output_tokens": 113557, + "total_tokens": 425605589, + "reasoning_tokens": 636131, + "cache_read_tokens": 418707130, + "cache_write_tokens": 3767528, + "total_usd_micros": 471267848 + }, + "model": { + "provider": "openrouter", + "model_id": "gpt-5.6-sol" + }, + "todos": { + "kind": "openai_plan", + "list_id": "openai_plan:9b72836c-4fbb-46bd-94d0-0493116d4be3", + "items": [ + { + "id": "a90777d3e027fa91", + "status": "completed", + "order": 0, + "subject": "Inspect repository instructions and collect the complete implementation diff" + }, + { + "id": "acfe029ba81f7c31", + "status": "completed", + "order": 1, + "subject": "Launch reuse, quality, and efficiency review agents with the full diff" + }, + { + "id": "affe4f6eb75db16a", + "status": "in_progress", + "order": 2, + "subject": "Evaluate findings and apply worthwhile fixes" + }, + { + "id": "8c2771b0ca2b57fb", + "status": "pending", + "order": 3, + "subject": "Run focused and broad validation for any fixes" + }, + { + "id": "eb1b4adfba6aba2a", + "status": "pending", + "order": 4, + "subject": "Summarize the review and changes" + } + ] + }, + "subagents": [ + { + "agent_id": "592501d5", + "depth": 1, + "task": "Act as the CODE REUSE reviewer for the Shared-Checkout Parallel Execution implementation. Review only; do not edit files. The complete, exact task diff is materialized at `/tmp/fabro-shared-checkout-parallel.full.diff` (8,178 lines, generated with `git diff --find-renames --find-copies --no-ext-diff --full-index 30d770046..HEAD`). Read the ENTIRE diff file before concluding; use `git diff 30d770046..HEAD -- ` and repository searches for deeper context as needed. The task intentionally replaces parallel Git worktrees/selection with concurrent branches sharing one sandbox/checkout, typed ordered results, and standard/no-op fan-in; removes join_policy and obsolete Git/event/UI infrastructure. For every changed area, search for existing utilities/helpers and flag: duplicated new functions, inline logic that should use an existing utility, hand-rolled string/path/env/type-guard behavior, or abstractions already available nearby or in shared modules. Check Rust, TypeScript/UI, OpenAPI/generated clients, tests, and docs. Report only concrete, actionable findings (severity, file + current line(s), why, exact existing helper/pattern to reuse, proposed fix). Screen false positives and explicitly say if no worthwhile findings. Also call out any generated-file churn that indicates generation was performed incorrectly.", + "status": { + "kind": "completed", + "success": true, + "turns_used": 142 + } + }, + { + "agent_id": "21e6a217", + "depth": 1, + "task": "Act as the CODE QUALITY reviewer for the Shared-Checkout Parallel Execution implementation. Review only; do not edit files. The complete, exact task diff is materialized at `/tmp/fabro-shared-checkout-parallel.full.diff` (8,178 lines, generated with `git diff --find-renames --find-copies --no-ext-diff --full-index 30d770046..HEAD`). Read the ENTIRE diff file before concluding; use `git diff 30d770046..HEAD -- `, tests, and repository searches for deeper context. The task intentionally replaces parallel Git worktrees/selection with concurrent branches sharing one sandbox/checkout, typed ordered results, and standard/no-op fan-in; removes join_policy and obsolete Git/event/UI infrastructure. Review every changed area for correctness and quality, especially redundant state, parameter sprawl, near-copy/paste, leaky abstractions, raw strings where existing types/constants apply, unnecessary JSX wrappers, unnecessary WHAT/change-narration comments, accidental compatibility behavior contrary to greenfield removal, event/API/projection drift, and subtle behavior violations of the stated plan. Check Rust, TypeScript/UI, OpenAPI/generated clients, tests, and docs. Report only concrete actionable findings with severity, file + current line(s), reasoning, and a proposed fix. Screen false positives; explicitly say if no worthwhile findings. Do not merely restate intended breaking removals.", + "status": { + "kind": "completed", + "success": true, + "turns_used": 1140 + } + }, + { + "agent_id": "499d8608", + "depth": 1, + "task": "Act as the EFFICIENCY reviewer for the Shared-Checkout Parallel Execution implementation. Review only; do not edit files. The complete, exact task diff is materialized at `/tmp/fabro-shared-checkout-parallel.full.diff` (8,178 lines, generated with `git diff --find-renames --find-copies --no-ext-diff --full-index 30d770046..HEAD`). Read the ENTIRE diff file before concluding; use `git diff 30d770046..HEAD -- ` and repository searches for deeper context. The task intentionally replaces parallel Git worktrees/selection with concurrent branches sharing one sandbox/checkout, typed ordered results, and standard/no-op fan-in; max_parallel/cancellation/order must remain correct. Review all changes for unnecessary repeated work/file reads/serialization/network calls, missed concurrency, hot-path blocking/bloat, recurring no-op state updates (including whether updater wrappers honor same-reference no-op returns), TOCTOU existence checks, leaks/unbounded memory, overly broad reads, avoidable cloning/large-value materialization, semaphore/task/cancellation issues, and render inefficiency. Check Rust, TypeScript/UI, OpenAPI/generated clients, tests, and docs. Report only concrete actionable findings with severity, file + current line(s), reasoning, and proposed fix. Screen false positives and explicitly say if no worthwhile findings.", + "status": { + "kind": "completed", + "success": true, + "turns_used": 384 + } + } + ], + "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" + }, + { + "name": "rust-style-guide", + "source": "tool" + }, + { + "name": "rust-style-guide", + "source": "tool" + }, + { + "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": true + }, + { + "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": "openai/gpt-5.6-sol", + "context_window_tokens": 1050000, + "input_tokens": 352562, + "usage_percent": 33.577333333333335, + "count_method": "response_usage_scaled_breakdown", + "staleness": "live", + "generated_at": "2026-07-24T10:13:50.060991020Z", + "event_seq": 16316, + "breakdown": [ + { + "category": "system_prompt", + "tokens": 1045, + "usage_percent": 0.09952380952380953 + }, + { + "category": "tools", + "tokens": 1426, + "usage_percent": 0.1358095238095238 + }, + { + "category": "skills", + "tokens": 198, + "usage_percent": 0.018857142857142857 + }, + { + "category": "memory", + "tokens": 3636, + "usage_percent": 0.3462857142857143 + }, + { + "category": "conversation", + "tokens": 346250, + "usage_percent": 32.976190476190474 + }, + { + "category": "other", + "tokens": 7, + "usage_percent": 0.0006666666666666666 + } + ], + "warnings": [ + { + "code": "activated_skill_context_counted_as_conversation", + "message": "Activated skill instructions are counted as conversation in this version." + } + ] + }, + "agent_control": "running", + "state": "running" + }, "preflight_compile@1": { "first_event_seq": 31, "prompt": null, @@ -1882,7 +2302,12 @@ "first_event_seq": 3547, "prompt": null, "response": null, - "completion": null, + "completion": { + "outcome": "succeeded", + "notes": "Stage completed: simplify_fable", + "failure_reason": null, + "timestamp": "2026-07-24T06:45:17.450080596Z" + }, "provider_used": { "mode": "agent", "provider": "openrouter", @@ -1896,18 +2321,24 @@ "output": null, "started_at": "2026-07-24T03:21:29.172790992Z", "handler": "agent", + "timing": { + "wall_time_ms": 12228277, + "inference_time_ms": 6293405, + "tool_time_ms": 5933474, + "active_time_ms": 12226879 + }, "usage": { - "input_tokens": 693556, - "output_tokens": 903653, - "total_tokens": 91793718, - "reasoning_tokens": 407115, - "cache_read_tokens": 88597030, - "cache_write_tokens": 1192364, - "total_usd_micros": 175975679 + "input_tokens": 166817, + "output_tokens": 344509, + "total_tokens": 31631883, + "reasoning_tokens": 117120, + "cache_read_tokens": 30620801, + "cache_write_tokens": 382636, + "total_usd_micros": 60153414 }, "model": { "provider": "openrouter", - "model_id": "claude-fable-5" + "model_id": "anthropic/claude-fable-5" }, "todos": { "kind": "openai_plan", @@ -2146,12 +2577,12 @@ "provider": "openrouter", "model": "anthropic/claude-fable-5", "context_window_tokens": 1000000, - "input_tokens": 239893, - "usage_percent": 23.9893, + "input_tokens": 240327, + "usage_percent": 24.0327, "count_method": "response_usage_scaled_breakdown", "staleness": "live", - "generated_at": "2026-07-24T06:44:55.406334740Z", - "event_seq": 11694, + "generated_at": "2026-07-24T06:45:17.413584221Z", + "event_seq": 11704, "breakdown": [ { "category": "system_prompt", @@ -2175,8 +2606,8 @@ }, { "category": "conversation", - "tokens": 234063, - "usage_percent": 23.4063 + "tokens": 234497, + "usage_percent": 23.4497 }, { "category": "other", @@ -2187,7 +2618,7 @@ "warnings": [] }, "agent_control": "running", - "state": "running" + "state": "succeeded" } } } \ No newline at end of file diff --git a/stages/006-simplify_fable@1/diff.patch b/stages/006-simplify_fable@1/diff.patch new file mode 100644 index 000000000..316ccbee4 --- /dev/null +++ b/stages/006-simplify_fable@1/diff.patch @@ -0,0 +1,8178 @@ +diff --git a/apps/fabro-web/app/components/stage-renderers/fan-in-results.test.tsx b/apps/fabro-web/app/components/stage-renderers/fan-in-results.test.tsx +new file mode 100644 +index 000000000..26115eaee +--- /dev/null ++++ b/apps/fabro-web/app/components/stage-renderers/fan-in-results.test.tsx +@@ -0,0 +1,77 @@ ++import { afterEach, beforeEach, describe, expect, test } from "bun:test"; ++import type { EventEnvelope } from "@qltysh/fabro-api-client"; ++import TestRenderer, { act } from "react-test-renderer"; ++ ++import { makeEventEnvelope, setupReactTestEnv } from "../../lib/test-utils"; ++import type { Stage } from "../stage-sidebar"; ++import { FanInResults } from "./fan-in-results"; ++ ++let teardown: () => void; ++beforeEach(() => { ++ teardown = setupReactTestEnv(); ++}); ++afterEach(() => teardown()); ++ ++const fanInStage: Stage = { ++ id: "join@1", ++ name: "join", ++ handler: "parallel.fan_in", ++ status: "succeeded", ++ duration: "1s", ++ nodeId: "join", ++ visit: 1, ++ startedAt: "2026-04-09T12:00:00Z", ++ providerUsed: null, ++}; ++ ++function event(seq: number, partial: Partial): EventEnvelope { ++ return makeEventEnvelope(seq, { stage_id: "join@1", ...partial }); ++} ++ ++function renderFanIn(events: EventEnvelope[]): string { ++ (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; ++ let renderer!: TestRenderer.ReactTestRenderer; ++ act(() => { ++ renderer = TestRenderer.create(); ++ }); ++ return JSON.stringify(renderer.toJSON()); ++} ++ ++describe("FanInResults", () => { ++ test("renders a neutral joined state without best-branch selection UI", () => { ++ const rendered = renderFanIn([]); ++ ++ expect(rendered).toContain("Joined"); ++ expect(rendered).not.toContain("Selected branch"); ++ expect(rendered).not.toContain("Selected by"); ++ expect(rendered).not.toContain("TrophyIcon"); ++ }); ++ ++ test("optionally renders the standard reducer transcript", () => { ++ const rendered = renderFanIn([ ++ event(1, { ++ event: "stage.prompt", ++ properties: { ++ mode: "prompt", ++ text: "Combine the useful findings.", ++ model: "claude-sonnet-4-6", ++ }, ++ }), ++ event(2, { ++ event: "prompt.completed", ++ properties: { ++ response: "All branch findings are now available.", ++ billing: { input_tokens: 1200, output_tokens: 340 }, ++ }, ++ }), ++ ]); ++ ++ expect(rendered).toContain("Reducer transcript"); ++ expect(rendered).toContain("Combine the useful findings."); ++ expect(rendered).toContain("All branch findings are now available."); ++ expect(rendered).toContain("claude-sonnet-4-6"); ++ expect(rendered).toContain("1k"); ++ expect(rendered).toContain("340"); ++ expect(rendered).toContain("tokens"); ++ }); ++}); +diff --git a/apps/fabro-web/app/components/stage-renderers/fan-in-results.tsx b/apps/fabro-web/app/components/stage-renderers/fan-in-results.tsx +index f9126d2a4..0f977ce7b 100644 +--- a/apps/fabro-web/app/components/stage-renderers/fan-in-results.tsx ++++ b/apps/fabro-web/app/components/stage-renderers/fan-in-results.tsx +@@ -1,143 +1,53 @@ + import { useMemo } from "react"; + import { ++ CheckCircleIcon, + CpuChipIcon, +- SparklesIcon, +- TrophyIcon, + } from "@heroicons/react/20/solid"; + import type { EventEnvelope } from "@qltysh/fabro-api-client"; + + import type { Stage } from "../stage-sidebar"; + import { formatTokenCount } from "../../lib/format"; +-import { getString } from "../../lib/unknown"; + import { Markdown } from "./primitives"; +-import { prettyJson } from "./pretty-json"; + import { StageMetaBar } from "./meta-bar"; +-import { parseFanInOutcome } from "./helpers"; +- +-interface ReducerTurn { +- prompt: string; +- response: string; +- model: string | null; +- inputTokens: number; +- outputTokens: number; +-} +- +-function extractReducerTurn(events: EventEnvelope[]): ReducerTurn | null { +- let prompt = ""; +- let response = ""; +- let model: string | null = null; +- let inputTokens = 0; +- let outputTokens = 0; +- let hasReducer = false; +- +- for (const event of events) { +- const props = event.properties ?? {}; +- if (event.event === "stage.prompt" && getString(props, "mode") === "fan_in") { +- prompt = getString(props, "text") ?? prompt; +- hasReducer = true; +- } else if (event.event === "prompt.completed") { +- response = getString(props, "response") ?? response; +- model = getString(props, "model") ?? model; +- const billing = props.billing as Record | undefined; +- if (billing) { +- const it = billing.input_tokens; +- const ot = billing.output_tokens; +- if (typeof it === "number") inputTokens = it; +- if (typeof ot === "number") outputTokens = ot; +- } +- hasReducer = true; +- } +- } +- +- return hasReducer ? { prompt, response, model, inputTokens, outputTokens } : null; +-} +- +-/** +- * The fan-in `stage.prompt.text` is built by the handler as +- * "\n\n". Split it for nicer display so the JSON candidate set +- * lands in a code block rather than fighting markdown rendering. +- */ +-function splitPromptAndCandidates(text: string): { prompt: string; candidatesJson: string } { +- const trimmed = text.trim(); +- // Find the start of the JSON envelope. The handler always uses array results +- // so look for the first opening bracket that begins a balanced array/object. +- const idx = (() => { +- for (let i = 0; i < trimmed.length; i += 1) { +- const ch = trimmed[i]; +- if (ch === "[" || ch === "{") return i; +- } +- return -1; +- })(); +- if (idx < 0) return { prompt: trimmed, candidatesJson: "" }; +- const promptPart = trimmed.slice(0, idx).trim(); +- const jsonPart = trimmed.slice(idx).trim(); +- const pretty = prettyJson(jsonPart); +- return { +- prompt: promptPart, +- candidatesJson: pretty.isJson ? pretty.text : jsonPart, +- }; +-} ++import { parseReducerTranscript } from "./helpers"; + + export function FanInResults({ + stage, + events, +- notes, + }: { + stage: Stage; + events: EventEnvelope[]; +- notes: string | null; + }) { +- const outcome = useMemo(() => parseFanInOutcome(events, notes), [events, notes]); +- const reducer = useMemo(() => extractReducerTurn(events), [events]); +- const promptParts = useMemo( +- () => (reducer ? splitPromptAndCandidates(reducer.prompt) : null), +- [reducer], +- ); ++ const reducer = useMemo(() => parseReducerTranscript(events), [events]); + + return ( +
+ +- {outcome.reducerModel ? ( ++ {reducer?.model ? ( + + + ) : null} + + +-
++
+
+-
+-
+ +- {reducer && promptParts && ( ++ {reducer && ( +
+

+ Reducer transcript +@@ -149,18 +59,10 @@ export function FanInResults({ + Prompt + + +- {promptParts.prompt && ( +- +- )} +- {promptParts.candidatesJson && ( +-
+- +- Candidates JSON +- +-
+-                  {promptParts.candidatesJson}
+-                
+-
++ {reducer.prompt ? ( ++ ++ ) : ( ++

No prompt recorded.

+ )} + + +diff --git a/apps/fabro-web/app/components/stage-renderers/helpers.test.ts b/apps/fabro-web/app/components/stage-renderers/helpers.test.ts +index d5603d8bf..4fcf47e95 100644 +--- a/apps/fabro-web/app/components/stage-renderers/helpers.test.ts ++++ b/apps/fabro-web/app/components/stage-renderers/helpers.test.ts +@@ -3,10 +3,9 @@ import type { EventEnvelope } from "@qltysh/fabro-api-client"; + + import { + extractStageContext, +- extractStageNotes, +- parseFanInOutcome, + parseHumanInterviewPairs, + parseParallelOverview, ++ parseReducerTranscript, + } from "./helpers"; + + function envelope(seq: number, partial: Partial): EventEnvelope { +@@ -144,45 +143,70 @@ describe("parseHumanInterviewPairs", () => { + }); + + describe("parseParallelOverview", () => { +- test("rolls up branch_count from started and results from completed", () => { ++ test("rolls up branch_count and status-only results", () => { + const events: EventEnvelope[] = [ + envelope(1, { + event: "parallel.started", +- properties: { branch_count: 3, join_policy: "wait_all" }, ++ properties: { branch_count: 3 }, + }), + envelope(2, { + event: "parallel.completed", + properties: { ++ duration_ms: 12000, + success_count: 2, + failure_count: 1, + results: [ +- { id: "branch-a", status: "succeeded", head_sha: "abc1234567890" }, +- { id: "branch-b", status: "succeeded" }, +- { id: "branch-c", status: "failed" }, ++ { ++ id: "branch-a", ++ status: "succeeded", ++ context_updates: { "response.branch-a": "A" }, ++ }, ++ { ++ id: "branch-b", ++ status: "succeeded", ++ context_updates: { "command.output": { stdout: "B" } }, ++ }, ++ { ++ id: "branch-c", ++ status: "failed", ++ context_updates: { "response.branch-c": "C" }, ++ }, + ], + }, + }), + ]; + const overview = parseParallelOverview(events); +- expect(overview).toMatchObject({ ++ expect(overview).toEqual({ + branchCount: 3, +- joinPolicy: "wait_all", + successCount: 2, + failureCount: 1, ++ durationMs: 12000, ++ results: [ ++ { ++ id: "branch-a", ++ status: "succeeded", ++ context_updates: { "response.branch-a": "A" }, ++ }, ++ { ++ id: "branch-b", ++ status: "succeeded", ++ context_updates: { "command.output": { stdout: "B" } }, ++ }, ++ { ++ id: "branch-c", ++ status: "failed", ++ context_updates: { "response.branch-c": "C" }, ++ }, ++ ], + isComplete: true, + }); +- expect(overview.results).toEqual([ +- { id: "branch-a", status: "succeeded", headSha: "abc1234567890" }, +- { id: "branch-b", status: "succeeded", headSha: null }, +- { id: "branch-c", status: "failed", headSha: null }, +- ]); + }); + + test("reports in-flight when only the started event is present", () => { + const events: EventEnvelope[] = [ + envelope(1, { + event: "parallel.started", +- properties: { branch_count: 4, join_policy: "first_success" }, ++ properties: { branch_count: 4 }, + }), + ]; + const overview = parseParallelOverview(events); +@@ -192,49 +216,52 @@ describe("parseParallelOverview", () => { + }); + }); + +-describe("parseFanInOutcome", () => { +- test("extracts the selected branch id from notes", () => { +- const outcome = parseFanInOutcome([], "Selected best candidate: branch-42"); +- expect(outcome.selectedId).toBe("branch-42"); +- expect(outcome.hasReducerTranscript).toBe(false); ++describe("parseReducerTranscript", () => { ++ test("returns null when fan-in joins without a reducer", () => { ++ expect(parseReducerTranscript([])).toBeNull(); + }); + +- test("flags reducer presence when fan-in prompt events exist", () => { ++ test("parses the standard prompt transcript when a reducer ran", () => { + const events: EventEnvelope[] = [ + envelope(1, { + event: "stage.prompt", +- properties: { mode: "fan_in", text: "rank these", model: "claude-sonnet-4-6" }, ++ properties: { ++ mode: "prompt", ++ text: "Combine the branch results.", ++ model: "claude-sonnet-4-6", ++ }, + }), + envelope(2, { + event: "prompt.completed", +- properties: { response: "branch-a wins", model: "ignored-downstream-model" }, ++ properties: { ++ response: "The branch results are joined.", ++ billing: { input_tokens: 1200, output_tokens: 340 }, ++ }, + }), + ]; +- const outcome = parseFanInOutcome(events, "Selected best candidate: branch-a"); +- expect(outcome.hasReducerTranscript).toBe(true); +- expect(outcome.reducerModel).toBe("claude-sonnet-4-6"); +- expect(outcome.selectedId).toBe("branch-a"); +- }); + +- test("returns null selection when notes lack the selected line", () => { +- const outcome = parseFanInOutcome([], "all candidates failed"); +- expect(outcome.selectedId).toBeNull(); ++ expect(parseReducerTranscript(events)).toEqual({ ++ prompt: "Combine the branch results.", ++ response: "The branch results are joined.", ++ model: "claude-sonnet-4-6", ++ inputTokens: 1200, ++ outputTokens: 340, ++ }); + }); +-}); + +-describe("extractStageNotes", () => { +- test("returns notes from the stage.completed event", () => { ++ test("uses normal prompt mode for the reducer transcript", () => { + const events: EventEnvelope[] = [ + envelope(1, { +- event: "stage.completed", +- properties: { notes: "Stop condition satisfied at cycle 7" }, ++ event: "stage.prompt", ++ properties: { mode: "prompt", text: "Standard reducer" }, ++ }), ++ envelope(2, { ++ event: "prompt.completed", ++ properties: { response: "Standard response" }, + }), + ]; +- expect(extractStageNotes(events)).toBe("Stop condition satisfied at cycle 7"); +- }); + +- test("returns null when there is no stage.completed event", () => { +- expect(extractStageNotes([])).toBeNull(); ++ expect(parseReducerTranscript(events)?.response).toBe("Standard response"); + }); + }); + +diff --git a/apps/fabro-web/app/components/stage-renderers/helpers.ts b/apps/fabro-web/app/components/stage-renderers/helpers.ts +index f7f6fa10d..3c631a9e2 100644 +--- a/apps/fabro-web/app/components/stage-renderers/helpers.ts ++++ b/apps/fabro-web/app/components/stage-renderers/helpers.ts +@@ -1,7 +1,16 @@ +-import type { EventEnvelope } from "@qltysh/fabro-api-client"; ++import { StageOutcome } from "@qltysh/fabro-api-client"; ++import type { EventEnvelope, ParallelBranchResult } from "@qltysh/fabro-api-client"; ++ ++export type { ParallelBranchResult }; + + import { getArray, getNumber, getObject, getString, type UnknownRecord } from "../../lib/unknown"; + ++const STAGE_OUTCOMES: ReadonlySet = new Set(Object.values(StageOutcome)); ++ ++function asStageOutcome(value: string | undefined): StageOutcome | null { ++ return value !== undefined && STAGE_OUTCOMES.has(value) ? (value as StageOutcome) : null; ++} ++ + export interface InterviewOption { + key: string; + label: string; +@@ -137,15 +146,8 @@ export function parseHumanInterviewPairs(events: EventEnvelope[]): HumanIntervie + return Array.from(pairs.values()).sort((a, b) => a.question.ts.localeCompare(b.question.ts)); + } + +-export interface ParallelBranchResult { +- id: string; +- status: string; +- headSha: string | null; +-} +- + export interface ParallelOverview { + branchCount: number | null; +- joinPolicy: string | null; + successCount: number | null; + failureCount: number | null; + durationMs: number | null; +@@ -160,7 +162,6 @@ export interface ParallelOverview { + */ + export function parseParallelOverview(events: EventEnvelope[]): ParallelOverview { + let branchCount: number | null = null; +- let joinPolicy: string | null = null; + let successCount: number | null = null; + let failureCount: number | null = null; + let durationMs: number | null = null; +@@ -171,7 +172,6 @@ export function parseParallelOverview(events: EventEnvelope[]): ParallelOverview + const props: UnknownRecord = event.properties ?? {}; + if (event.event === "parallel.started") { + branchCount = getNumber(props, "branch_count") ?? branchCount; +- joinPolicy = getString(props, "join_policy") ?? joinPolicy; + } else if (event.event === "parallel.completed") { + isComplete = true; + successCount = getNumber(props, "success_count") ?? successCount; +@@ -182,20 +182,23 @@ export function parseParallelOverview(events: EventEnvelope[]): ParallelOverview + .map((entry) => { + const record = entry && typeof entry === "object" ? (entry as UnknownRecord) : null; + if (!record) return null; ++ const id = getString(record, "id"); ++ const status = asStageOutcome(getString(record, "status")); ++ const contextUpdates = getObject(record, "context_updates"); ++ if (!id || !status || !contextUpdates) return null; + return { +- id: getString(record, "id") ?? "", +- status: getString(record, "status") ?? "unknown", +- headSha: getString(record, "head_sha") ?? null, ++ id, ++ status, ++ context_updates: contextUpdates, + } satisfies ParallelBranchResult; + }) +- .filter((r): r is ParallelBranchResult => r != null && r.id !== ""); ++ .filter((r): r is ParallelBranchResult => r != null); + if (branchCount == null) branchCount = results.length; + } + } + + return { + branchCount, +- joinPolicy, + successCount, + failureCount, + durationMs, +@@ -204,59 +207,39 @@ export function parseParallelOverview(events: EventEnvelope[]): ParallelOverview + }; + } + +-export interface FanInOutcome { +- selectedId: string | null; +- hasReducerTranscript: boolean; +- reducerModel: string | null; ++export interface ReducerTranscript { ++ prompt: string; ++ response: string; ++ model: string | null; ++ inputTokens: number; ++ outputTokens: number; + } + +-const FAN_IN_NOTES_RE = /Selected best candidate:\s*(.+?)\s*$/; ++/** Extract the standard prompt/response transcript emitted by an optional fan-in reducer. */ ++export function parseReducerTranscript(events: EventEnvelope[]): ReducerTranscript | null { ++ let prompt = ""; ++ let response = ""; ++ let model: string | null = null; ++ let inputTokens = 0; ++ let outputTokens = 0; ++ let hasReducer = false; + +-/** +- * Derive the fan-in winner from the `parallel.completed`-style notes string, +- * and report whether reducer LLM events were emitted (so the UI knows to show +- * the embedded transcript). +- */ +-export function parseFanInOutcome(events: EventEnvelope[], notes: string | null): FanInOutcome { +- const match = notes ? FAN_IN_NOTES_RE.exec(notes) : null; +- let hasReducerTranscript = false; +- let reducerModel: string | null = null; + for (const event of events) { ++ const props: UnknownRecord = event.properties ?? {}; + if (event.event === "stage.prompt") { +- const mode = getString(event.properties ?? {}, "mode"); +- if (mode === "fan_in") { +- hasReducerTranscript = true; +- const model = getString(event.properties ?? {}, "model"); +- if (model) reducerModel = model; +- } +- } +- if (event.event === "prompt.completed") { +- hasReducerTranscript = true; ++ prompt = getString(props, "text") ?? prompt; ++ model = getString(props, "model") ?? model; ++ hasReducer = true; ++ } else if (event.event === "prompt.completed" && hasReducer) { ++ response = getString(props, "response") ?? response; ++ model = getString(props, "model") ?? model; ++ const billing = getObject(props, "billing") ?? {}; ++ inputTokens = getNumber(billing, "input_tokens") ?? inputTokens; ++ outputTokens = getNumber(billing, "output_tokens") ?? outputTokens; + } + } +- return { +- selectedId: match ? match[1].trim() : null, +- hasReducerTranscript, +- reducerModel, +- }; +-} + +-function asUnknownRecord(value: unknown): UnknownRecord | null { +- if (!value || typeof value !== "object" || Array.isArray(value)) return null; +- return value as UnknownRecord; +-} +- +-/** +- * Extract `notes` from the `stage.completed` event scoped to this stage. +- * Returns null when the stage hasn't finished yet or when notes are absent. +- */ +-export function extractStageNotes(events: EventEnvelope[]): string | null { +- for (const event of events) { +- if (event.event !== "stage.completed") continue; +- const notes = getString(event.properties ?? {}, "notes"); +- if (notes) return notes; +- } +- return null; ++ return hasReducer ? { prompt, response, model, inputTokens, outputTokens } : null; + } + + export interface StageContextData { +diff --git a/apps/fabro-web/app/components/stage-renderers/parallel-children.test.tsx b/apps/fabro-web/app/components/stage-renderers/parallel-children.test.tsx +new file mode 100644 +index 000000000..f1313b780 +--- /dev/null ++++ b/apps/fabro-web/app/components/stage-renderers/parallel-children.test.tsx +@@ -0,0 +1,85 @@ ++import { afterEach, beforeEach, describe, expect, test } from "bun:test"; ++import type { EventEnvelope } from "@qltysh/fabro-api-client"; ++import TestRenderer, { act } from "react-test-renderer"; ++import { MemoryRouter } from "react-router"; ++ ++import { makeEventEnvelope, setupReactTestEnv } from "../../lib/test-utils"; ++import type { Stage } from "../stage-sidebar"; ++import { ParallelChildren } from "./parallel-children"; ++ ++let teardown: () => void; ++beforeEach(() => { ++ teardown = setupReactTestEnv(); ++}); ++afterEach(() => teardown()); ++ ++const parallelStage: Stage = { ++ id: "fork@1", ++ name: "fork", ++ handler: "parallel", ++ status: "succeeded", ++ duration: "12s", ++ nodeId: "fork", ++ visit: 1, ++ startedAt: "2026-04-09T12:00:00Z", ++ providerUsed: null, ++}; ++ ++function event(partial: Partial): EventEnvelope { ++ return makeEventEnvelope(partial.seq ?? 1, { event: "parallel.completed", ...partial }); ++} ++ ++function renderParallel(events: EventEnvelope[]): TestRenderer.ReactTestRenderer { ++ let renderer!: TestRenderer.ReactTestRenderer; ++ act(() => { ++ renderer = TestRenderer.create( ++ ++ ++ , ++ ); ++ }); ++ return renderer; ++} ++ ++describe("ParallelChildren", () => { ++ test("renders branch status and stage links without checkout metadata", () => { ++ const renderer = renderParallel([ ++ event({ ++ event: "parallel.started", ++ properties: { branch_count: 2 }, ++ }), ++ event({ ++ seq: 2, ++ event: "parallel.completed", ++ properties: { ++ duration_ms: 12000, ++ success_count: 1, ++ failure_count: 1, ++ results: [ ++ { id: "branch-a", status: "succeeded", context_updates: {} }, ++ { id: "branch-b", status: "failed", context_updates: {} }, ++ ], ++ }, ++ }), ++ ]); ++ ++ const rendered = JSON.stringify(renderer.toJSON()); ++ expect(rendered).toContain("branch-a"); ++ expect(rendered).toContain("Succeeded"); ++ expect(rendered).toContain("branch-b"); ++ expect(rendered).toContain("Failed"); ++ const hrefs = renderer.root.findAllByType("a").map((link) => link.props.href); ++ expect(hrefs).toEqual([ ++ "/runs/run-1/stages/branch-a@1", ++ "/runs/run-1/stages/branch-b@1", ++ ]); ++ }); ++}); +diff --git a/apps/fabro-web/app/components/stage-renderers/parallel-children.tsx b/apps/fabro-web/app/components/stage-renderers/parallel-children.tsx +index 0b9e4b951..30cb6169c 100644 +--- a/apps/fabro-web/app/components/stage-renderers/parallel-children.tsx ++++ b/apps/fabro-web/app/components/stage-renderers/parallel-children.tsx +@@ -1,34 +1,19 @@ + import { useMemo } from "react"; + import { Link } from "react-router"; + import { ArrowTopRightOnSquareIcon } from "@heroicons/react/20/solid"; ++import { StageState } from "@qltysh/fabro-api-client"; + import type { EventEnvelope } from "@qltysh/fabro-api-client"; + + import type { Stage } from "../stage-sidebar"; +-import { CopyButton } from "../ui"; ++import { stageStatusLabel, stageStatusTone } from "../../lib/stage-sidebar"; + import { formatDurationMs } from "../../lib/format"; + import { StageMetaBar } from "./meta-bar"; +-import { parseParallelOverview, type ParallelBranchResult } from "./helpers"; ++import { parseParallelOverview } from "./helpers"; + +-const RESULT_STATUS_TONE: Record = { +- succeeded: "bg-mint/15 text-mint", +- partially_succeeded: "bg-amber/15 text-amber", +- failed: "bg-coral/15 text-coral", +- cancelled: "bg-overlay-strong text-fg-muted", +- skipped: "bg-overlay-strong text-fg-muted", +-}; +- +-function statusTone(status: string): string { +- return RESULT_STATUS_TONE[status] ?? "bg-overlay-strong text-fg-muted"; +-} +- +-function statusLabel(status: string): string { +- if (!status) return "—"; +- return status.charAt(0).toUpperCase() + status.slice(1).replace(/_/g, " "); +-} +- +-function shortSha(sha: string | null): string | null { +- if (!sha) return null; +- return sha.length > 8 ? sha.slice(0, 8) : sha; ++/** Branch row view state: completed outcomes plus a synthesized in-flight row. */ ++interface BranchRow { ++ id: string; ++ status: StageState; + } + + function StatItem({ +@@ -56,27 +41,21 @@ function ChildRow({ + result, + stageHref, + }: { +- result: ParallelBranchResult; ++ result: BranchRow; + stageHref: string | null; + }) { +- const sha = shortSha(result.headSha); +- const tone = statusTone(result.status); ++ const tone = stageStatusTone(result.status); + + const inner = ( + <> + +- {statusLabel(result.status)} ++ {stageStatusLabel(result.status)} + + + {result.id} + +- {sha && ( +- +- {sha} +- +- )} + {stageHref && ( + {inner} + )} +- {result.headSha && ( +- +- )} + + ); + } +@@ -128,25 +104,18 @@ export function ParallelChildren({ + return new Map(Array.from(latest.entries()).map(([nodeId, s]) => [nodeId, s.id])); + }, [allStages]); + +- const items = overview.results.length > 0 ++ const items: BranchRow[] = overview.results.length > 0 + ? overview.results + : overview.branchCount && overview.branchCount > 0 + ? Array.from({ length: overview.branchCount }, (_, i) => ({ + id: `branch ${i + 1}`, +- status: "running", +- headSha: null, ++ status: StageState.RUNNING, + })) + : []; + + return ( +
+- +- {overview.joinPolicy ? ( +- +- {overview.joinPolicy.replace(/_/g, " ")} +- +- ) : null} +- ++ + +
+ +diff --git a/apps/fabro-web/app/lib/test-utils.tsx b/apps/fabro-web/app/lib/test-utils.tsx +index f881952de..995245aa5 100644 +--- a/apps/fabro-web/app/lib/test-utils.tsx ++++ b/apps/fabro-web/app/lib/test-utils.tsx +@@ -1,4 +1,5 @@ + import { createElement, type ReactNode } from "react"; ++import type { EventEnvelope } from "@qltysh/fabro-api-client"; + import TestRenderer, { act } from "react-test-renderer"; + + const IS_REACT_ACT_ENV = "IS_REACT_ACT_ENVIRONMENT" as const; +@@ -39,6 +40,21 @@ export function setupReactTestEnv(): () => void { + }; + } + ++/** Build an event envelope fixture; override any field via `partial`. */ ++export function makeEventEnvelope( ++ seq: number, ++ partial: Partial, ++): EventEnvelope { ++ return { ++ seq, ++ id: `evt-${seq}`, ++ ts: `2026-04-09T12:00:0${seq}Z`, ++ run_id: "run-1", ++ event: "stage.prompt", ++ ...partial, ++ } as EventEnvelope; ++} ++ + export function renderHook( + hook: () => T, + options: { wrapper: React.ComponentType<{ children: ReactNode }> }, +diff --git a/apps/fabro-web/app/routes/run-stages.tsx b/apps/fabro-web/app/routes/run-stages.tsx +index aa0ae43af..deaf26d44 100644 +--- a/apps/fabro-web/app/routes/run-stages.tsx ++++ b/apps/fabro-web/app/routes/run-stages.tsx +@@ -42,10 +42,7 @@ import { + } from "../components/ui"; + import { ConditionalDecision } from "../components/stage-renderers/conditional-decision"; + import { FanInResults } from "../components/stage-renderers/fan-in-results"; +-import { +- extractStageContext, +- extractStageNotes, +-} from "../components/stage-renderers/helpers"; ++import { extractStageContext } from "../components/stage-renderers/helpers"; + import { HumanQA } from "../components/stage-renderers/human-qa"; + import { ParallelChildren } from "../components/stage-renderers/parallel-children"; + import { +@@ -1572,11 +1569,7 @@ function StageActivityBody({ + allStages={stages} + /> + ) : renderer === "fan_in" ? ( +- ++ + ) : renderer === "wait" ? ( + + ) : ( +diff --git a/docs/internal/demo/06-parallel.fabro b/docs/internal/demo/06-parallel.fabro +index 7f7191a3c..f4714e600 100644 +--- a/docs/internal/demo/06-parallel.fabro ++++ b/docs/internal/demo/06-parallel.fabro +@@ -5,7 +5,7 @@ digraph Parallel { + start [shape=Mdiamond, label="Start"] + exit [shape=Msquare, label="Exit"] + +- fork [label="Fork Analysis", shape=component, join_policy="wait_all"] ++ fork [label="Fork Analysis", shape=component] + + security [label="Security Audit", prompt="Examine the codebase for security concerns: hardcoded secrets, injection risks, unsafe dependencies. List findings as bullet points.", shape=tab, reasoning_effort="low"] + architecture [label="Architecture Review", prompt="Assess the codebase architecture: separation of concerns, dependency structure, modularity. List findings as bullet points.", shape=tab, reasoning_effort="low"] +diff --git a/docs/internal/demo/11-ensemble.fabro b/docs/internal/demo/11-ensemble.fabro +index 3a8655cde..87aadf36c 100644 +--- a/docs/internal/demo/11-ensemble.fabro ++++ b/docs/internal/demo/11-ensemble.fabro +@@ -14,7 +14,7 @@ digraph Ensemble { + start [shape=Mdiamond, label="Start"] + exit [shape=Msquare, label="Exit"] + +- fork [label="Fan Out", shape=component, join_policy="wait_all"] ++ fork [label="Fan Out", shape=component] + + opus [label="Opus", prompt="Analyze the goal. Provide your independent assessment, recommendations, and any code or prose needed. Be thorough.", shape=tab] + gemini [label="Gemini", prompt="Analyze the goal. Provide your independent assessment, recommendations, and any code or prose needed. Be thorough.", shape=tab] +diff --git a/docs/internal/events.md b/docs/internal/events.md +index 4af5ab491..53d10e1e4 100644 +--- a/docs/internal/events.md ++++ b/docs/internal/events.md +@@ -523,16 +523,16 @@ Emitted when a parallel node begins executing branches. + "id": "...", "ts": "...", "run_id": "...", + "event": "parallel.started", + "properties": { +- "branch_count": 3, +- "join_policy": "all" ++ "visit": 1, ++ "branch_count": 3 + } + } + ``` + + | Property | Type | Description | + |----------|------|-------------| ++| `visit` | number | Visit number for this parallel stage | + | `branch_count` | number | Number of parallel branches | +-| `join_policy` | string | Join policy | + + ### `parallel.branch.started` + +@@ -587,18 +587,33 @@ Emitted when all parallel branches have finished. + "id": "...", "ts": "...", "run_id": "...", + "event": "parallel.completed", + "properties": { ++ "visit": 1, + "duration_ms": 12000, + "success_count": 2, +- "failure_count": 1 ++ "failure_count": 1, ++ "results": [ ++ { ++ "id": "branch_a", ++ "status": "succeeded", ++ "context_updates": {"response.branch_a": "review complete"} ++ }, ++ { ++ "id": "branch_b", ++ "status": "failed", ++ "context_updates": {"command.output": "validation failed"} ++ } ++ ] + } + } + ``` + + | Property | Type | Description | + |----------|------|-------------| ++| `visit` | number | Visit number for this parallel stage | + | `duration_ms` | number | Total parallel duration | + | `success_count` | number | Branches that succeeded | + | `failure_count` | number | Branches that failed | ++| `results` | array | Ordered typed branch results with `id`, `status`, and isolated `context_updates` | + + --- + +@@ -760,58 +775,6 @@ Note: `node_id` is optional — may be absent for non-stage commits. + | `branch` | string | Branch name | + | `success` | boolean | Whether push succeeded | + +-### `git.branch` +- +-```json +-{ +- "id": "...", "ts": "...", "run_id": "...", +- "event": "git.branch", +- "properties": { +- "branch": "fabro/run-01JQXYZ", +- "sha": "abc123..." +- } +-} +-``` +- +-| Property | Type | Description | +-|----------|------|-------------| +-| `branch` | string | Branch name | +-| `sha` | string | Branch HEAD SHA | +- +-### `git.worktree.added` +- +-```json +-{ +- "id": "...", "ts": "...", "run_id": "...", +- "event": "git.worktree.added", +- "properties": { +- "path": "/tmp/fabro-worktrees/...", +- "branch": "fabro/run-01JQXYZ" +- } +-} +-``` +- +-| Property | Type | Description | +-|----------|------|-------------| +-| `path` | string | Worktree directory path | +-| `branch` | string | Branch name | +- +-### `git.worktree.removed` +- +-```json +-{ +- "id": "...", "ts": "...", "run_id": "...", +- "event": "git.worktree.removed", +- "properties": { +- "path": "/tmp/fabro-worktrees/..." +- } +-} +-``` +- +-| Property | Type | Description | +-|----------|------|-------------| +-| `path` | string | Worktree directory path | +- + ### `git.fetch` + + ```json +diff --git a/docs/internal/parallel-strategy.md b/docs/internal/parallel-strategy.md +index 351764ade..61a019326 100644 +--- a/docs/internal/parallel-strategy.md ++++ b/docs/internal/parallel-strategy.md +@@ -1,318 +1,153 @@ +-# Parallel fan-out / fan-in strategy +- +-Status: proposed (not yet implemented). Line numbers reference the tree at the +-time of writing and will drift. +- +-This document specifies the intended product behavior of parallel fan-out +-(`shape=component`) and fan-in (`shape=tripleoctagon`). Appendix A catalogs +-pre-existing bugs this design resolves. Appendix B lists behavior changes +-relative to today's implementation. +- +-## 1. Introduction: goals and current weaknesses +- +-The goal of this design is a parallel execution model that is **simple**, +-**coherent**, and **correct**: +- +-- **Simple** — a user should be able to predict what a fan-out/fan-in does +- from the graph alone. One mental model ("branches produce candidates; fan-in +- picks a commit; downstream sees all responses"), no new syntax for +- synthesis, no incantations (special fidelity settings, escape-hatch +- attributes) to make the flagship patterns work. +-- **Coherent** — the same rules apply regardless of node type or channel. +- What holds for a sequential command node's output should hold for a branch +- command node's output; the workspace (git) facet and the text (context) +- facet should follow parallel logic; selection should live in exactly one +- place. +-- **Correct** — the engine must do what the graph, the docs, and the recorded +- run state say it does. Nodes drawn in the graph must run; documented outputs +- must exist; a judge asked to pick the best candidate must be shown the +- candidates. +- +-Today's implementation misses all three. Issue #490 is the visible symptom: +-a synthesis node after fan-in never sees branch responses — only +-`{id, status, head_sha}` metadata — while two tutorials promise the opposite. +-Investigation showed a broader incoherence: +- +-- **Inherited spec gap.** The Attractor spec (fabro's ancestor) deliberately +- isolates branch context and never defines a channel for branch output text. +- Its fan-in pseudocode judges candidates it cannot see (`llm_evaluate` +- receives only statuses) and sorts by a `score` field nothing sets. Fabro +- inherited this gap faithfully. +-- **Missing compensation.** Kilroy (a sibling Attractor implementation) +- compensates with a file/git handoff: the post-merge node's prompt is +- injected with each branch's `worktree_dir`, `logs_root`, and `head_sha` plus +- instructions to read/merge them. Fabro has no equivalent, but its tutorials +- promise kilroy-like behavior ("the synth node receives all four perspectives +- in its preamble"). +-- **Accidental semantics.** Several adjacent behaviors are unprincipled +- accidents rather than decisions: branches execute a single node and silently +- skip chained nodes; two handlers fast-forward to two different definitions +- of "winner"; branch nodes run with a stale preamble built for the fan-out +- node; selection is vacuous in both modes; nested fan-outs silently lose git +- isolation. Appendix A catalogs these. +- +-## 2. Core model: candidates +- +-A parallel branch is an isolated unit of execution that produces a +-**candidate**. A candidate has exactly three facets: +- +-| Facet | Content | Carried by | +-|---|---|---| +-| Commit | Workspace state produced by the branch | Per-branch git commit (`head_sha`) | +-| Response | Text output (LLM response, command output) | Branch stage records + context keys | +-| Verdict | Terminal status plus optional numeric `score` | `parallel.results` entries | +- +-Fan-out produces N candidates in isolation. Fan-in selects **one commit** to +-continue on. Downstream nodes (synthesis) get **all responses**. Selection and +-synthesis are distinct concerns: selection needs a node (`tripleoctagon`); +-synthesis is any ordinary downstream node, because responses propagate. +- +-The post-fan-in contract, in one sentence: **after fan-in, the run looks as if +-every branch had run sequentially, and the winner ran last.** +- +-## 3. Topology: branches are subgraphs +- +-Each outgoing edge of a fan-out node starts a branch. A branch executes as a +-subgraph walk: the engine traverses nodes and edges from the branch entry node +-until it reaches the join node (the fan-in). Multi-node chains +-(`fork -> plan_a; plan_a -> impl_a; impl_a -> merge`) run every node in the +-chain. This matches the Attractor spec (`execute_subgraph`) and kilroy +-(`runSubgraphUntil`); today fabro executes exactly one node per branch and +-silently skips the rest (Appendix A.2). +- +-Structural validation (lint): +- +-- Every path from a branch entry must converge on the run's join node. +- A branch path that escapes the join (reaches exit, or a node outside the +- fan-out region) is a validation error. +-- A nested `component` node inside a branch is rejected until +- worktree-from-worktree isolation is implemented (today it silently runs with +- no git isolation; Appendix A.6). +-- `fidelity="full"` on a fan-in's outgoing edge gets a lint warning: branches +- run on different threads, so full fidelity can never carry branch outputs +- (it drops the preamble entirely). +- +-## 4. Node types in branches +- +-No type-based restrictions. Restriction is structural (§3), not by allowlist: +- +-- **Agent / prompt nodes** — the primary case. +-- **Command / script / tool nodes** — first-class. Deterministic fan-out +- (test matrices, benchmark bake-offs across worktrees) is a supported pattern +- with no LLM anywhere: branches emit `score` via status fields, heuristic +- selection picks the winner by measurement. +-- **Conditionals** — meaningful under subgraph branches: they route *within* +- the branch. +-- **Human gates** — allowed; each branch may pause independently. +-- **Nested parallel** — rejected by lint until isolation composes (§3). +- +-## 5. Git isolation +- +-Unchanged mechanics, with ownership fixed: +- +-1. Before fan-out, checkpoint the sandbox to produce `base_sha`. +-2. Each branch gets a worktree on a branch ref +- (`fabro/run/parallel///pass/`), rooted at `base_sha`. +- The branch's `internal.work_dir` points at the worktree. +-3. After a branch completes, `git add -A` + commit (`--allow-empty`) yields the +- candidate's `head_sha`. +-4. Worktrees are removed after the join. Loser branch refs are **kept** so +- downstream nodes and humans can `git show`/`git diff` any candidate. +-5. **Fan-in exclusively owns the fast-forward.** The parallel handler performs +- no merge. After selection, fan-in fast-forwards the primary workspace to the +- winner's `head_sha`. (Today both handlers fast-forward, to potentially +- different winners; Appendix A.3.) +- +-Degradation without git (no repo, or git isolation disabled): branches share +-the primary sandbox with no workspace isolation, `head_sha` is absent from +-candidates, and fan-in performs no merge. Response and verdict facets work +-unchanged — prompt-only ensembles do not require git. +- +-## 6. Execution and stage recording +- +-Branch nodes execute as **real stages**, recorded through the normal +-`ExecutionState::record` path and namespaced under the fan-out +-(e.g. stage `a@1` within `fork@1`). Consequences (all fixes to current +-behavior): +- +-- Branch prompts/responses appear in events, `fabro dump`, and the web UI as +- ordinary stages. +-- Each branch node gets a **freshly built preamble** for its own position, via +- the standard lifecycle, instead of reusing the fan-out node's stale preamble. +-- Branch stages participate in the standard retry policy per node. +- +-## 7. Context merge-back at fan-in +- +-When fan-in completes, branch context updates are applied to the parent +-context with a collision rule: +- +-- **Per-node keys** (`response.`, structured-output fields +- namespaced by node) apply for **all** branches. Branch node IDs are unique, +- so no collisions. +-- **Singleton keys** (`last_stage`, `last_response`, `command.output`, +- un-namespaced status fields) are taken from the **winner only**. +-- Failed branches' `response.` values are still applied (a synthesis node +- analyzing disagreement wants to see the failure text). Their singletons are +- never applied. +- +-Fan-in additionally writes (as today): +- +-- `parallel.results` — one entry per candidate: `{id, status, head_sha?, +- score?}`. +-- `parallel.branch_count`, `parallel.fan_in.best_id`, +- `parallel.fan_in.best_outcome`, `parallel.fan_in.best_head_sha`. +- +-No file is materialized into the run workspace. `parallel.results` reaches LLM +-consumers through the preamble's context section, and agents can `git show` +-any candidate via its `head_sha`. (The current docs claim +-`parallel_results.json` is available to downstream nodes; that claim is false +-today and should be corrected rather than implemented — see open question 4 +-for the one consumer this leaves unserved.) +- +-## 8. Selection +- +-Fan-in selects the winning candidate. Two modes, as today, but with real +-signal: +- +-- **Heuristic** (no prompt on the fan-in node): rank by status +- (succeeded < partially_succeeded < failed), then `score` descending, then +- lexical id. `score` becomes settable: branches emit it via structured-output +- / status fields, which now survive into `parallel.results` (§7). +-- **LLM judge** (fan-in node has a `prompt` and a backend is configured): the +- judge prompt includes, per candidate: id, status, score, a bounded response +- excerpt, and `git diff --stat` vs. `base_sha` when git isolation is active. +- Today the judge sees only `{id, status, head_sha}` and cannot possibly +- discriminate (Appendix A.4). +- +-Selection determines the commit facet only. It does not suppress loser +-responses (§7) or loser refs (§5). +- +-## 9. Downstream visibility (preamble) +- +-Prompt templates render once at manifest build time with `{goal, inputs}` +-only; the preamble is the sole channel for runtime context into a +-fresh-session node. Therefore: +- +-- At **`compact`** (default) fidelity, branch stage summaries render their +- responses **inline**, bounded, with a `See: ` reference when +- truncated — the same treatment `command.output` already receives at compact. +- Rationale: post-fan-in branch responses are unrecoverable through any +- fidelity setting (different threads), exactly like command output. +-- The per-branch response budget is larger than command output's 25-line tail +- (ensemble analyses front-load their substance; a small tail amputates it). +- Exact budget TBD at implementation; must remain bounded so N long branches +- cannot blow the downstream context. Agent-type synthesis nodes can read the +- full text from the blob/artifact reference. +-- `summary:high` renders the same with its larger budget. `truncate` carries +- goal only (explicit opt-out). `full` remains the degenerate case and lints +- (§3). +- +-## 10. Join policies +- +-- `wait_all` (default): all branches run to completion; join proceeds when all +- are terminal. Succeeds if no branch failed, else partially succeeds (fan-in +- fails only when *all* candidates failed). +-- `first_success`: join proceeds at the first successful branch. Remaining +- branches are cancelled; cancelled branches record a terminal cancelled stage +- (their partial responses are not merged back). The sole successful branch is +- the winner. +-- `k_of_n` / `quorum` (kilroy has them): deliberately **not** added now. The +- surface stays minimal until a concrete need appears. +- +-## Open questions +- +-1. Implementation phasing: subgraph branches (§3) are the largest lift. +- Response propagation (§6–§9) fixes #490 and both tutorials on its own and +- can ship first. +-2. `first_success` cancellation semantics for in-flight agent sessions +- (graceful stop vs. abort; what the cancelled stage records). +-3. Exact preamble budget per branch response (§9). +-4. Context access for deterministic post-fan-in consumers. Command/script +- nodes have no channel to context (no preamble, no template rendering in +- `script`), so a deterministic aggregator after fan-in cannot learn +- candidate `head_sha`s. Candidate mechanisms: a results file under a +- checkpoint-excluded workspace path, or an env var (e.g. +- `FABRO_PARALLEL_RESULTS`) pointing at a file outside the workspace. A bare +- workspace file is ruled out: checkpoint commits `git add -A`, so it would +- leak into run history and PRs. Design alongside the deterministic fan-out +- pattern (§4). +- +---- +- +-## Appendix A: pre-existing bugs +- +-Cataloged against the current tree; line numbers will drift. +- +-1. **Branch outputs dropped (#490).** The fan-out task reads only +- `outcome.status` and `head_sha` from each branch; branch `context_updates` +- (including `response.`) are discarded with the forked context +- (`fabro-workflow/src/handler/parallel.rs:389-397,465-470`). No channel +- carries branch text to downstream nodes. Confirmed by live repro on +- fabro-testing (run `01KX148ZAMMJRAADHK1HBF7PC3`, server 0.287.0-nightly.0). +-2. **Chained branch nodes silently skipped.** Branches execute exactly one +- node; the engine then jumps to the join (`parallel.rs:388-397,612,628`; +- `fabro-core/src/executor.rs:421`). In `fork -> a; a -> a2; a2 -> merge`, +- `a2` never runs and nothing warns. +-3. **Double fast-forward with two different winner definitions.** The parallel +- handler fast-forwards the *lexically first* successful branch +- (`parallel.rs:511-538`); fan-in then fast-forwards *its* selected winner +- (`fan_in.rs:120-131`). If selection ever picks a non-lexical-first branch, +- the second `--ff-only` merge cannot succeed (sibling commits diverge). +- Masked today only because selection is vacuous (A.4). +-4. **Selection is vacuous.** Heuristic tie-breaks on a `score` field nothing +- can set (scores would arrive via branch context updates, which are dropped +- per A.1). The LLM judge prompt is `serde_json::to_string_pretty` of +- `parallel.results` — id/status/head_sha only (`fan_in.rs:247-250`). Both +- modes reduce to "first successful branch, alphabetically." +-5. **Branch preambles are stale.** Branches run via `dispatch_handler`, +- bypassing the lifecycle's per-node preamble rebuild; each branch node +- inherits `current.preamble` as computed for the fan-out node itself. +-6. **Nested parallel silently loses git isolation.** Branch `EngineServices` +- are built with `git_state: RwLock::new(None)` (`parallel.rs:380`), so a +- `component` node inside a branch runs its own branches with no worktrees +- and no warning. +-7. **Docs contradict the engine.** `tutorials/ensemble.mdx:85` and +- `tutorials/parallel-review.mdx:81` claim the post-merge node receives all +- branch perspectives in its preamble (false, per A.1). +- `workflows/stages-and-nodes.mdx:195` claims merged results are available to +- downstream nodes as `parallel_results.json` (the file exists only under +- `stages/@1/` in dumps, not in any node's working directory). +-8. **`fidelity="full"` across a fan-in is a trap.** It drops the preamble +- (metadata included) and cannot attach to any branch thread; raising +- fidelity strictly reduces what the downstream node sees. No lint warns. +- +-## Appendix B: behavior changes vs. today +- +-Changes a user could observe if this spec is implemented as written. +- +-1. **Chained branch nodes execute.** Graphs that (unknowingly) relied on +- single-node branch semantics will now run the full chain (fixes A.2; may +- lengthen existing runs). +-2. **New validation errors.** Branch paths that don't converge on the join, +- and nested `component` nodes inside branches, become lint failures for +- graphs that previously ran (with wrong or silently degraded semantics). +-3. **Fan-in owns the fast-forward.** The workspace after fan-in may land on a +- different commit than today whenever selection (scores, LLM judge) +- disagrees with lexical-first order. The parallel handler no longer merges. +-4. **Post-fan-in context is richer.** `response.` for every branch, +- winner-sourced singletons (`last_stage`, `last_response`, +- `command.output`), and `score` in `parallel.results`. Today those +- singletons retain their pre-fork values; workflows with edge conditions +- over them could route differently. +-5. **Preambles after fan-in grow.** Branch responses render inline at +- `compact` fidelity (bounded). Downstream nodes see more tokens per run; +- snapshot tests over preambles will churn. +-6. **Branch executions become visible stages.** Events, dumps, the web UI, and +- the stage list gain per-branch stages (`a@1` under `fork@1`). Consumers of +- `events.jsonl` / the API will see new stage records. +-7. **`parallel_results.json` docs claim is corrected, not implemented.** +- `workflows/stages-and-nodes.mdx:195` is updated to describe the real +- channels (context key + preamble); no file appears in the workspace +- (open question 4 covers deterministic consumers). +-8. **Loser branch refs are documented as retained** and become part of the +- product contract instead of an accident of not deleting them. +-9. **`first_success` cancels losers explicitly** and records cancelled stages; +- today's exact cancellation behavior is unspecified. +-10. **LLM judge prompts change shape.** Fan-in nodes with prompts now send +- candidate excerpts and diff stats to the judge — more tokens, different +- (better) selections than today's id-only prompt. ++# Shared-checkout parallel execution strategy ++ ++Status: implemented. ++ ++This document defines Fabro's parallel fan-out (`shape=component`) and fan-in ++(`shape=tripleoctagon`) behavior. ++ ++## 1. Execution model ++ ++A parallel node dispatches one branch for each outgoing edge. A branch executes ++the single target node on that edge; parallel branches are not subgraph walks. ++Every branch: ++ ++- receives an independent fork of the parent workflow context; ++- receives the same `Arc` as the parent run; ++- inherits the same sandbox working directory and `internal.work_dir`; ++- runs through the normal handler dispatch path, including dry-run behavior; ++- retains its branch identity, lifecycle events, and hook scope. ++ ++Branches execute concurrently. `max_parallel` limits the number that may run at ++once and defaults to 4. The parallel node always waits for every branch task, ++even when a branch fails or run cancellation begins. There is no early-success ++join mode. ++ ++The parent context is not used as shared mutable branch state. A branch can ++change its context fork without exposing those changes as top-level values to ++other branches or to the parent. ++ ++## 2. Shared checkout ++ ++All branches use the run's existing sandbox and checkout. Parallel execution ++creates no branch-specific: ++ ++- Git refs or branches; ++- worktrees; ++- base checkpoints; ++- commits; ++- cleanup operations; ++- merges or fast-forwards. ++ ++Normal run-level checkpointing still occurs after the parallel node. Any files ++left in the shared checkout by its branches are captured together by that ++checkpoint. ++ ++Read-only parallel work is best effort: an agent or command can still write if ++its configured capabilities permit it. Concurrent writes are allowed and are ++entirely user-managed. Fabro does not lock files, enforce read-only access, ++detect overlapping edits, or warn about races. Workflows that write in parallel ++should coordinate externally or assign disjoint paths. ++ ++## 3. Branch results ++ ++The shared result type is: ++ ++```rust ++ParallelBranchResult { ++ id: String, ++ status: String, ++ context_updates: BTreeMap, ++} ++``` ++ ++The parallel handler stores one result per outgoing edge in ++`parallel.results`. Results preserve outgoing-edge order, independent of branch ++completion order. `parallel.branch_count` stores the number of dispatched ++branches. ++ ++`context_updates` includes changes made in the branch context and updates ++returned by the branch outcome. This applies to successful and failed branches, ++including structured values, `response.`, and `command.output`. ++Engine-internal context keys are omitted. A task failure or panic cannot provide ++updates that were never returned, but its result still preserves the original ++branch ID and index. ++ ++Branch updates remain nested in their result. Fabro never merges them into the ++parent's top-level context, so branches cannot collide through context keys. ++ ++The parallel stage outcome is: ++ ++- `succeeded` when every branch succeeds; ++- `failed` when every branch fails; ++- `partially_succeeded` for mixed outcomes, partial outcomes, and zero branches. ++ ++## 4. Artifacts and downstream context ++ ++Large context values use the normal artifact store. Offloading recursively ++replaces oversized leaf values while retaining the object and array structure ++of `parallel.results`. ++ ++When Fabro constructs execution or prompt context, it resolves nested textual ++blob references under `response.*` and `command.output`, including those keys ++inside a branch result's `context_updates`. This lets a prompted fan-in inspect ++complete branch text without flattening branch state into the parent context. ++ ++`parallel.results` is runtime context. Fabro does not materialize a ++`parallel_results.json` file in the workspace. Diagnostic run dumps may export ++stage projection data, but that export is not a workflow handoff mechanism and ++is not visible as a checkout file to downstream nodes. ++ ++## 5. Fan-in ++ ++Fan-in is an explicit join node. ++ ++A fan-in node without a nonblank prompt validates that `parallel.results` ++exists and deserializes as typed branch results. It then succeeds with a joined ++branches note. It is a no-op barrier: it does not alter context or workspace ++state. ++ ++A fan-in node with a prompt delegates to the standard prompt handler. It sees ++the aggregated runtime context in the normal prompt preamble and records the ++normal prompt-stage outputs: ++ ++- `response.`; ++- `last_response`; ++- model usage and timing; ++- prompt and response events. ++ ++A prompted fan-in synthesizes results. It does not rank branches, select a ++winner, restore files, or choose workspace state. ++ ++## 6. Events and projections ++ ++Parallel execution emits: ++ ++- `parallel.started` with `visit` and `branch_count`; ++- `parallel.branch.started` with stable branch identity and index; ++- `parallel.branch.completed` with index, duration, and status; ++- `parallel.completed` with counts and the ordered typed result array. ++ ++Every branch task emits one terminal branch completion event, including handler ++failure, cancellation before semaphore acquisition, panic, or join failure. ++The final typed array is also projected into ++`StageProjection.parallel_results`. ++ ++## 7. Cancellation ++ ++Semaphore acquisition observes the run cancellation token. Branches waiting for ++a permit can terminate as cancelled rather than waiting indefinitely. Branches ++already executing continue through their handler's cooperative cancellation ++path. The parallel handler joins every task before returning cancellation to the ++run executor. ++ ++Cancellation does not trigger branch Git cleanup because no branch Git state is ++created. ++ ++## 8. Product constraints ++ ++- Branches remain single-node executions. ++- `max_parallel` remains supported. ++- Results are deterministic in outgoing-edge order. ++- There is no branch-selection score, SHA, model-usage mode, notice, or UI. ++- Server-owned independent checkout/worktree behavior is separate and remains ++ unchanged. +diff --git a/docs/public/api-reference/fabro-api.yaml b/docs/public/api-reference/fabro-api.yaml +index d6e384405..a39a4c414 100644 +--- a/docs/public/api-reference/fabro-api.yaml ++++ b/docs/public/api-reference/fabro-api.yaml +@@ -10411,6 +10411,22 @@ components: + items: + $ref: "#/components/schemas/StageContextWindowWarning" + ++ ParallelBranchResult: ++ description: The outcome and isolated context updates from one parallel branch. ++ type: object ++ required: ++ - id ++ - status ++ - context_updates ++ properties: ++ id: ++ type: string ++ status: ++ $ref: "#/components/schemas/StageOutcome" ++ context_updates: ++ type: object ++ additionalProperties: true ++ + StageProjection: + description: Observable projection data for one workflow stage execution. + type: object +@@ -10447,8 +10463,8 @@ components: + parallel_results: + type: ["array", "null"] + items: +- type: object +- description: Per-branch result objects produced by a parallel stage. ++ $ref: "#/components/schemas/ParallelBranchResult" ++ description: Ordered per-branch results produced by a parallel stage. + output: + type: ["string", "null"] + output_bytes: +diff --git a/docs/public/examples/clone-substack.mdx b/docs/public/examples/clone-substack.mdx +index cc11157f7..3529436a2 100644 +--- a/docs/public/examples/clone-substack.mdx ++++ b/docs/public/examples/clone-substack.mdx +@@ -153,8 +153,8 @@ Write to .workflow/plan_b.md." + label="Debate & Consolidate", + prompt="Synthesize the two implementation plans into a single best-of-breed \ + final plan.\n\n\ +-Read branch outputs via parallel_results.json. If parallel_results.json is missing, \ +-fall back to reading .workflow/plan_a.md and .workflow/plan_b.md.\n\n\ ++Review every branch result in parallel.results from the prompt context, then read \ ++.workflow/plan_a.md and .workflow/plan_b.md from the shared checkout.\n\n\ + If .workflow/postmortem_latest.md exists, read it FIRST. The postmortem contains \ + root-cause analysis and concrete fixes from the previous iteration. The final plan \ + MUST be adjusted to address every issue identified in the postmortem — add new \ +@@ -481,8 +481,8 @@ Write to .workflow/review_b.md." + goal_gate=true, + retry_target="postmortem", + prompt="Synthesize the two reviews into a consensus verdict.\n\n\ +-Read branch outputs via parallel_results.json. If parallel_results.json is \ +-missing, fall back to reading .workflow/review_a.md and .workflow/review_b.md.\n\n\ ++Review every branch result in parallel.results from the prompt context, then read \ ++.workflow/review_a.md and .workflow/review_b.md from the shared checkout.\n\n\ + Read .workflow/definition_of_done.md for acceptance criteria reference.\n\n\ + Consensus rules:\n\ + - Both APPROVED with no critical gaps: the implementation passes\n\ +@@ -513,7 +513,7 @@ Read (if they exist):\n\ + - .workflow/test-evidence/latest/manifest.json\n\ + - Evidence files referenced by manifest entries for failed or suspicious IT \ + scenarios\n\ +-- Branch review outputs via parallel_results.json (if available)\n\n\ ++- Parallel branch review status and context updates from parallel.results (if available)\n\n\ + Output to .workflow/postmortem_latest.md (overwrite previous):\n\ + - Root causes of failure\n\ + - What works and must be preserved\n\ +diff --git a/docs/public/execution/context.mdx b/docs/public/execution/context.mdx +index c67012b3c..25f6e536d 100644 +--- a/docs/public/execution/context.mdx ++++ b/docs/public/execution/context.mdx +@@ -57,13 +57,14 @@ Agents can also emit arbitrary context updates by including a JSON object with a + | `human.gate..answer` | The answer text for a specific human gate node | + | `human.gate..label` | The selected label for a specific human gate node, when applicable | + +-### Parallel merge (fan-in) ++### Parallel fan-out and fan-in + + | Key | Value | + |---|---| +-| `parallel.fan_in.best_id` | Node ID of the best-performing branch | +-| `parallel.fan_in.best_outcome` | Status of the best branch | +-| `parallel.fan_in.best_head_sha` | Git SHA from the best branch (if applicable) | ++| `parallel.results` | Ordered branch results. Each entry contains `id`, `status`, and the branch's isolated `context_updates`. | ++| `parallel.branch_count` | Number of outgoing branches dispatched by the parallel node. | ++ ++Branch updates remain nested inside `parallel.results`; they are not merged into top-level context. Prompted fan-in nodes can synthesize the complete result array, while promptless fan-in nodes act as barriers. + + ### Engine-managed keys + +diff --git a/docs/public/execution/outcomes.mdx b/docs/public/execution/outcomes.mdx +index 5ddbdc774..4ebf04a96 100644 +--- a/docs/public/execution/outcomes.mdx ++++ b/docs/public/execution/outcomes.mdx +@@ -26,7 +26,7 @@ Each node type has its own rules for which outcomes it can return: + |---|---|---| + | **Command** | `succeeded`, `failed` | `succeeded` when exit code is 0; `failed` otherwise | + | **Agent / Prompt** | `succeeded`, `failed`, `partially_succeeded`, `skipped` | Defaults to `succeeded`. The LLM can set any outcome via a [routing directive](/agents/outputs#routing-directives) JSON object in its response. Backend errors request retry when retryable or finish as `failed`. | +-| **Parallel** | `succeeded`, `partially_succeeded`, `failed` | Depends on the `join_policy`. `wait_all`: `succeeded` if no failures, `partially_succeeded` if some branches failed. `first_success`: `succeeded` if threshold met, else `failed`. | ++| **Parallel** | `succeeded`, `partially_succeeded`, `failed` | Waits for every branch. `succeeded` when all branches succeed, `failed` when all branches fail, and `partially_succeeded` for mixed, partial, or zero-branch results. | + | **Human** | `succeeded` | Always succeeds — the user's selection becomes a routing signal via `preferred_label` | + | **Conditional** | `succeeded` | Always succeeds — routing is handled by the engine's edge selection | + | **Start / Exit / Wait** | `succeeded` | Always succeed | +diff --git a/docs/public/reference/dot-language.mdx b/docs/public/reference/dot-language.mdx +index f01fbf2ed..678d5ac23 100644 +--- a/docs/public/reference/dot-language.mdx ++++ b/docs/public/reference/dot-language.mdx +@@ -249,8 +249,7 @@ audit [ + + | Attribute | Type | Description | + |---|---|---| +-| `join_policy` | String | When the merge can proceed: `wait_all` (default), `first_success` | +-| `max_parallel` | Integer | Maximum concurrent branches (default: 4) | ++| `max_parallel` | Integer | Maximum concurrent branches (default: 4). The node always waits for every branch. | + + ### Wait nodes + +diff --git a/docs/public/tutorials/ensemble.mdx b/docs/public/tutorials/ensemble.mdx +index e6ff5adb8..c134c8d26 100644 +--- a/docs/public/tutorials/ensemble.mdx ++++ b/docs/public/tutorials/ensemble.mdx +@@ -1,6 +1,6 @@ + --- + title: "Ensemble" +-description: "Multi-provider fan-out, error policies, and result synthesis" ++description: "Multi-provider fan-out and shared-checkout result synthesis" + --- + + This tutorial combines parallel execution with multi-model routing to get independent opinions from four different LLM providers, then synthesizes the results. This is the ensemble pattern — useful when you want diverse perspectives, consensus-based decisions, or protection against any single model's blind spots. +@@ -28,15 +28,15 @@ digraph Ensemble { + start [shape=Mdiamond, label="Start"] + exit [shape=Msquare, label="Exit"] + +- fork [label="Fan Out", shape=component, join_policy="wait_all"] ++ fork [label="Fan Out", shape=component] + + opus [label="Opus", prompt="Analyze the goal. Provide your independent assessment, recommendations, and any code or prose needed. Be thorough.", shape=tab] + gemini [label="Gemini", prompt="Analyze the goal. Provide your independent assessment, recommendations, and any code or prose needed. Be thorough.", shape=tab] + codex [label="Codex", prompt="Analyze the goal. Provide your independent assessment, recommendations, and any code or prose needed. Be thorough.", shape=tab] + mercury [label="Mercury", prompt="Analyze the goal. Provide your independent assessment, recommendations, and any code or prose needed. Be thorough.", shape=tab] + +- merge [label="Merge", shape=tripleoctagon] +- synth [label="Synthesize", prompt="You have received independent analyses from four different models (Opus, Gemini, Codex, Mercury). Compare their perspectives: identify consensus, highlight disagreements, and synthesize the strongest ideas into a single coherent recommendation. Note where models agreed and where they diverged.", shape=tab] ++ merge [label="Synthesize", shape=tripleoctagon, prompt="Compare every branch result: identify consensus, highlight disagreements, and synthesize the strongest ideas into one recommendation. Note where models agreed and diverged."] ++ synth [label="Write Recommendation", prompt="Write the synthesized analysis as a single coherent recommendation.", shape=tab] + + start -> fork + fork -> opus +diff --git a/docs/public/tutorials/parallel-review.mdx b/docs/public/tutorials/parallel-review.mdx +index 41f589bea..39f6fd7f8 100644 +--- a/docs/public/tutorials/parallel-review.mdx ++++ b/docs/public/tutorials/parallel-review.mdx +@@ -1,6 +1,6 @@ + --- + title: "Parallel Review" +-description: "Fan-out, fan-in, join policies, and merge nodes" ++description: "Shared-checkout fan-out, fan-in, and result synthesis" + --- + + This tutorial runs three code review perspectives in parallel — security, architecture, and quality — then merges the results into a single report. +@@ -19,14 +19,14 @@ digraph Parallel { + start [shape=Mdiamond, label="Start"] + exit [shape=Msquare, label="Exit"] + +- fork [label="Fork Analysis", shape=component, join_policy="wait_all"] ++ fork [label="Fork Analysis", shape=component] + + security [label="Security Audit", prompt="Examine the codebase for security concerns: hardcoded secrets, injection risks, unsafe dependencies. List findings as bullet points.", shape=tab, reasoning_effort="low"] + architecture [label="Architecture Review", prompt="Assess the codebase architecture: separation of concerns, dependency structure, modularity. List findings as bullet points.", shape=tab, reasoning_effort="low"] + quality [label="Code Quality", prompt="Check code quality: naming conventions, dead code, test coverage gaps, error handling. List findings as bullet points.", shape=tab, reasoning_effort="low"] + +- merge [label="Merge Findings", shape=tripleoctagon] +- report [label="Final Report", prompt="Synthesize the security, architecture, and code quality findings into a prioritized summary report with top 5 action items.", shape=tab] ++ merge [label="Synthesize Findings", shape=tripleoctagon, prompt="Synthesize every branch result into a prioritized summary report with the top 5 action items."] ++ report [label="Write Final Report", prompt="Write the synthesized review as a clear final report.", shape=tab] + + start -> fork + fork -> security +@@ -48,37 +48,38 @@ fabro run docs/internal/demo/06-parallel.fabro + The `fork` node has `shape=component`, making it a **parallel fan-out node**. Every outgoing edge becomes a concurrent branch: + + ```dot +-fork [label="Fork Analysis", shape=component, join_policy="wait_all"] ++fork [label="Fork Analysis", shape=component] + + fork -> security + fork -> architecture + fork -> quality + ``` + +-All three branches start at the same time. Each gets an isolated copy of the run context, so branches can't interfere with each other. ++All three branches start concurrently and operate in the same sandbox checkout and working directory. This review is safe because the branches are prompt nodes with no workspace tools. + +-### Join policies ++ ++Parallel branches are not isolated from one another. If branches edit files, they can observe, race with, or overwrite each other's changes. Fabro does not lock files, detect overlapping writes, or warn about races. Keep branches read-only or give each branch responsibility for disjoint paths. ++ + +-The `join_policy` controls when execution can proceed past the merge: +- +-| Policy | Behavior | +-|---|---| +-| `wait_all` | Wait for every branch to finish (default) | +-| `first_success` | Proceed as soon as one branch succeeds | ++The parallel node always waits for every branch to finish before continuing. + + ## Fan-in with the merge node + +-The `merge` node has `shape=tripleoctagon`, making it a **merge (fan-in) node**. It collects results from all branches into a single context: ++The `merge` node has `shape=tripleoctagon`, making it a **merge (fan-in) node**. It receives every branch's status and context updates in `parallel.results` and synthesizes them with its prompt: + + ```dot +-merge [label="Merge Findings", shape=tripleoctagon] ++merge [ ++ label="Synthesize Findings", ++ shape=tripleoctagon, ++ prompt="Synthesize every branch result into a prioritized summary report with the top 5 action items." ++] + + security -> merge + architecture -> merge + quality -> merge + ``` + +-The merged branch results are available to downstream nodes. The `report` node receives all three perspectives in its preamble and synthesizes them. ++`parallel.results` is runtime context, not a `parallel_results.json` file in the checkout. Fan-in never selects a branch's files or changes workspace state; every branch has already worked in the same checkout. The downstream `report` node writes the synthesis produced by fan-in as the final report. + + ## Concurrency control + +@@ -92,10 +93,10 @@ This is useful when branches are resource-intensive (e.g., each running a full a + + ## What you've learned + +-- **Fan-out nodes** (`shape=component`) spawn concurrent branches +-- **Merge nodes** (`shape=tripleoctagon`) collect branch results +-- **Join policies** control when execution can proceed past the merge +-- Each branch gets an isolated copy of the context ++- **Fan-out nodes** (`shape=component`) spawn concurrent branches and wait for all of them ++- Parallel branches share one checkout, so workflows must prevent or tolerate file races ++- **Fan-in nodes** (`shape=tripleoctagon`) can synthesize `parallel.results` with a prompt ++- Fan-in never selects or restores workspace state, and no results file is created + + ## Next + +diff --git a/docs/public/workflows/stages-and-nodes.mdx b/docs/public/workflows/stages-and-nodes.mdx +index 1dd0f71ac..9263d1ea7 100644 +--- a/docs/public/workflows/stages-and-nodes.mdx ++++ b/docs/public/workflows/stages-and-nodes.mdx +@@ -155,10 +155,10 @@ Conditions support `=`, `!=`, `&&`, and context variable lookups (e.g. `context. + + **Shape:** `component` + +-Fans out to execute multiple branches concurrently. Each branch gets its own isolated context. ++Fans out to execute multiple branches concurrently. Every branch runs in the same sandbox checkout and working directory, and the parallel node waits for every branch to finish. + + ```dot +-fork [label="Fan Out", shape=component, join_policy="wait_all"] ++fork [label="Fan Out", shape=component] + + fork -> security + fork -> architecture +@@ -167,24 +167,22 @@ fork -> quality + + | Attribute | Description | + |---|---| +-| `join_policy` | When the merge can proceed (see table below) | + | `max_parallel` | Maximum concurrent branches (default: 4) | + +-**Join policies:** +- +-| Policy | Behavior | +-|---|---| +-| `wait_all` | Wait for every branch to finish (default) | +-| `first_success` | Proceed as soon as one branch succeeds | ++Because the checkout is shared, file changes from one branch are immediately visible to the others. Concurrent writes can race or overwrite each other. Fabro does not isolate branch files, lock paths, detect conflicts, or warn about overlapping writes. Design branches to be read-only or assign each branch disjoint files and directories when deterministic workspace changes matter. + + ### Merge (fan-in) + + **Shape:** `tripleoctagon` + +-Collects results from parallel branches into a single context. Typically paired with a parallel fan-out node: ++Converges parallel branches after all of them finish. Branch status and context updates are collected in the runtime context at `parallel.results`; Fabro does not create a `parallel_results.json` file in the checkout. + + ```dot +-merge [label="Merge Results", shape=tripleoctagon] ++merge [ ++ label="Synthesize Results", ++ shape=tripleoctagon, ++ prompt="Synthesize every branch result into one report." ++] + + security -> merge + architecture -> merge +@@ -192,7 +190,7 @@ quality -> merge + merge -> report + ``` + +-The merged results are available to downstream nodes as `parallel_results.json`. ++A fan-in node with a `prompt` synthesizes the collected results. It never chooses, restores, or merges a branch's workspace state: all branches have already operated on the same checkout. Without a prompt, fan-in is only a convergence barrier. + + ## Common node attributes + +diff --git a/lib/crates/fabro-agent/src/lib.rs b/lib/crates/fabro-agent/src/lib.rs +index 47aa8cd75..fd6f84db7 100644 +--- a/lib/crates/fabro-agent/src/lib.rs ++++ b/lib/crates/fabro-agent/src/lib.rs +@@ -57,8 +57,7 @@ pub use read_before_write_sandbox::ReadBeforeWriteSandbox; + pub use sandbox::{ + CommandOutputCallback, DirEntry, ExecResult, ExecStreamingResult, GrepOptions, RefreshOutcome, + Sandbox, SandboxEvent, SandboxEventCallback, StderrCollector, StdioProcess, StdioProcessHandle, +- WorktreeEvent, WorktreeEventCallback, WorktreeOptions, WorktreeSandbox, format_lines_numbered, +- shell_quote, ++ format_lines_numbered, shell_quote, + }; + pub use session::{ + CompletionCoordinator, Session, SessionControlHandle, SessionInputTiming, StaticEnvProvider, +diff --git a/lib/crates/fabro-agent/src/sandbox.rs b/lib/crates/fabro-agent/src/sandbox.rs +index 5884876ab..3ba22c7a9 100644 +--- a/lib/crates/fabro-agent/src/sandbox.rs ++++ b/lib/crates/fabro-agent/src/sandbox.rs +@@ -4,6 +4,5 @@ + pub use fabro_sandbox::{ + CommandOutputCallback, DirEntry, ExecResult, ExecStreamingResult, GrepOptions, RefreshOutcome, + Sandbox, SandboxEvent, SandboxEventCallback, StderrCollector, StdioProcess, StdioProcessHandle, +- StdioProcessTermination, WorktreeEvent, WorktreeEventCallback, WorktreeOptions, +- WorktreeSandbox, delegate_sandbox, format_lines_numbered, shell_quote, ++ StdioProcessTermination, delegate_sandbox, format_lines_numbered, shell_quote, + }; +diff --git a/lib/crates/fabro-api/build.rs b/lib/crates/fabro-api/build.rs +index 0a81b9845..3fa54d1a6 100644 +--- a/lib/crates/fabro-api/build.rs ++++ b/lib/crates/fabro-api/build.rs +@@ -348,6 +348,11 @@ fn main() { + ("StageState", "fabro_types::StageState", &[]), + ("CommandTermination", "fabro_types::CommandTermination", &[]), + ("StageModelUsage", "fabro_types::StageModelUsage", &[]), ++ ( ++ "ParallelBranchResult", ++ "fabro_types::ParallelBranchResult", ++ &[], ++ ), + ("StageProjection", "fabro_types::StageProjection", &[]), + ("PermissionLevel", "fabro_types::PermissionLevel", &[]), + ( +diff --git a/lib/crates/fabro-api/src/lib.rs b/lib/crates/fabro-api/src/lib.rs +index da898f8c2..b8401950c 100644 +--- a/lib/crates/fabro-api/src/lib.rs ++++ b/lib/crates/fabro-api/src/lib.rs +@@ -50,8 +50,8 @@ pub mod types { + McpServerReplace as ReplaceMcpServerRequest, McpServerStatus, McpServerView as McpServer, + McpTransportView, Message, PairId, PairMessageId, PairMessageRecord, PairMessageRequest, + PairRecord, PairStartRequest, PairStatus, PairTarget, PairTranscriptEntry, +- PairTranscriptResponse, PendingInterviewRecord, PermissionLevel, PreRunPushOutcome, +- Principal, PullRequest, PullRequestDetails, PullRequestDetailsStatus, ++ PairTranscriptResponse, ParallelBranchResult, PendingInterviewRecord, PermissionLevel, ++ PreRunPushOutcome, Principal, PullRequest, PullRequestDetails, PullRequestDetailsStatus, + PullRequestDetailsUnavailableReason, PullRequestLink, PullRequestMeta, PullRequestResponse, + QuestionType, RepositoryRef, Role, Run, RunApproval, RunApprovalState, RunClientProvenance, + RunEvent, RunEventDetailContentKind, RunEventDetailResponse, RunFailure, +diff --git a/lib/crates/fabro-api/tests/run_event_round_trip.rs b/lib/crates/fabro-api/tests/run_event_round_trip.rs +index c36c1e67f..4f4ea5e99 100644 +--- a/lib/crates/fabro-api/tests/run_event_round_trip.rs ++++ b/lib/crates/fabro-api/tests/run_event_round_trip.rs +@@ -250,6 +250,65 @@ fn run_event_round_trips_stage_started() { + assert_run_event_round_trip(value); + } + ++#[test] ++fn run_event_round_trips_parallel_public_contracts() { ++ assert_run_event_round_trip(json!({ ++ "id": "evt_parallel_started", ++ "ts": "2026-04-29T12:02:00Z", ++ "run_id": fixtures::RUN_1, ++ "event": "parallel.started", ++ "node_id": "fanout", ++ "node_label": "Fanout", ++ "parallel_group_id": "fanout@2", ++ "properties": { ++ "visit": 2, ++ "branch_count": 2 ++ } ++ })); ++ assert_run_event_round_trip(json!({ ++ "id": "evt_parallel_branch_completed", ++ "ts": "2026-04-29T12:02:01Z", ++ "run_id": fixtures::RUN_1, ++ "event": "parallel.branch.completed", ++ "node_id": "review_api", ++ "node_label": "Review API", ++ "parallel_group_id": "fanout@2", ++ "parallel_branch_id": "fanout@2:0", ++ "properties": { ++ "index": 0, ++ "duration_ms": 1000, ++ "status": "succeeded" ++ } ++ })); ++ assert_run_event_round_trip(json!({ ++ "id": "evt_parallel_completed", ++ "ts": "2026-04-29T12:02:02Z", ++ "run_id": fixtures::RUN_1, ++ "event": "parallel.completed", ++ "node_id": "fanout", ++ "node_label": "Fanout", ++ "parallel_group_id": "fanout@2", ++ "properties": { ++ "visit": 2, ++ "duration_ms": 2000, ++ "success_count": 1, ++ "failure_count": 1, ++ "results": [ ++ { ++ "id": "review_api", ++ "status": "succeeded", ++ "context_updates": {"response.review_api": "looks good"} ++ }, ++ { ++ "id": "review_ux", ++ "status": "failed", ++ "context_updates": {} ++ } ++ ] ++ } ++ })); ++} ++ + #[test] + fn run_event_round_trips_agent_tool_started() { + let value = json!({ +diff --git a/lib/crates/fabro-api/tests/stage_projection_round_trip.rs b/lib/crates/fabro-api/tests/stage_projection_round_trip.rs +index 2f26ad612..7a680b135 100644 +--- a/lib/crates/fabro-api/tests/stage_projection_round_trip.rs ++++ b/lib/crates/fabro-api/tests/stage_projection_round_trip.rs +@@ -7,8 +7,8 @@ use fabro_api::types::{ + AgentToolSource as ApiAgentToolSource, AgentToolSummary as ApiAgentToolSummary, + AgentToolsAvailableProps as ApiAgentToolsAvailableProps, + McpServerProjection as ApiMcpServerProjection, McpServerStatus as ApiMcpServerStatus, +- PermissionLevel as ApiPermissionLevel, SkillsProjection as ApiSkillsProjection, +- StageContextWindow as ApiStageContextWindow, ++ ParallelBranchResult as ApiParallelBranchResult, PermissionLevel as ApiPermissionLevel, ++ SkillsProjection as ApiSkillsProjection, StageContextWindow as ApiStageContextWindow, + StageContextWindowBreakdownItem as ApiStageContextWindowBreakdownItem, + StageContextWindowCategory as ApiStageContextWindowCategory, + StageContextWindowCountMethod as ApiStageContextWindowCountMethod, +@@ -22,11 +22,11 @@ use fabro_api::types::{ + use fabro_types::{ + ActivatedSkill, AgentMcpToolSummary, AgentSkillActivationSource, AgentSkillSummary, + AgentToolCategory, AgentToolSource, AgentToolSummary, AgentToolsAvailableProps, +- McpServerProjection, McpServerStatus, PermissionLevel, SkillsProjection, StageContextWindow, +- StageContextWindowBreakdownItem, StageContextWindowCategory, StageContextWindowCountMethod, +- StageContextWindowProjection, StageContextWindowStaleness, StageContextWindowUnavailableReason, +- StageContextWindowWarning, StageProjection, SubAgentProjection, SubAgentStatus, TodoListKind, +- TodoListProjection, ++ McpServerProjection, McpServerStatus, ParallelBranchResult, PermissionLevel, SkillsProjection, ++ StageContextWindow, StageContextWindowBreakdownItem, StageContextWindowCategory, ++ StageContextWindowCountMethod, StageContextWindowProjection, StageContextWindowStaleness, ++ StageContextWindowUnavailableReason, StageContextWindowWarning, StageProjection, ++ SubAgentProjection, SubAgentStatus, TodoListKind, TodoListProjection, + }; + use serde_json::json; + +@@ -37,6 +37,7 @@ fn stage_projection_reuses_canonical_type() { + + #[test] + fn stage_projection_reuses_nested_agent_state_types() { ++ assert_same_type::(); + assert_same_type::(); + assert_same_type::(); + assert_same_type::(); +@@ -85,7 +86,21 @@ fn stage_projection_round_trips_representative_json() { + "diff": "diff --git a/file b/file", + "script_invocation": { "command": "cargo test" }, + "script_timing": { "duration_ms": 42 }, +- "parallel_results": [{ "branch": 0, "status": "succeeded" }], ++ "parallel_results": [ ++ { ++ "id": "review_api", ++ "status": "succeeded", ++ "context_updates": { ++ "response.review_api": "looks good", ++ "score": 0.95 ++ } ++ }, ++ { ++ "id": "review_ux", ++ "status": "failed", ++ "context_updates": {} ++ } ++ ], + "output": "ok", + "termination": "exited", + "started_at": "2026-04-29T12:34:00Z", +diff --git a/lib/crates/fabro-cli/src/commands/run/run_progress/event.rs b/lib/crates/fabro-cli/src/commands/run/run_progress/event.rs +index a4a72f0bc..6f8498411 100644 +--- a/lib/crates/fabro-cli/src/commands/run/run_progress/event.rs ++++ b/lib/crates/fabro-cli/src/commands/run/run_progress/event.rs +@@ -130,7 +130,7 @@ pub(super) enum ProgressEvent { + ParallelBranchCompleted { + branch: String, + duration_ms: u64, +- status: String, ++ status: fabro_types::StageOutcome, + }, + ParallelCompleted, + AssistantMessage { +@@ -318,7 +318,7 @@ pub(super) fn from_run_event(stored: &RunEvent) -> Option { + EventBody::ParallelBranchCompleted(props) => Some(ProgressEvent::ParallelBranchCompleted { + branch: node_id, + duration_ms: props.duration_ms, +- status: props.status.clone(), ++ status: props.status, + }), + EventBody::ParallelCompleted(_) => Some(ProgressEvent::ParallelCompleted), + EventBody::AgentMessage(props) => Some(ProgressEvent::AssistantMessage { +diff --git a/lib/crates/fabro-cli/src/commands/run/run_progress/mod.rs b/lib/crates/fabro-cli/src/commands/run/run_progress/mod.rs +index 6e1a6227b..24570dac4 100644 +--- a/lib/crates/fabro-cli/src/commands/run/run_progress/mod.rs ++++ b/lib/crates/fabro-cli/src/commands/run/run_progress/mod.rs +@@ -248,7 +248,7 @@ impl ProgressUI { + status, + } => { + self.stage +- .on_parallel_branch_completed(renderer, &branch, duration_ms, &status); ++ .on_parallel_branch_completed(renderer, &branch, duration_ms, status); + } + ProgressEvent::ParallelCompleted => { + self.stage.on_parallel_completed(); +@@ -587,7 +587,6 @@ mod tests { + node_id: "fork1".into(), + visit: 1, + branch_count: 2, +- join_policy: "wait_all".into(), + }); + assert_eq!(ui.stage.parallel_parent.as_deref(), Some("fork1")); + +@@ -611,8 +610,7 @@ mod tests { + branch: "security".into(), + index: 0, + duration_ms: 2000, +- status: "succeeded".into(), +- head_sha: None, ++ status: fabro_workflow::outcome::StageOutcome::Succeeded, + }); + let stage = &ui.stage.active_stages["fork1"]; + assert!(matches!( +@@ -630,7 +628,6 @@ mod tests { + node_id: "fork1".into(), + visit: 1, + branch_count: 1, +- join_policy: "wait_all".into(), + }); + emit(&mut ui, Event::ParallelBranchStarted { + parallel_group_id: StageId::new("fork1", 1), +@@ -1246,7 +1243,6 @@ mod tests { + node_id: "fork1".into(), + visit: 1, + branch_count: 1, +- join_policy: "wait_all".into(), + }); + emit(&mut ui, Event::ParallelBranchStarted { + parallel_group_id: StageId::new("fork1", 1), +@@ -1260,8 +1256,7 @@ mod tests { + branch: "security".into(), + index: 0, + duration_ms: 500, +- status: "succeeded".into(), +- head_sha: None, ++ status: fabro_workflow::outcome::StageOutcome::Succeeded, + }); + + let stage = &ui.stage.active_stages["fork1"]; +diff --git a/lib/crates/fabro-cli/src/commands/run/run_progress/stage_display.rs b/lib/crates/fabro-cli/src/commands/run/run_progress/stage_display.rs +index 13014c2a0..b58568b48 100644 +--- a/lib/crates/fabro-cli/src/commands/run/run_progress/stage_display.rs ++++ b/lib/crates/fabro-cli/src/commands/run/run_progress/stage_display.rs +@@ -244,7 +244,7 @@ impl StageDisplay { + renderer: &ProgressRenderer, + branch: &str, + duration_ms: u64, +- status: &str, ++ status: StageOutcome, + ) { + let Some(parent_id) = self.parallel_parent.clone() else { + return; +@@ -261,7 +261,7 @@ impl StageDisplay { + return; + }; + +- let succeeded = matches!(status, "succeeded" | "partially_succeeded"); ++ let succeeded = status.is_successful(); + entry.status = if succeeded { + ToolCallStatus::Succeeded + } else { +diff --git a/lib/crates/fabro-cli/tests/it/workflow/dry_run_examples.rs b/lib/crates/fabro-cli/tests/it/workflow/dry_run_examples.rs +index bb8a50c85..658dd1fea 100644 +--- a/lib/crates/fabro-cli/tests/it/workflow/dry_run_examples.rs ++++ b/lib/crates/fabro-cli/tests/it/workflow/dry_run_examples.rs +@@ -80,7 +80,9 @@ fn dry_run_parallel() { + let mut cmd = context.run_cmd(); + cmd.args(["--dry-run", "--auto-approve"]); + cmd.arg(&workflow); +- fabro_snapshot!(run_output_filters(&context), cmd, @" ++ let mut filters = run_output_filters(&context); ++ filters.push((r"\bbranch[12]\b".to_string(), "[BRANCH]".to_string())); ++ fabro_snapshot!(filters, cmd, @" + success: true + exit_code: 0 + ----- stdout ----- +@@ -93,6 +95,8 @@ fn dry_run_parallel() { + Web UI: http://localhost:3000/runs/[ULID] + Sandbox: local (ready in [TIME]) + ✓ start [TIME] ++ ✓ [BRANCH] [TIME] ++ ✓ [BRANCH] [TIME] + ✓ Fork Work [TIME] + ✓ Merge Results [TIME] + ✓ Review [TIME] +diff --git a/lib/crates/fabro-dump/src/lib.rs b/lib/crates/fabro-dump/src/lib.rs +index b4da2215c..d8378cbd5 100644 +--- a/lib/crates/fabro-dump/src/lib.rs ++++ b/lib/crates/fabro-dump/src/lib.rs +@@ -127,7 +127,7 @@ impl RunDump { + if let Some(parallel_results) = stage.parallel_results.as_ref() { + entries.push(RunDumpEntry::json_path( + &base.join("parallel_results.json"), +- parallel_results.clone(), ++ serde_json::to_value(parallel_results)?, + )); + } + if let Some(output) = stage.output.as_ref() { +@@ -605,7 +605,14 @@ mod tests { + stage.diff = Some("diff --git a/a b/a".to_string()); + stage.script_invocation = Some(serde_json::json!({ "command": "cargo test" })); + stage.script_timing = Some(serde_json::json!({ "duration_ms": 10 })); +- stage.parallel_results = Some(serde_json::json!([{ "stage": "fanout@1" }])); ++ stage.parallel_results = Some(vec![fabro_types::ParallelBranchResult { ++ id: "review".to_string(), ++ status: fabro_types::StageOutcome::Succeeded, ++ context_updates: std::collections::BTreeMap::from([( ++ "response.review".to_string(), ++ serde_json::json!("looks good"), ++ )]), ++ }]); + stage.output = Some("output".to_string()); + + let dump = RunDump::from_projection(&projection).unwrap(); +diff --git a/lib/crates/fabro-sandbox/src/daytona/mod.rs b/lib/crates/fabro-sandbox/src/daytona/mod.rs +index 181b7895a..fa227d2c8 100644 +--- a/lib/crates/fabro-sandbox/src/daytona/mod.rs ++++ b/lib/crates/fabro-sandbox/src/daytona/mod.rs +@@ -1311,22 +1311,6 @@ impl Sandbox for DaytonaSandbox { + crate::git_push_via_exec(self, refspec).await + } + +- fn parallel_worktree_path( +- &self, +- _run_dir: &std::path::Path, +- run_id: &str, +- node_id: &str, +- key: &str, +- ) -> String { +- format!( +- "{}/.fabro/scratch/{}/parallel/{}/{}", +- self.working_directory(), +- run_id, +- node_id, +- key +- ) +- } +- + async fn ssh_access_command(&self) -> crate::Result> { + self.create_ssh_access(Some(60.0)).await.map(Some) + } +diff --git a/lib/crates/fabro-sandbox/src/docker.rs b/lib/crates/fabro-sandbox/src/docker.rs +index 08dc04c3d..51f52a41c 100644 +--- a/lib/crates/fabro-sandbox/src/docker.rs ++++ b/lib/crates/fabro-sandbox/src/docker.rs +@@ -1929,22 +1929,6 @@ impl Sandbox for DockerSandbox { + crate::git_push_via_exec(self, refspec).await + } + +- fn parallel_worktree_path( +- &self, +- _run_dir: &std::path::Path, +- run_id: &str, +- node_id: &str, +- key: &str, +- ) -> String { +- format!( +- "{}/.fabro/scratch/{}/parallel/{}/{}", +- self.working_directory(), +- run_id, +- node_id, +- key +- ) +- } +- + fn origin_url(&self) -> Option<&str> { + if !self.repo_cloned() { + return None; +diff --git a/lib/crates/fabro-sandbox/src/lib.rs b/lib/crates/fabro-sandbox/src/lib.rs +index 54daf581a..c4d10a7a2 100644 +--- a/lib/crates/fabro-sandbox/src/lib.rs ++++ b/lib/crates/fabro-sandbox/src/lib.rs +@@ -22,8 +22,6 @@ pub mod details; + + pub mod reconnect; + +-pub mod worktree; +- + pub mod terminal; + + pub mod local; +@@ -62,4 +60,3 @@ pub use sandbox::{ + }; + pub use sandbox_spec::SandboxSpec; + pub use terminal::{TerminalSession, TerminalSize, open_terminal_for_run}; +-pub use worktree::{WorktreeEvent, WorktreeEventCallback, WorktreeOptions, WorktreeSandbox}; +diff --git a/lib/crates/fabro-sandbox/src/sandbox.rs b/lib/crates/fabro-sandbox/src/sandbox.rs +index 352a51be4..fa2e9bacb 100644 +--- a/lib/crates/fabro-sandbox/src/sandbox.rs ++++ b/lib/crates/fabro-sandbox/src/sandbox.rs +@@ -217,16 +217,6 @@ macro_rules! delegate_sandbox { + self.$field.git_push_ref(refspec).await + } + +- fn parallel_worktree_path( +- &self, +- run_dir: &std::path::Path, +- run_id: &str, +- node_id: &str, +- key: &str, +- ) -> String { +- self.$field.parallel_worktree_path(run_dir, run_id, node_id, key) +- } +- + async fn ssh_access_command(&self) -> $crate::Result> { + self.$field.ssh_access_command().await + } +@@ -1005,23 +995,6 @@ pub trait Sandbox: Send + Sync { + )) + } + +- /// Compute the filesystem path for a parallel branch worktree. +- fn parallel_worktree_path( +- &self, +- run_dir: &std::path::Path, +- _run_id: &str, +- node_id: &str, +- key: &str, +- ) -> String { +- run_dir +- .join("parallel") +- .join(node_id) +- .join(key) +- .join("worktree") +- .to_string_lossy() +- .into_owned() +- } +- + /// Return an SSH command string for connecting to this sandbox, if + /// supported. + async fn ssh_access_command(&self) -> crate::Result> { +diff --git a/lib/crates/fabro-sandbox/src/worktree.rs b/lib/crates/fabro-sandbox/src/worktree.rs +deleted file mode 100644 +index 45370f11a..000000000 +--- a/lib/crates/fabro-sandbox/src/worktree.rs ++++ /dev/null +@@ -1,908 +0,0 @@ +-use std::collections::HashMap; +-use std::path::Path; +-use std::sync::Arc; +- +-use async_trait::async_trait; +-use tokio_util::sync::CancellationToken; +- +-use crate::sandbox::fetch_source_run_ref; +-use crate::{ +- CommandOutputCallback, DirEntry, ExecResult, ExecStreamingResult, GitRunInfo, GitSetupIntent, +- GrepOptions, Sandbox, StdioProcess, shell_quote, +-}; +- +-/// Git command prefix that disables background maintenance. +-const GIT: &str = "git -c maintenance.auto=0 -c gc.auto=0"; +- +-// --------------------------------------------------------------------------- +-// Public types +-// --------------------------------------------------------------------------- +- +-/// Events emitted during worktree lifecycle operations. +-pub enum WorktreeEvent { +- BranchCreated { branch: String, sha: String }, +- WorktreeAdded { path: String, branch: String }, +- WorktreeRemoved { path: String }, +-} +- +-/// Callback type for worktree lifecycle events. +-pub type WorktreeEventCallback = Arc; +- +-/// Configuration for a `WorktreeSandbox`. +-pub struct WorktreeOptions { +- pub branch_name: String, +- pub base_sha: String, +- pub worktree_path: String, +- /// Skip branch creation and hard reset (for resume, where branch already +- /// exists). +- pub skip_branch_creation: bool, +- pub setup_intent: Option, +-} +- +-/// Wraps any `Sandbox`, manages a git worktree lifecycle in +-/// `initialize()`/`cleanup()`, and overrides `working_directory()` and +-/// `exec_command()` to use the worktree path. +-/// +-/// `initialize()` and `cleanup()` do NOT call the inner sandbox's lifecycle +-/// methods. The inner sandbox's lifecycle is managed separately by the caller. +-pub struct WorktreeSandbox { +- inner: Arc, +- config: WorktreeOptions, +- event_callback: Option, +- initialized: std::sync::atomic::AtomicBool, +-} +- +-impl WorktreeSandbox { +- /// Create a new `WorktreeSandbox` wrapping `inner` with the given +- /// configuration. +- pub fn new(inner: Arc, config: WorktreeOptions) -> Self { +- Self { +- inner, +- config, +- event_callback: None, +- initialized: std::sync::atomic::AtomicBool::new(false), +- } +- } +- +- /// Set the callback to receive worktree lifecycle events. +- pub fn set_event_callback(&mut self, cb: WorktreeEventCallback) { +- self.event_callback = Some(cb); +- } +- +- /// The git branch name managed by this sandbox. +- pub fn branch_name(&self) -> &str { +- &self.config.branch_name +- } +- +- /// The base commit SHA used when initializing the worktree. +- pub fn base_sha(&self) -> &str { +- &self.config.base_sha +- } +- +- /// The filesystem path to the worktree directory. +- pub fn worktree_path(&self) -> &str { +- &self.config.worktree_path +- } +- +- fn emit(&self, event: WorktreeEvent) { +- if let Some(ref cb) = self.event_callback { +- cb(event); +- } +- } +- +- fn resolve_path(&self, path: &str) -> String { +- if std::path::Path::new(path).is_absolute() { +- path.to_string() +- } else { +- format!("{}/{path}", self.config.worktree_path) +- } +- } +- +- async fn fetch_fork_source_if_needed(&self) -> crate::Result<()> { +- if let Some(GitSetupIntent::ForkFromCheckpoint { +- source_run_id, +- checkpoint_sha, +- .. +- }) = self.config.setup_intent.as_ref() +- { +- fetch_source_run_ref(&*self.inner, source_run_id, checkpoint_sha).await?; +- } +- Ok(()) +- } +-} +- +-// --------------------------------------------------------------------------- +-// Sandbox implementation +-// --------------------------------------------------------------------------- +- +-#[async_trait] +-impl Sandbox for WorktreeSandbox { +- // --- Lifecycle --- +- +- /// Set up the git worktree: +- /// 1. Best-effort remove any stale worktree at `path` (so the branch is +- /// free to be updated). +- /// 2. Unless `skip_branch_creation`: force-create the branch at `base_sha`, +- /// emit `BranchCreated`. +- /// 3. Add the worktree, emit `WorktreeAdded`. +- /// +- /// Does NOT call `inner.initialize()`. +- async fn initialize(&self) -> crate::Result<()> { +- if self +- .initialized +- .swap(true, std::sync::atomic::Ordering::Relaxed) +- { +- return Ok(()); +- } +- let path = shell_quote(&self.config.worktree_path); +- let branch = shell_quote(&self.config.branch_name); +- let sha = shell_quote(&self.config.base_sha); +- +- self.fetch_fork_source_if_needed().await?; +- +- // Best-effort remove any stale worktree registration + directory first, +- // so that the branch is not "in use" when we try to force-update it. +- let rm_cmd = format!("{GIT} worktree remove --force {path}"); +- let _ = self +- .inner +- .exec_command(&rm_cmd, 30_000, None, None, None) +- .await; +- +- // Prune all stale worktree references whose directories no longer exist. +- // Without this, a branch may remain locked by a worktree in a deleted +- // temp directory from a previous run. +- let prune_cmd = format!("{GIT} worktree prune"); +- let _ = self +- .inner +- .exec_command(&prune_cmd, 30_000, None, None, None) +- .await; +- +- if !self.config.skip_branch_creation { +- let cmd = format!("{GIT} branch --force {branch} {sha}"); +- let result = self +- .inner +- .exec_command(&cmd, 30_000, None, None, None) +- .await?; +- if !result.is_success() { +- return Err(crate::Error::message(format!( +- "git branch --force failed (exit {}): {}", +- result.display_exit_code(), +- result.stderr.trim() +- ))); +- } +- self.emit(WorktreeEvent::BranchCreated { +- branch: self.config.branch_name.clone(), +- sha: self.config.base_sha.clone(), +- }); +- } +- +- let add_cmd = format!("{GIT} worktree add {path} {branch}"); +- let result = self +- .inner +- .exec_command(&add_cmd, 30_000, None, None, None) +- .await?; +- if !result.is_success() { +- // Roll back the branch created above so we don't leak partial state. +- if !self.config.skip_branch_creation { +- let rollback_cmd = format!("{GIT} branch -D {branch}"); +- let _ = self +- .inner +- .exec_command(&rollback_cmd, 30_000, None, None, None) +- .await; +- } +- return Err(crate::Error::message(format!( +- "git worktree add failed (exit {}): {}", +- result.display_exit_code(), +- result.stderr.trim() +- ))); +- } +- self.emit(WorktreeEvent::WorktreeAdded { +- path: self.config.worktree_path.clone(), +- branch: self.config.branch_name.clone(), +- }); +- +- Ok(()) +- } +- +- /// No-op — the worktree must survive cleanup for `fabro cp` access. +- /// Worktrees are pruned separately by `system prune`. +- async fn cleanup(&self) -> crate::Result<()> { +- Ok(()) +- } +- +- async fn start(&self) -> crate::Result<()> { +- self.inner.start().await +- } +- +- async fn stop(&self) -> crate::Result<()> { +- self.inner.stop().await +- } +- +- async fn delete(&self) -> crate::Result<()> { +- self.inner.delete().await +- } +- +- fn working_directory(&self) -> &str { +- &self.config.worktree_path +- } +- +- /// Execute a command, defaulting `working_dir` to the worktree path when +- /// `None`. +- async fn exec_command( +- &self, +- command: &str, +- timeout_ms: u64, +- working_dir: Option<&str>, +- env_vars: Option<&HashMap>, +- cancel_token: Option, +- ) -> crate::Result { +- let wd = working_dir.unwrap_or(&self.config.worktree_path); +- self.inner +- .exec_command(command, timeout_ms, Some(wd), env_vars, cancel_token) +- .await +- } +- +- /// Stream a command's output, forwarding to the inner sandbox's streaming +- /// implementation so live output and `streams_separated` / `live_streaming` +- /// flags survive the worktree wrapping. +- async fn exec_command_streaming( +- &self, +- command: &str, +- timeout_ms: Option, +- working_dir: Option<&str>, +- env_vars: Option<&HashMap>, +- cancel_token: Option, +- output_callback: CommandOutputCallback, +- ) -> crate::Result { +- let wd = working_dir.unwrap_or(&self.config.worktree_path); +- self.inner +- .exec_command_streaming( +- command, +- timeout_ms, +- Some(wd), +- env_vars, +- cancel_token, +- output_callback, +- ) +- .await +- } +- +- async fn spawn_stdio_process( +- &self, +- command: &str, +- working_dir: Option<&str>, +- env_vars: Option<&HashMap>, +- cancel_token: Option, +- ) -> crate::Result { +- let wd = working_dir.unwrap_or(&self.config.worktree_path); +- self.inner +- .spawn_stdio_process(command, Some(wd), env_vars, cancel_token) +- .await +- } +- +- // --- Delegated methods --- +- +- async fn read_file_bytes(&self, path: &str) -> crate::Result> { +- let resolved = self.resolve_path(path); +- self.inner.read_file_bytes(&resolved).await +- } +- +- async fn write_file(&self, path: &str, content: &str) -> crate::Result<()> { +- let resolved = self.resolve_path(path); +- self.inner.write_file(&resolved, content).await +- } +- +- async fn delete_file(&self, path: &str) -> crate::Result<()> { +- let resolved = self.resolve_path(path); +- self.inner.delete_file(&resolved).await +- } +- +- async fn file_exists(&self, path: &str) -> crate::Result { +- let resolved = self.resolve_path(path); +- self.inner.file_exists(&resolved).await +- } +- +- async fn list_directory( +- &self, +- path: &str, +- depth: Option, +- ) -> crate::Result> { +- let resolved = self.resolve_path(path); +- self.inner.list_directory(&resolved, depth).await +- } +- +- async fn grep( +- &self, +- pattern: &str, +- path: &str, +- options: &GrepOptions, +- ) -> crate::Result> { +- let resolved = self.resolve_path(path); +- self.inner.grep(pattern, &resolved, options).await +- } +- +- async fn glob(&self, pattern: &str, path: Option<&str>) -> crate::Result> { +- let resolved = path.map(|p| self.resolve_path(p)); +- let glob_path = resolved.as_deref().unwrap_or(&self.config.worktree_path); +- self.inner.glob(pattern, Some(glob_path)).await +- } +- +- async fn download_file_to_local( +- &self, +- remote_path: &str, +- local_path: &Path, +- ) -> crate::Result<()> { +- let resolved = self.resolve_path(remote_path); +- self.inner +- .download_file_to_local(&resolved, local_path) +- .await +- } +- +- async fn upload_file_from_local( +- &self, +- local_path: &Path, +- remote_path: &str, +- ) -> crate::Result<()> { +- let resolved = self.resolve_path(remote_path); +- self.inner +- .upload_file_from_local(local_path, &resolved) +- .await +- } +- +- fn platform(&self) -> &str { +- self.inner.platform() +- } +- +- fn os_version(&self) -> String { +- self.inner.os_version() +- } +- +- fn sandbox_info(&self) -> String { +- self.inner.sandbox_info() +- } +- +- async fn refresh_push_credentials(&self) -> crate::Result { +- self.inner.refresh_push_credentials().await +- } +- +- async fn set_autostop_interval(&self, minutes: i32) -> crate::Result<()> { +- self.inner.set_autostop_interval(minutes).await +- } +- +- async fn setup_git( +- &self, +- intent: &crate::GitSetupIntent, +- ) -> crate::Result> { +- if let GitSetupIntent::ForkFromCheckpoint { +- source_run_id, +- checkpoint_sha, +- .. +- } = intent +- { +- fetch_source_run_ref(&*self.inner, source_run_id, checkpoint_sha).await?; +- } +- Ok(Some(GitRunInfo { +- base_sha: self.config.base_sha.clone(), +- run_branch: self.config.branch_name.clone(), +- base_branch: None, +- })) +- } +- +- fn resume_setup_commands(&self, run_branch: &str) -> Vec { +- self.inner.resume_setup_commands(run_branch) +- } +- +- async fn git_push_ref(&self, refspec: &str) -> crate::Result<()> { +- let has_origin = match self +- .exec_command("git remote get-url origin", 10_000, None, None, None) +- .await +- { +- Ok(result) if result.is_success() => true, +- Ok(_) => false, +- Err(err) => return Err(crate::Error::context("git remote get-url origin", err)), +- }; +- if !has_origin { +- return Ok(()); +- } +- +- crate::git_push_via_exec(self, refspec).await +- } +- +- fn parallel_worktree_path( +- &self, +- run_dir: &Path, +- run_id: &str, +- node_id: &str, +- key: &str, +- ) -> String { +- self.inner +- .parallel_worktree_path(run_dir, run_id, node_id, key) +- } +- +- async fn ssh_access_command(&self) -> crate::Result> { +- self.inner.ssh_access_command().await +- } +- +- fn origin_url(&self) -> Option<&str> { +- self.inner.origin_url() +- } +- +- async fn get_preview_url( +- &self, +- port: u16, +- ) -> crate::Result)>> { +- self.inner.get_preview_url(port).await +- } +- +- fn mark_agent_read(&self, path: &str) { +- let resolved = self.resolve_path(path); +- self.inner.mark_agent_read(&resolved); +- } +-} +- +-// --------------------------------------------------------------------------- +-// Tests +-// --------------------------------------------------------------------------- +- +-#[cfg(test)] +-#[expect( +- clippy::disallowed_methods, +- reason = "worktree tests stage fixtures with sync std::fs writes in temp dirs" +-)] +-mod tests { +- use std::sync::Mutex; +- +- use fabro_types::CommandTermination; +- +- use super::*; +- use crate::local::LocalSandbox; +- use crate::test_support::MockSandbox; +- +- fn make_config(wt_path: &str) -> WorktreeOptions { +- WorktreeOptions { +- branch_name: "fabro/run/test-branch".to_string(), +- base_sha: "abc123def456".to_string(), +- worktree_path: wt_path.to_string(), +- skip_branch_creation: false, +- setup_intent: None, +- } +- } +- +- fn make_config_skip(wt_path: &str) -> WorktreeOptions { +- WorktreeOptions { +- branch_name: "fabro/run/test-branch".to_string(), +- base_sha: "abc123def456".to_string(), +- worktree_path: wt_path.to_string(), +- skip_branch_creation: true, +- setup_intent: None, +- } +- } +- +- /// Create a shared mock and return both the `Arc` (passed to +- /// WorktreeSandbox) and the `Arc` (used to assert captured +- /// state). +- fn make_mock() -> (Arc, Arc) { +- let mock = Arc::new(MockSandbox::linux()); +- let as_sandbox: Arc = mock.clone(); +- (as_sandbox, mock) +- } +- +- // ----------------------------------------------------------------------- +- // initialize() — full setup (skip_branch_creation = false) +- // ----------------------------------------------------------------------- +- +- #[tokio::test] +- async fn initialize_issues_correct_git_commands() { +- let (inner, mock) = make_mock(); +- let wt = WorktreeSandbox::new(inner, make_config("/tmp/wt")); +- +- wt.initialize().await.unwrap(); +- +- let cmds = mock.captured_commands.lock().unwrap().clone(); +- // worktree remove (best-effort), worktree prune, branch --force, worktree add +- assert_eq!(cmds.len(), 4, "expected 4 git commands, got: {cmds:?}"); +- assert!( +- cmds[0].contains("worktree remove --force"), +- "cmd[0]: {}", +- cmds[0] +- ); +- assert!(cmds[1].contains("worktree prune"), "cmd[1]: {}", cmds[1]); +- assert!(cmds[2].contains("branch --force"), "cmd[2]: {}", cmds[2]); +- assert!(cmds[3].contains("worktree add"), "cmd[3]: {}", cmds[3]); +- } +- +- #[tokio::test] +- async fn initialize_emits_branch_and_worktree_events() { +- let (inner, _mock) = make_mock(); +- let mut wt = WorktreeSandbox::new(inner, make_config("/tmp/wt")); +- +- let events: Arc>> = Arc::new(Mutex::new(Vec::new())); +- let events_clone = Arc::clone(&events); +- wt.set_event_callback(Arc::new(move |event| { +- let label = match &event { +- WorktreeEvent::BranchCreated { .. } => "BranchCreated", +- WorktreeEvent::WorktreeAdded { .. } => "WorktreeAdded", +- WorktreeEvent::WorktreeRemoved { .. } => "WorktreeRemoved", +- }; +- events_clone.lock().unwrap().push(label.to_string()); +- })); +- +- wt.initialize().await.unwrap(); +- +- let captured = events.lock().unwrap(); +- assert_eq!(*captured, vec!["BranchCreated", "WorktreeAdded"]); +- } +- +- #[tokio::test] +- async fn initialize_uses_shell_quoted_values_in_commands() { +- let (inner, mock) = make_mock(); +- let config = WorktreeOptions { +- branch_name: "fabro/run/my-branch".to_string(), +- base_sha: "deadbeef".to_string(), +- worktree_path: "/tmp/my worktree".to_string(), // path with space +- skip_branch_creation: false, +- setup_intent: None, +- }; +- let wt = WorktreeSandbox::new(inner, config); +- +- wt.initialize().await.unwrap(); +- +- let cmds = mock.captured_commands.lock().unwrap().clone(); +- // The path "/tmp/my worktree" should be quoted in the worktree remove command +- // (cmd[0]) +- assert!( +- cmds[0].contains("'/tmp/my worktree'") || cmds[0].contains("\"/tmp/my worktree\""), +- "worktree path should be shell-quoted: {}", +- cmds[0] +- ); +- } +- +- // ----------------------------------------------------------------------- +- // initialize() — skip_branch_creation = true +- // ----------------------------------------------------------------------- +- +- #[tokio::test] +- async fn initialize_skip_branch_creation_issues_only_worktree_commands() { +- let (inner, mock) = make_mock(); +- let wt = WorktreeSandbox::new(inner, make_config_skip("/tmp/wt")); +- +- wt.initialize().await.unwrap(); +- +- let cmds = mock.captured_commands.lock().unwrap().clone(); +- // worktree remove (best-effort), worktree prune, worktree add +- assert_eq!(cmds.len(), 3, "expected 3 git commands, got: {cmds:?}"); +- assert!( +- cmds[0].contains("worktree remove --force"), +- "cmd[0]: {}", +- cmds[0] +- ); +- assert!(cmds[1].contains("worktree prune"), "cmd[1]: {}", cmds[1]); +- assert!(cmds[2].contains("worktree add"), "cmd[2]: {}", cmds[2]); +- } +- +- #[tokio::test] +- async fn initialize_skip_branch_creation_emits_only_worktree_added() { +- let (inner, _mock) = make_mock(); +- let mut wt = WorktreeSandbox::new(inner, make_config_skip("/tmp/wt")); +- +- let events: Arc>> = Arc::new(Mutex::new(Vec::new())); +- let events_clone = Arc::clone(&events); +- wt.set_event_callback(Arc::new(move |event| { +- let label = match &event { +- WorktreeEvent::BranchCreated { .. } => "BranchCreated", +- WorktreeEvent::WorktreeAdded { .. } => "WorktreeAdded", +- WorktreeEvent::WorktreeRemoved { .. } => "WorktreeRemoved", +- }; +- events_clone.lock().unwrap().push(label.to_string()); +- })); +- +- wt.initialize().await.unwrap(); +- +- let captured = events.lock().unwrap(); +- assert_eq!(*captured, vec!["WorktreeAdded"]); +- } +- +- // ----------------------------------------------------------------------- +- // initialize() — error propagation +- // ----------------------------------------------------------------------- +- +- #[tokio::test] +- async fn initialize_propagates_error_on_nonzero_exit() { +- let inner: Arc = Arc::new(MockSandbox { +- exec_result: ExecResult { +- stdout: String::new(), +- stderr: "fatal: not a git repo".to_string(), +- exit_code: Some(128), +- termination: CommandTermination::Exited, +- duration_ms: 5, +- }, +- ..MockSandbox::linux() +- }); +- let wt = WorktreeSandbox::new(inner, make_config("/tmp/wt")); +- +- let result = wt.initialize().await; +- +- assert!(result.is_err(), "should return Err on non-zero exit"); +- let err = result.unwrap_err().to_string(); +- assert!( +- err.contains("branch --force failed") || err.contains("128"), +- "error should mention the failure: {err}" +- ); +- } +- +- // ----------------------------------------------------------------------- +- // cleanup() +- // ----------------------------------------------------------------------- +- +- // ----------------------------------------------------------------------- +- // working_directory() +- // ----------------------------------------------------------------------- +- +- #[test] +- fn working_directory_returns_worktree_path() { +- let (inner, _mock) = make_mock(); +- let wt = WorktreeSandbox::new(inner, make_config("/tmp/my_worktree")); +- +- assert_eq!(wt.working_directory(), "/tmp/my_worktree"); +- } +- +- // ----------------------------------------------------------------------- +- // exec_command() working_dir defaulting +- // ----------------------------------------------------------------------- +- +- #[tokio::test] +- async fn exec_command_none_working_dir_defaults_to_worktree_path() { +- let (inner, mock) = make_mock(); +- let wt = WorktreeSandbox::new(inner, make_config("/tmp/wt")); +- +- wt.exec_command("echo hello", 5000, None, None, None) +- .await +- .unwrap(); +- +- let wdirs = mock.captured_working_dirs.lock().unwrap().clone(); +- assert_eq!( +- wdirs.last(), +- Some(&Some("/tmp/wt".to_string())), +- "None working_dir should be replaced with worktree path" +- ); +- } +- +- #[tokio::test] +- async fn exec_command_explicit_working_dir_passes_through() { +- let (inner, mock) = make_mock(); +- let wt = WorktreeSandbox::new(inner, make_config("/tmp/wt")); +- +- wt.exec_command("echo hello", 5000, Some("/explicit/path"), None, None) +- .await +- .unwrap(); +- +- let wdirs = mock.captured_working_dirs.lock().unwrap().clone(); +- assert_eq!( +- wdirs.last(), +- Some(&Some("/explicit/path".to_string())), +- "explicit working_dir should be passed through unchanged" +- ); +- } +- +- #[tokio::test] +- async fn stdio_process_none_working_dir_defaults_to_worktree_path() { +- let (inner, mock) = make_mock(); +- let wt = WorktreeSandbox::new(inner, make_config("/tmp/wt")); +- +- wt.spawn_stdio_process("python fake_agent.py", None, None, None) +- .await +- .unwrap(); +- +- let wdirs = mock.captured_working_dirs.lock().unwrap().clone(); +- assert_eq!( +- wdirs.last(), +- Some(&Some("/tmp/wt".to_string())), +- "None working_dir should be replaced with worktree path" +- ); +- } +- +- // ----------------------------------------------------------------------- +- // Accessors +- // ----------------------------------------------------------------------- +- +- // ----------------------------------------------------------------------- +- // Bug: cleanup() destroys worktree, breaking `fabro cp` +- // ----------------------------------------------------------------------- +- +- #[tokio::test] +- async fn cleanup_should_preserve_worktree_for_post_run_access() { +- // The worktree directory must survive cleanup() so that `fabro cp` can +- // access run artifacts afterward. It is pruned separately by `system prune`. +- // LocalSandbox.cleanup() was a no-op; WorktreeSandbox should match. +- let (inner, mock) = make_mock(); +- let wt = WorktreeSandbox::new(inner, make_config("/tmp/wt")); +- +- wt.cleanup().await.unwrap(); +- +- let cmds = mock.captured_commands.lock().unwrap().clone(); +- assert!( +- cmds.is_empty(), +- "cleanup should not issue destructive git commands \ +- (worktree must be preserved for fabro cp), but got: {cmds:?}" +- ); +- } +- +- #[tokio::test] +- async fn lifecycle_operations_forward_to_inner_sandbox() { +- let (inner, mock) = make_mock(); +- let wt = WorktreeSandbox::new(inner, make_config("/tmp/wt")); +- +- wt.start().await.unwrap(); +- wt.stop().await.unwrap(); +- wt.delete().await.unwrap(); +- +- assert_eq!(mock.start_count(), 1); +- assert_eq!(mock.stop_count(), 1); +- assert_eq!(mock.delete_count(), 1); +- } +- +- // ----------------------------------------------------------------------- +- // Bug: initialize() is not idempotent — double call destroys worktree +- // ----------------------------------------------------------------------- +- +- #[tokio::test] +- async fn initialize_is_idempotent_on_second_call() { +- // engine.run_with_lifecycle() calls sandbox.initialize() unconditionally, +- // even when run.rs already called it during sandbox construction. +- // The second call must be a no-op; it must NOT re-run +- // `git worktree remove --force` which would destroy the worktree. +- let (inner, mock) = make_mock(); +- let wt = WorktreeSandbox::new(inner, make_config("/tmp/wt")); +- +- wt.initialize().await.unwrap(); +- let first_count = mock.captured_commands.lock().unwrap().len(); +- +- wt.initialize().await.unwrap(); +- let second_count = mock.captured_commands.lock().unwrap().len(); +- +- assert_eq!( +- first_count, +- second_count, +- "second initialize() should be a no-op, but it issued {} additional commands", +- second_count - first_count +- ); +- } +- +- // ----------------------------------------------------------------------- +- // Bug: file operations resolve against inner working_directory, not worktree +- // ----------------------------------------------------------------------- +- +- #[tokio::test] +- async fn grep_should_search_worktree_not_inner_working_directory() { +- // WorktreeSandbox delegates grep() to the inner sandbox without path +- // adjustment. When the inner LocalSandbox was created with original_cwd, +- // grep("pattern", ".") searches the original repo instead of the worktree. +- let original = +- std::env::temp_dir().join(format!("fabro-test-original-{}", uuid::Uuid::new_v4())); +- let worktree = +- std::env::temp_dir().join(format!("fabro-test-worktree-{}", uuid::Uuid::new_v4())); +- std::fs::create_dir_all(&original).unwrap(); +- std::fs::create_dir_all(&worktree).unwrap(); +- +- // Put a marker file ONLY in the worktree directory +- std::fs::write(worktree.join("marker.txt"), "UNIQUE_WORKTREE_MARKER").unwrap(); +- +- let inner: Arc = Arc::new(LocalSandbox::new(original.clone())); +- let config = WorktreeOptions { +- branch_name: "test-branch".into(), +- base_sha: "abc123".into(), +- worktree_path: worktree.to_string_lossy().to_string(), +- skip_branch_creation: false, +- setup_intent: None, +- }; +- let wt = WorktreeSandbox::new(inner, config); +- +- // working_directory() correctly returns the worktree path +- assert_eq!(wt.working_directory(), worktree.to_string_lossy().as_ref()); +- +- // grep with "." should search the worktree, not the original repo +- let results = wt +- .grep("UNIQUE_WORKTREE_MARKER", ".", &GrepOptions::default()) +- .await +- .unwrap(); +- assert!( +- !results.is_empty(), +- "grep(\".\") should search the worktree directory, not the inner sandbox's working directory" +- ); +- +- std::fs::remove_dir_all(&original).ok(); +- std::fs::remove_dir_all(&worktree).ok(); +- } +- +- #[tokio::test] +- async fn glob_should_search_worktree_when_path_is_none() { +- // WorktreeSandbox delegates glob() to the inner sandbox without path +- // adjustment. LocalSandbox::glob(pattern, None) defaults to +- // self.working_directory, which is the original repo path. +- let original = +- std::env::temp_dir().join(format!("fabro-test-original-{}", uuid::Uuid::new_v4())); +- let worktree = +- std::env::temp_dir().join(format!("fabro-test-worktree-{}", uuid::Uuid::new_v4())); +- std::fs::create_dir_all(&original).unwrap(); +- std::fs::create_dir_all(&worktree).unwrap(); +- +- // Put a file ONLY in the worktree directory +- std::fs::write(worktree.join("worktree_only.txt"), "content").unwrap(); +- +- let inner: Arc = Arc::new(LocalSandbox::new(original.clone())); +- let config = WorktreeOptions { +- branch_name: "test-branch".into(), +- base_sha: "abc123".into(), +- worktree_path: worktree.to_string_lossy().to_string(), +- skip_branch_creation: false, +- setup_intent: None, +- }; +- let wt = WorktreeSandbox::new(inner, config); +- +- let results = wt.glob("*.txt", None).await.unwrap(); +- assert!( +- results.iter().any(|r| r.contains("worktree_only.txt")), +- "glob(pattern, None) should search the worktree directory, not the inner sandbox's working directory. Got: {results:?}" +- ); +- +- std::fs::remove_dir_all(&original).ok(); +- std::fs::remove_dir_all(&worktree).ok(); +- } +- +- #[tokio::test] +- async fn read_file_relative_should_resolve_against_worktree() { +- // WorktreeSandbox delegates read_file() to the inner sandbox without +- // path adjustment. Relative paths resolve against the inner +- // LocalSandbox's working_directory (original repo), not the worktree. +- let original = +- std::env::temp_dir().join(format!("fabro-test-original-{}", uuid::Uuid::new_v4())); +- let worktree = +- std::env::temp_dir().join(format!("fabro-test-worktree-{}", uuid::Uuid::new_v4())); +- std::fs::create_dir_all(&original).unwrap(); +- std::fs::create_dir_all(&worktree).unwrap(); +- +- // Put the file ONLY in the worktree directory +- std::fs::write(worktree.join("only_in_worktree.txt"), "worktree content").unwrap(); +- +- let inner: Arc = Arc::new(LocalSandbox::new(original.clone())); +- let config = WorktreeOptions { +- branch_name: "test-branch".into(), +- base_sha: "abc123".into(), +- worktree_path: worktree.to_string_lossy().to_string(), +- skip_branch_creation: false, +- setup_intent: None, +- }; +- let wt = WorktreeSandbox::new(inner, config); +- +- let result = wt.read_file("only_in_worktree.txt", None, None).await; +- assert!( +- result.is_ok(), +- "read_file with relative path should resolve against worktree, not inner sandbox's working directory. Error: {}", +- result.unwrap_err() +- ); +- +- std::fs::remove_dir_all(&original).ok(); +- std::fs::remove_dir_all(&worktree).ok(); +- } +- +- // ----------------------------------------------------------------------- +- // Accessors +- // ----------------------------------------------------------------------- +- +- #[test] +- fn accessors_return_config_values() { +- let (inner, _mock) = make_mock(); +- let config = WorktreeOptions { +- branch_name: "my-branch".to_string(), +- base_sha: "sha123".to_string(), +- worktree_path: "/path/to/wt".to_string(), +- skip_branch_creation: false, +- setup_intent: None, +- }; +- let wt = WorktreeSandbox::new(inner, config); +- +- assert_eq!(wt.branch_name(), "my-branch"); +- assert_eq!(wt.base_sha(), "sha123"); +- assert_eq!(wt.worktree_path(), "/path/to/wt"); +- } +-} +diff --git a/lib/crates/fabro-store/src/run_state.rs b/lib/crates/fabro-store/src/run_state.rs +index f242acc52..44783d4fc 100644 +--- a/lib/crates/fabro-store/src/run_state.rs ++++ b/lib/crates/fabro-store/src/run_state.rs +@@ -505,13 +505,10 @@ impl RunProjectionReducer for RunProjection { + )?; + } + EventBody::ParallelCompleted(props) => { +- let parallel_results = serde_json::to_value(&props.results).map_err(|err| { +- Error::InvalidEvent(format!("invalid parallel.completed payload: {err}")) +- })?; + let Some(stage) = stage_at_stored_or_current_visit(self, stored, event.seq) else { + return Ok(()); + }; +- stage.parallel_results = Some(parallel_results); ++ stage.parallel_results = Some(props.results.clone()); + } + EventBody::ParallelBranchStarted(_) => { + // Branches bypass the engine's StageStarted/StageCompleted +@@ -529,21 +526,17 @@ impl RunProjectionReducer for RunProjection { + // A branch never emits its own StageCompleted, so finalize it + // here; otherwise the stage spins Running forever after the run + // (and the fan-in) is done. +- let outcome = +- StageOutcome::from_str(&props.status).unwrap_or(StageOutcome::Failed { +- retry_requested: false, +- }); + let Some(stage) = stage_at_stored_or_current_visit(self, stored, event.seq) else { + return Ok(()); + }; + stage.completion = Some(StageCompletion { +- outcome, +- notes: None, ++ outcome: props.status, ++ notes: None, + failure_reason: None, +- timestamp: ts, ++ timestamp: ts, + }); + stage.timing = Some(fabro_types::StageTiming::wall_only(props.duration_ms)); +- stage.state = StageState::from(outcome); ++ stage.state = StageState::from(props.status); + } + EventBody::TodoCreated(props) => { + let Some(stage) = stage_at_stored_or_current_visit(self, stored, event.seq) else { +@@ -2054,8 +2047,7 @@ mod tests { + EventBody::ParallelBranchCompleted(ParallelBranchCompletedProps { + index: 0, + duration_ms: 1234, +- status: "succeeded".to_string(), +- head_sha: None, ++ status: StageOutcome::Succeeded, + }), + branch.clone(), + )) +@@ -2088,8 +2080,9 @@ mod tests { + EventBody::ParallelBranchCompleted(ParallelBranchCompletedProps { + index: 0, + duration_ms: 500, +- status: "failed".to_string(), +- head_sha: None, ++ status: StageOutcome::Failed { ++ retry_requested: false, ++ }, + }), + branch.clone(), + )) +diff --git a/lib/crates/fabro-store/tests/serializable_projection.rs b/lib/crates/fabro-store/tests/serializable_projection.rs +index ffc7fbb03..66600c154 100644 +--- a/lib/crates/fabro-store/tests/serializable_projection.rs ++++ b/lib/crates/fabro-store/tests/serializable_projection.rs +@@ -6,9 +6,9 @@ use fabro_types::graph::Graph; + use fabro_types::run::RunSpec; + use fabro_types::{ + BilledModelUsage, BilledTokenCounts, Checkpoint, CheckpointRecord, InterviewQuestionRecord, +- QuestionType, RunDiff, RunSandbox, RunSandboxInstance, RunSandboxPlan, RunSandboxRuntime, +- RunStatus, SandboxProviderKind, StageCompletion, StageModelUsage, StageOutcome, StartRecord, +- WorkflowSettings, first_event_seq, fixtures, test_support, ++ ParallelBranchResult, QuestionType, RunDiff, RunSandbox, RunSandboxInstance, RunSandboxPlan, ++ RunSandboxRuntime, RunStatus, SandboxProviderKind, StageCompletion, StageModelUsage, ++ StageOutcome, StartRecord, WorkflowSettings, first_event_seq, fixtures, test_support, + }; + use serde_json::json; + +@@ -143,7 +143,12 @@ fn serializable_projection_round_trips_and_trims_bulky_node_fields() { + stage.diff = Some("diff --git a/a b/a".to_string()); + stage.script_invocation = Some(json!({ "command": "cargo test" })); + stage.script_timing = Some(json!({ "duration_ms": 10 })); +- stage.parallel_results = Some(json!([{ "stage": "fanout@1" }])); ++ let parallel_results = vec![ParallelBranchResult { ++ id: "review".to_string(), ++ status: StageOutcome::Succeeded, ++ context_updates: BTreeMap::from([("response.review".to_string(), json!("looks good"))]), ++ }]; ++ stage.parallel_results = Some(parallel_results.clone()); + stage.timing = Some(fabro_types::StageTiming::wall_only(1234)); + let usage = sample_usage(); + let usage_counts = BilledTokenCounts::from_billed_usage(std::slice::from_ref(&usage)); +@@ -203,10 +208,7 @@ fn serializable_projection_round_trips_and_trims_bulky_node_fields() { + Some(json!({ "command": "cargo test" })) + ); + assert_eq!(node.script_timing, Some(json!({ "duration_ms": 10 }))); +- assert_eq!( +- node.parallel_results, +- Some(json!([{ "stage": "fanout@1" }])) +- ); ++ assert_eq!(node.parallel_results, Some(parallel_results)); + assert_eq!(node.timing.map(|t| t.wall_time_ms), Some(1234)); + assert_eq!(node.usage, usage_counts); + assert_eq!(node.model.as_ref(), Some(usage.model())); +diff --git a/lib/crates/fabro-types/src/lib.rs b/lib/crates/fabro-types/src/lib.rs +index e339f285d..752d67dde 100644 +--- a/lib/crates/fabro-types/src/lib.rs ++++ b/lib/crates/fabro-types/src/lib.rs +@@ -19,6 +19,7 @@ pub mod manifest_path; + pub mod mcp_store; + pub mod outcome; + pub mod pair; ++pub mod parallel; + pub mod principal; + pub mod pull_request; + pub mod repository; +@@ -93,6 +94,7 @@ pub use pair::{ + PairTranscriptWarning, RunEventDetailContent, RunEventDetailContentKind, + RunEventDetailEnvelope, RunEventDetailResponse, RunPairStatusResponse, + }; ++pub use parallel::ParallelBranchResult; + pub use principal::{AuthMethod, Principal, SystemActorKind, UserPrincipal}; + pub use pull_request::{ + CheckRun, CheckRunStatus, PullRequest, PullRequestDetails, PullRequestDetailsStatus, +diff --git a/lib/crates/fabro-types/src/parallel.rs b/lib/crates/fabro-types/src/parallel.rs +new file mode 100644 +index 000000000..fc4a2563a +--- /dev/null ++++ b/lib/crates/fabro-types/src/parallel.rs +@@ -0,0 +1,14 @@ ++use std::collections::BTreeMap; ++ ++use serde::{Deserialize, Serialize}; ++use serde_json::Value; ++ ++use crate::StageOutcome; ++ ++#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] ++pub struct ParallelBranchResult { ++ pub id: String, ++ pub status: StageOutcome, ++ #[serde(default)] ++ pub context_updates: BTreeMap, ++} +diff --git a/lib/crates/fabro-types/src/run_event/infra.rs b/lib/crates/fabro-types/src/run_event/infra.rs +index 3abd80c12..8af295ffa 100644 +--- a/lib/crates/fabro-types/src/run_event/infra.rs ++++ b/lib/crates/fabro-types/src/run_event/infra.rs +@@ -30,7 +30,6 @@ pub enum RunNoticeCode { + GitPushFailed, + GithubTokenFailed, + GithubTokenRefreshLimited, +- ParallelBaseCheckpointFailed, + PullRequestFailed, + SandboxCleanupFailed, + SandboxGitUnavailable, +diff --git a/lib/crates/fabro-types/src/run_event/misc.rs b/lib/crates/fabro-types/src/run_event/misc.rs +index 3f88cdbd3..84276a3bd 100644 +--- a/lib/crates/fabro-types/src/run_event/misc.rs ++++ b/lib/crates/fabro-types/src/run_event/misc.rs +@@ -1,8 +1,7 @@ + use serde::{Deserialize, Serialize}; +-use serde_json::Value; + + use super::ExecOutputTail; +-use crate::{CommandTermination, PullRequestLink}; ++use crate::{CommandTermination, ParallelBranchResult, PullRequestLink, StageOutcome}; + + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] + pub struct InterviewOption { +@@ -18,7 +17,6 @@ pub struct InterviewOption { + pub struct ParallelStartedProps { + pub visit: u32, + pub branch_count: usize, +- pub join_policy: String, + } + + #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +@@ -30,9 +28,7 @@ pub struct ParallelBranchStartedProps { + pub struct ParallelBranchCompletedProps { + pub index: usize, + pub duration_ms: u64, +- pub status: String, +- #[serde(default, skip_serializing_if = "Option::is_none")] +- pub head_sha: Option, ++ pub status: StageOutcome, + } + + #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +@@ -42,7 +38,7 @@ pub struct ParallelCompletedProps { + pub success_count: usize, + pub failure_count: usize, + #[serde(default, skip_serializing_if = "Vec::is_empty")] +- pub results: Vec, ++ pub results: Vec, + } + + #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +@@ -106,23 +102,6 @@ pub struct GitPushProps { + pub exec_output_tail: Option, + } + +-#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +-pub struct GitBranchProps { +- pub branch: String, +- pub sha: String, +-} +- +-#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +-pub struct GitWorktreeAddProps { +- pub path: String, +- pub branch: String, +-} +- +-#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +-pub struct GitWorktreeRemoveProps { +- pub path: String, +-} +- + #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] + pub struct GitFetchProps { + pub branch: String, +diff --git a/lib/crates/fabro-types/src/run_event/mod.rs b/lib/crates/fabro-types/src/run_event/mod.rs +index cb1c38b81..205b17d74 100644 +--- a/lib/crates/fabro-types/src/run_event/mod.rs ++++ b/lib/crates/fabro-types/src/run_event/mod.rs +@@ -176,12 +176,6 @@ pub enum EventBody { + GitCommit(GitCommitProps), + #[serde(rename = "git.push")] + GitPush(GitPushProps), +- #[serde(rename = "git.branch")] +- GitBranch(GitBranchProps), +- #[serde(rename = "git.worktree.added")] +- GitWorktreeAdd(GitWorktreeAddProps), +- #[serde(rename = "git.worktree.removed")] +- GitWorktreeRemove(GitWorktreeRemoveProps), + #[serde(rename = "git.fetch")] + GitFetch(GitFetchProps), + #[serde(rename = "git.reset")] +@@ -477,9 +471,6 @@ impl EventBody { + Self::CheckpointFailed(_) => "checkpoint.failed", + Self::GitCommit(_) => "git.commit", + Self::GitPush(_) => "git.push", +- Self::GitBranch(_) => "git.branch", +- Self::GitWorktreeAdd(_) => "git.worktree.added", +- Self::GitWorktreeRemove(_) => "git.worktree.removed", + Self::GitFetch(_) => "git.fetch", + Self::GitReset(_) => "git.reset", + Self::EdgeSelected(_) => "edge.selected", +@@ -649,9 +640,6 @@ fn is_known_event_name(event: &str) -> bool { + | "checkpoint.failed" + | "git.commit" + | "git.push" +- | "git.branch" +- | "git.worktree.added" +- | "git.worktree.removed" + | "git.fetch" + | "git.reset" + | "edge.selected" +diff --git a/lib/crates/fabro-types/src/run_projection.rs b/lib/crates/fabro-types/src/run_projection.rs +index 6b67282ee..f251d6182 100644 +--- a/lib/crates/fabro-types/src/run_projection.rs ++++ b/lib/crates/fabro-types/src/run_projection.rs +@@ -75,7 +75,6 @@ impl StageModelUsage { + pub const MODE_PROMPT: &'static str = "prompt"; + pub const MODE_AGENT: &'static str = "agent"; + pub const MODE_ACP: &'static str = "acp"; +- pub const MODE_FAN_IN: &'static str = "fan_in"; + + /// Build the usage record from a `stage.prompt` event, returning `None` + /// when the event carried no model metadata. +@@ -328,7 +327,7 @@ pub struct StageProjection { + pub diff: Option, + pub script_invocation: Option, + pub script_timing: Option, +- pub parallel_results: Option, ++ pub parallel_results: Option>, + pub output: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub output_bytes: Option, +diff --git a/lib/crates/fabro-validate/src/lib.rs b/lib/crates/fabro-validate/src/lib.rs +index e5430c481..3747b677c 100644 +--- a/lib/crates/fabro-validate/src/lib.rs ++++ b/lib/crates/fabro-validate/src/lib.rs +@@ -257,6 +257,54 @@ reasoning = false + assert!(result.is_ok()); + } + ++ #[test] ++ fn validate_rejects_join_policy_on_any_node() { ++ let mut g = minimal_valid_graph(); ++ ++ let mut fork = Node::new("fork"); ++ fork.attrs.insert( ++ "shape".to_string(), ++ AttrValue::String("component".to_string()), ++ ); ++ fork.attrs.insert( ++ "join_policy".to_string(), ++ AttrValue::String("wait_all".to_string()), ++ ); ++ g.nodes.insert("fork".to_string(), fork); ++ ++ let mut custom = Node::new("custom"); ++ custom.attrs.insert( ++ "type".to_string(), ++ AttrValue::String("custom.handler".to_string()), ++ ); ++ custom.attrs.insert( ++ "join_policy".to_string(), ++ AttrValue::String("first_success".to_string()), ++ ); ++ g.nodes.insert("custom".to_string(), custom); ++ ++ let diagnostics = validate(&g, &[]); ++ let removed = diagnostics ++ .iter() ++ .filter(|d| d.rule == "join_policy_removed") ++ .collect::>(); ++ ++ assert_eq!(removed.len(), 2, "diagnostics: {diagnostics:?}"); ++ assert!(removed.iter().all(|d| d.severity == Severity::Error)); ++ assert!( ++ removed ++ .iter() ++ .all(|d| d.message.contains("Remove 'join_policy'")) ++ ); ++ assert_eq!( ++ removed ++ .iter() ++ .filter_map(|d| d.node_id.as_deref()) ++ .collect::>(), ++ std::collections::BTreeSet::from(["custom", "fork"]), ++ ); ++ } ++ + #[test] + fn validate_or_raise_fails_for_missing_start() { + let mut g = Graph::new("test"); +diff --git a/lib/crates/fabro-validate/src/rules/inert_attribute.rs b/lib/crates/fabro-validate/src/rules/inert_attribute.rs +index fef9c5454..a740d97b8 100644 +--- a/lib/crates/fabro-validate/src/rules/inert_attribute.rs ++++ b/lib/crates/fabro-validate/src/rules/inert_attribute.rs +@@ -18,7 +18,6 @@ const HANDLER_SPECIFIC_ATTRS: &[(&str, &[&str])] = &[ + ("script", &["command"]), + ("language", &["command"]), + ("duration", &["wait"]), +- ("join_policy", &["parallel"]), + ("max_parallel", &["parallel"]), + ("output_schema", &["agent", "prompt"]), + ("prompt", &["agent", "prompt", "parallel.fan_in"]), +@@ -140,15 +139,11 @@ mod tests { + fn warns_on_parallel_attrs_on_agent_node() { + let mut g = minimal_graph(); + let mut node = Node::new("work"); +- node.attrs.insert( +- "join_policy".to_string(), +- AttrValue::String("wait_all".to_string()), +- ); + node.attrs + .insert("max_parallel".to_string(), AttrValue::Integer(4)); + g.nodes.insert("work".to_string(), node); + let d = Rule.apply(&g); +- assert_eq!(d.len(), 2); ++ assert_eq!(d.len(), 1); + } + + #[test] +@@ -180,7 +175,7 @@ mod tests { + ); + g.nodes.insert( + "fork".to_string(), +- node_with_attr("fork", "component", "join_policy", "wait_all"), ++ node_with_attr("fork", "component", "max_parallel", "4"), + ); + g.nodes.insert( + "spec".to_string(), +diff --git a/lib/crates/fabro-validate/src/rules/join_policy_removed.rs b/lib/crates/fabro-validate/src/rules/join_policy_removed.rs +new file mode 100644 +index 000000000..b96f8b446 +--- /dev/null ++++ b/lib/crates/fabro-validate/src/rules/join_policy_removed.rs +@@ -0,0 +1,35 @@ ++use fabro_graphviz::graph::Graph; ++ ++use crate::{Diagnostic, LintRule, Severity}; ++ ++pub(super) fn rule() -> Box { ++ Box::new(Rule) ++} ++ ++struct Rule; ++ ++impl LintRule for Rule { ++ fn name(&self) -> &'static str { ++ "join_policy_removed" ++ } ++ ++ fn apply(&self, graph: &Graph) -> Vec { ++ graph ++ .nodes ++ .values() ++ .filter(|node| node.attrs.contains_key("join_policy")) ++ .map(|node| Diagnostic { ++ rule: self.name().to_string(), ++ severity: Severity::Error, ++ message: format!( ++ "Node '{}' sets the removed 'join_policy' attribute. Remove 'join_policy'; parallel nodes always wait for every branch to finish", ++ node.id, ++ ), ++ node_id: Some(node.id.clone()), ++ edge: None, ++ fix: Some("Remove 'join_policy' from this node".to_string()), ++ ..Diagnostic::default() ++ }) ++ .collect() ++ } ++} +diff --git a/lib/crates/fabro-validate/src/rules/mod.rs b/lib/crates/fabro-validate/src/rules/mod.rs +index 428004240..cb4a5a18a 100644 +--- a/lib/crates/fabro-validate/src/rules/mod.rs ++++ b/lib/crates/fabro-validate/src/rules/mod.rs +@@ -9,6 +9,7 @@ mod freeform_edge_count; + mod goal_gate_has_retry; + mod import_error; + mod inert_attribute; ++mod join_policy_removed; + mod model_support; + mod node_model_known; + mod orphan_custom_outcome; +@@ -58,6 +59,7 @@ pub fn built_in_rules() -> Vec> { + orphan_custom_outcome::rule(), + script_absolute_cd::rule(), + import_error::rule(), ++ join_policy_removed::rule(), + unresolved_file_ref::rule(), + thread_id_requires_fidelity_full::rule(), + selection_valid::rule(), +diff --git a/lib/crates/fabro-workflow/README.md b/lib/crates/fabro-workflow/README.md +index 655bca79a..21deba55b 100644 +--- a/lib/crates/fabro-workflow/README.md ++++ b/lib/crates/fabro-workflow/README.md +@@ -140,7 +140,7 @@ Nodes with `shape=hexagon` or `type="human"` pause execution for human input. Ou + + ### Parallel Execution + +-Nodes with `shape=component` fan out to branches concurrently. Configurable join policies: `wait_all` (default), `first_success`. ++Nodes with `shape=component` fan out to branches concurrently. Branches receive isolated context forks, share the same sandbox checkout, and always finish before the workflow continues. Use `max_parallel` to limit concurrency; concurrent workspace writes are user-managed. + + ### Checkpoints and Resume + +diff --git a/lib/crates/fabro-workflow/src/artifact.rs b/lib/crates/fabro-workflow/src/artifact.rs +index 3f9f9eb7d..26de9edd8 100644 +--- a/lib/crates/fabro-workflow/src/artifact.rs ++++ b/lib/crates/fabro-workflow/src/artifact.rs +@@ -3,7 +3,9 @@ use std::path::{Path, PathBuf}; + + use fabro_agent::Sandbox; + use fabro_config::RunScratch; +-use fabro_types::{RunBlobId, format_blob_ref, parse_blob_ref, parse_managed_blob_file_ref}; ++use fabro_types::{ ++ ParallelBranchResult, RunBlobId, format_blob_ref, parse_blob_ref, parse_managed_blob_file_ref, ++}; + use futures::future::BoxFuture; + use serde_json::Value; + use tokio::fs; +@@ -27,6 +29,10 @@ const ARTIFACT_POINTER_PREFIX: &str = "file://"; + /// and replaced with a `"blob://sha256/{blob_id}"` reference. + /// Small values are left untouched. + /// ++/// `parallel.results` is offloaded leaf-wise instead of as one value so it ++/// stays a structured array that fan-in prompts, projections, and the UI can ++/// read without hydrating the whole payload. ++/// + /// # Errors + /// + /// Returns an error if blob persistence fails. +@@ -34,21 +40,79 @@ pub async fn offload_large_values( + updates: &mut HashMap, + run_store: &RunStoreHandle, + ) -> Result<()> { +- for value in updates.values_mut() { +- let bytes = serde_json::to_vec(&*value) +- .map_err(|e| Error::engine_with_source("artifact serialize failed", e))?; ++ for (key, value) in updates { ++ if key == context::keys::PARALLEL_RESULTS { ++ offload_large_leaves(value, run_store).await?; ++ } else { ++ offload_value(value, run_store).await?; ++ } ++ } ++ Ok(()) ++} + +- if bytes.len() > BLOB_OFFLOAD_THRESHOLD { +- let blob_id = run_store +- .write_blob(&bytes) +- .await +- .map_err(|e| Error::engine_with_anyhow("artifact blob write failed", e))?; +- *value = Value::String(format_blob_ref(&blob_id)); ++/// Offload large leaves of typed parallel branch results before they are ++/// emitted through `parallel.completed` and stored in projections. ++/// ++/// # Errors ++/// ++/// Returns an error if blob persistence fails. ++pub async fn offload_parallel_branch_updates( ++ results: &mut [ParallelBranchResult], ++ run_store: &RunStoreHandle, ++) -> Result<()> { ++ for result in results.iter_mut() { ++ for value in result.context_updates.values_mut() { ++ offload_large_leaves(value, run_store).await?; + } + } + Ok(()) + } + ++fn offload_large_leaves<'a>( ++ value: &'a mut Value, ++ run_store: &'a RunStoreHandle, ++) -> BoxFuture<'a, Result<()>> { ++ Box::pin(async move { ++ match value { ++ Value::Array(items) => { ++ for item in items { ++ offload_large_leaves(item, run_store).await?; ++ } ++ } ++ Value::Object(map) => { ++ for item in map.values_mut() { ++ offload_large_leaves(item, run_store).await?; ++ } ++ } ++ Value::String(_) | Value::Null | Value::Bool(_) | Value::Number(_) => { ++ offload_value(value, run_store).await?; ++ } ++ } ++ Ok(()) ++ }) ++} ++ ++async fn offload_value(value: &mut Value, run_store: &RunStoreHandle) -> Result<()> { ++ // JSON escaping expands a string to at most 6 bytes per char plus quotes, ++ // so short strings can never cross the threshold — skip serializing them. ++ if let Value::String(text) = &*value { ++ if text.len().saturating_mul(6) + 2 <= BLOB_OFFLOAD_THRESHOLD { ++ return Ok(()); ++ } ++ } ++ let bytes = serde_json::to_vec(&*value) ++ .map_err(|e| Error::engine_with_source("artifact serialize failed", e))?; ++ ++ if bytes.len() > BLOB_OFFLOAD_THRESHOLD { ++ let blob_id = run_store ++ .write_blob(&bytes) ++ .await ++ .map_err(|e| Error::engine_with_anyhow("artifact blob write failed", e))?; ++ *value = Value::String(format_blob_ref(&blob_id)); ++ } ++ Ok(()) ++} ++ + /// Extract the file path from an artifact pointer value. + /// + /// Returns `Some(path)` if the value is a string starting with `"file://"`, +@@ -264,6 +328,10 @@ fn resolve_execution_values<'a>( + }) + } + ++fn is_text_context_key(key: &str) -> bool { ++ key == context::keys::COMMAND_OUTPUT || key.starts_with(context::keys::RESPONSE_PREFIX) ++} ++ + fn resolve_execution_value<'a>( + key: Option<&'a str>, + value: &'a mut Value, +@@ -274,7 +342,7 @@ fn resolve_execution_value<'a>( + Box::pin(async move { + match value { + Value::String(current) => { +- if matches!(key, Some(context::keys::COMMAND_OUTPUT)) { ++ if key.is_some_and(is_text_context_key) { + *current = resolve_text_or_blob_ref_str(current, run_store).await?; + } else if let Some(blob_id) = parse_blob_ref(current) { + *current = materialize_blob_ref(&blob_id, run_store, env, run_dir).await?; +@@ -286,12 +354,19 @@ fn resolve_execution_value<'a>( + } + Value::Array(items) => { + for item in items { +- resolve_execution_value(None, item, run_store, env, run_dir).await?; ++ resolve_execution_value(key, item, run_store, env, run_dir).await?; + } + } + Value::Object(map) => { +- for item in map.values_mut() { +- resolve_execution_value(None, item, run_store, env, run_dir).await?; ++ for (child_key, item) in map.iter_mut() { ++ resolve_execution_value( ++ Some(child_key.as_str()), ++ item, ++ run_store, ++ env, ++ run_dir, ++ ) ++ .await?; + } + } + Value::Null | Value::Bool(_) | Value::Number(_) => {} +@@ -306,15 +381,12 @@ async fn materialize_blob_ref( + env: &dyn Sandbox, + run_dir: &Path, + ) -> Result { +- let bytes = run_store +- .read_blob(blob_id) +- .await +- .map_err(|e| Error::engine_with_anyhow("artifact blob read failed", e))? +- .ok_or_else(|| Error::engine(format!("artifact blob missing: {blob_id}")))?; +- ++ // Blobs are content-addressed, so an existing materialized file is always ++ // current — check before paying for the store read. + if is_local_execution(env, run_dir).await? { + let path = local_materialized_blob_path(run_dir, blob_id); + if !path.exists() { ++ let bytes = read_required_blob(blob_id, run_store).await?; + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).await.map_err(|err| { + Error::Io(format!( +@@ -336,6 +408,7 @@ async fn materialize_blob_ref( + .await + .map_err(|e| Error::engine_with_source("failed to check blob existence", e))? + { ++ let bytes = read_required_blob(blob_id, run_store).await?; + let content = String::from_utf8(bytes.to_vec()) + .map_err(|e| Error::engine_with_source("artifact blob was not valid UTF-8 JSON", e))?; + env.write_file(&remote_path, &content).await.map_err(|e| { +@@ -346,6 +419,17 @@ async fn materialize_blob_ref( + Ok(format!("{ARTIFACT_POINTER_PREFIX}{remote_path}")) + } + ++async fn read_required_blob( ++ blob_id: &RunBlobId, ++ run_store: &RunStoreHandle, ++) -> Result { ++ run_store ++ .read_blob(blob_id) ++ .await ++ .map_err(|e| Error::engine_with_anyhow("artifact blob read failed", e))? ++ .ok_or_else(|| Error::engine(format!("artifact blob missing: {blob_id}"))) ++} ++ + async fn resolve_explicit_file_ref(value: &str, env: &dyn Sandbox) -> Result { + let local_path = value + .strip_prefix(ARTIFACT_POINTER_PREFIX) +@@ -466,6 +550,47 @@ mod tests { + assert_eq!(updates.get("small_key").unwrap(), &small_value); + } + ++ #[tokio::test] ++ async fn offload_preserves_parallel_results_and_replaces_only_large_leaves() { ++ let run_store = make_run_store("parallel-result-artifact-offload").await; ++ let large_response = "r".repeat(BLOB_OFFLOAD_THRESHOLD + 1); ++ let large_output = "o".repeat(BLOB_OFFLOAD_THRESHOLD + 1); ++ let mut updates = HashMap::from([( ++ context::keys::PARALLEL_RESULTS.to_string(), ++ serde_json::json!([{ ++ "id": "branch_a", ++ "status": "failed", ++ "context_updates": { ++ "response.branch_a": large_response, ++ "command.output": large_output, ++ "small": "kept inline", ++ } ++ }]), ++ )]); ++ ++ offload_large_values(&mut updates, &run_store.clone().into()) ++ .await ++ .unwrap(); ++ ++ let results = updates[context::keys::PARALLEL_RESULTS] ++ .as_array() ++ .expect("parallel.results must remain a structured array"); ++ let branch_updates = results[0]["context_updates"] ++ .as_object() ++ .expect("context_updates must remain a structured object"); ++ assert!( ++ branch_updates["response.branch_a"] ++ .as_str() ++ .is_some_and(|value| fabro_types::parse_blob_ref(value).is_some()) ++ ); ++ assert!( ++ branch_updates[context::keys::COMMAND_OUTPUT] ++ .as_str() ++ .is_some_and(|value| fabro_types::parse_blob_ref(value).is_some()) ++ ); ++ assert_eq!(branch_updates["small"], serde_json::json!("kept inline")); ++ } ++ + #[test] + fn artifact_path_extracts_path_from_pointer() { + let value = serde_json::json!("file:///tmp/logs/runtime/blobs/response.plan.json"); +@@ -487,6 +612,58 @@ mod tests { + assert_eq!(artifact_path(&value), None); + } + ++ #[tokio::test] ++ async fn resolve_context_hydrates_nested_parallel_text_blob_references() { ++ let run_store = make_run_store("parallel-result-text-resolution").await; ++ let response = "full branch response"; ++ let output = "full command output"; ++ let response_blob = run_store ++ .write_blob(&serde_json::to_vec(response).unwrap()) ++ .await ++ .unwrap(); ++ let output_blob = run_store ++ .write_blob(&serde_json::to_vec(output).unwrap()) ++ .await ++ .unwrap(); ++ let unrelated_blob = run_store ++ .write_blob(&serde_json::to_vec("unrelated artifact").unwrap()) ++ .await ++ .unwrap(); ++ let context = Context::new(); ++ context.set( ++ context::keys::PARALLEL_RESULTS, ++ serde_json::json!([{ ++ "id": "branch_a", ++ "status": "succeeded", ++ "context_updates": { ++ "response.branch_a": fabro_types::format_blob_ref(&response_blob), ++ "command.output": fabro_types::format_blob_ref(&output_blob), ++ "report": fabro_types::format_blob_ref(&unrelated_blob), ++ } ++ }]), ++ ); ++ let env = TestSyncEnv::new(true, "/workspace"); ++ let run_dir = tempfile::tempdir().unwrap(); ++ ++ let resolved = ++ resolved_context_snapshot(&context, &run_store.clone().into(), &env, run_dir.path()) ++ .await ++ .unwrap(); ++ ++ let updates = &resolved[context::keys::PARALLEL_RESULTS][0]["context_updates"]; ++ assert_eq!(updates["response.branch_a"], serde_json::json!(response)); ++ assert_eq!( ++ updates[context::keys::COMMAND_OUTPUT], ++ serde_json::json!(output) ++ ); ++ assert!( ++ updates["report"] ++ .as_str() ++ .is_some_and(|value| value.starts_with("file://")), ++ "non-textual nested values should retain artifact semantics" ++ ); ++ } ++ + #[test] + fn normalize_durable_updates_rewrites_managed_blob_file_refs_recursively() { + let blob_id = fabro_types::RunBlobId::new(b"hello"); +diff --git a/lib/crates/fabro-workflow/src/context.rs b/lib/crates/fabro-workflow/src/context.rs +index af1233586..1926c42db 100644 +--- a/lib/crates/fabro-workflow/src/context.rs ++++ b/lib/crates/fabro-workflow/src/context.rs +@@ -40,9 +40,6 @@ pub mod keys { + // --- parallel.* keys --- + pub const PARALLEL_RESULTS: &str = "parallel.results"; + pub const PARALLEL_BRANCH_COUNT: &str = "parallel.branch_count"; +- pub const PARALLEL_FAN_IN_BEST_ID: &str = "parallel.fan_in.best_id"; +- pub const PARALLEL_FAN_IN_BEST_OUTCOME: &str = "parallel.fan_in.best_outcome"; +- pub const PARALLEL_FAN_IN_BEST_HEAD_SHA: &str = "parallel.fan_in.best_head_sha"; + + // --- Prefix constants (for filtering and dynamic keys) --- + pub const GRAPH_PREFIX: &str = "graph."; +@@ -132,18 +129,36 @@ pub mod keys { + } + } + ++use std::collections::HashMap; ++ + pub use fabro_core::Context; + use fabro_graphviz::Fidelity; +-use fabro_types::{ParallelBranchId, StageId}; ++use fabro_types::{ParallelBranchId, RunId, StageId}; + ++use crate::error::Error; + use crate::event::StageScope; + ++/// Keys whose values changed or were added in `after` relative to `before`. ++/// Takes `after` by value so changed entries move instead of clone. ++pub(crate) fn context_diff( ++ before: &HashMap, ++ after: HashMap, ++) -> HashMap { ++ after ++ .into_iter() ++ .filter(|(key, value)| before.get(key) != Some(value)) ++ .collect() ++} ++ + /// Domain-specific typed accessors for workflow context values. + pub trait WorkflowContext { + fn fidelity(&self) -> Fidelity; + fn thread_id(&self) -> Option; + fn preamble(&self) -> String; + fn run_id(&self) -> String; ++ /// Parse `internal.run_id`, failing when the engine did not seed a ++ /// valid run ID. ++ fn parsed_run_id(&self) -> Result; + fn parallel_group_id(&self) -> Option; + fn parallel_branch_id(&self) -> Option; + /// Build the stage-level emit scope from the currently-executing node and +@@ -172,6 +187,12 @@ impl WorkflowContext for Context { + self.get_string(keys::INTERNAL_RUN_ID, "unknown") + } + ++ fn parsed_run_id(&self) -> Result { ++ self.run_id() ++ .parse() ++ .map_err(|err| Error::handler_with_source("invalid internal run_id", err)) ++ } ++ + fn parallel_group_id(&self) -> Option { + self.get(keys::INTERNAL_PARALLEL_GROUP_ID) + .and_then(|value| serde_json::from_value(value).ok()) +diff --git a/lib/crates/fabro-workflow/src/event/convert.rs b/lib/crates/fabro-workflow/src/event/convert.rs +index 3238e153d..5114bdac7 100644 +--- a/lib/crates/fabro-workflow/src/event/convert.rs ++++ b/lib/crates/fabro-workflow/src/event/convert.rs +@@ -371,12 +371,10 @@ fn event_body_from_event(event: &Event) -> EventBody { + Event::ParallelStarted { + visit, + branch_count, +- join_policy, + .. + } => EventBody::ParallelStarted(fabro_types::ParallelStartedProps { + visit: *visit, + branch_count: *branch_count, +- join_policy: join_policy.clone(), + }), + Event::ParallelBranchStarted { index, .. } => { + EventBody::ParallelBranchStarted(fabro_types::ParallelBranchStartedProps { +@@ -387,13 +385,11 @@ fn event_body_from_event(event: &Event) -> EventBody { + index, + duration_ms, + status, +- head_sha, + .. + } => EventBody::ParallelBranchCompleted(fabro_types::ParallelBranchCompletedProps { + index: *index, + duration_ms: *duration_ms, +- status: status.clone(), +- head_sha: head_sha.clone(), ++ status: *status, + }), + Event::ParallelCompleted { + visit, +@@ -516,19 +512,6 @@ fn event_body_from_event(event: &Event) -> EventBody { + success: *success, + exec_output_tail: exec_output_tail.clone(), + }), +- Event::GitBranch { branch, sha } => EventBody::GitBranch(fabro_types::GitBranchProps { +- branch: branch.clone(), +- sha: sha.clone(), +- }), +- Event::GitWorktreeAdd { path, branch } => { +- EventBody::GitWorktreeAdd(fabro_types::GitWorktreeAddProps { +- path: path.clone(), +- branch: branch.clone(), +- }) +- } +- Event::GitWorktreeRemove { path } => { +- EventBody::GitWorktreeRemove(fabro_types::GitWorktreeRemoveProps { path: path.clone() }) +- } + Event::GitFetch { branch, success } => EventBody::GitFetch(fabro_types::GitFetchProps { + branch: branch.clone(), + success: *success, +@@ -1716,15 +1699,96 @@ mod tests { + } + + #[test] +- fn parallel_started_populates_parallel_group_id() { ++ fn parallel_started_populates_group_id_and_public_properties() { + let stored = to_run_event(&fixtures::RUN_1, &Event::ParallelStarted { + node_id: "fanout".to_string(), + visit: 2, + branch_count: 3, +- join_policy: "wait_all".to_string(), + }); + assert_eq!(stored.parallel_group_id, Some(StageId::new("fanout", 2))); + assert!(stored.parallel_branch_id.is_none()); ++ assert_eq!( ++ stored.properties().unwrap(), ++ serde_json::json!({ ++ "visit": 2, ++ "branch_count": 3, ++ }) ++ ); ++ } ++ ++ #[test] ++ fn parallel_branch_completed_public_properties() { ++ let group_id = StageId::new("fanout", 2); ++ let stored = to_run_event(&fixtures::RUN_1, &Event::ParallelBranchCompleted { ++ parallel_group_id: group_id.clone(), ++ parallel_branch_id: ParallelBranchId::new(group_id, 1), ++ branch: "review".to_string(), ++ index: 1, ++ duration_ms: 42, ++ status: StageOutcome::Succeeded, ++ }); ++ ++ assert_eq!( ++ stored.properties().unwrap(), ++ serde_json::json!({ ++ "index": 1, ++ "duration_ms": 42, ++ "status": "succeeded", ++ }) ++ ); ++ } ++ ++ #[test] ++ fn parallel_completed_exposes_typed_results_in_input_order() { ++ let stored = to_run_event(&fixtures::RUN_1, &Event::ParallelCompleted { ++ node_id: "fanout".to_string(), ++ visit: 2, ++ duration_ms: 84, ++ success_count: 1, ++ failure_count: 1, ++ results: vec![ ++ ::fabro_types::ParallelBranchResult { ++ id: "review_api".to_string(), ++ status: StageOutcome::Succeeded, ++ context_updates: BTreeMap::from([( ++ "response.review_api".to_string(), ++ serde_json::json!("looks good"), ++ )]), ++ }, ++ ::fabro_types::ParallelBranchResult { ++ id: "review_ux".to_string(), ++ status: StageOutcome::Failed { ++ retry_requested: false, ++ }, ++ context_updates: BTreeMap::from([( ++ "response.review_ux".to_string(), ++ serde_json::json!("needs work"), ++ )]), ++ }, ++ ], ++ }); ++ ++ assert_eq!( ++ stored.properties().unwrap(), ++ serde_json::json!({ ++ "visit": 2, ++ "duration_ms": 84, ++ "success_count": 1, ++ "failure_count": 1, ++ "results": [ ++ { ++ "id": "review_api", ++ "status": "succeeded", ++ "context_updates": {"response.review_api": "looks good"}, ++ }, ++ { ++ "id": "review_ux", ++ "status": "failed", ++ "context_updates": {"response.review_ux": "needs work"}, ++ }, ++ ], ++ }) ++ ); + } + + #[test] +diff --git a/lib/crates/fabro-workflow/src/event/emitter.rs b/lib/crates/fabro-workflow/src/event/emitter.rs +index dc50a56dd..339919b18 100644 +--- a/lib/crates/fabro-workflow/src/event/emitter.rs ++++ b/lib/crates/fabro-workflow/src/event/emitter.rs +@@ -3,7 +3,6 @@ use std::sync::atomic::{AtomicI64, Ordering}; + + use ::fabro_types::{ExecOutputTail, RunEvent, RunId, RunNoticeCode, RunNoticeLevel}; + use chrono::Utc; +-use fabro_agent::{WorktreeEvent, WorktreeEventCallback}; + + use super::Event; + use super::convert::to_run_event_at; +@@ -139,22 +138,6 @@ impl Emitter { + pub fn touch(&self) { + self.last_event_at.store(epoch_millis(), Ordering::Relaxed); + } +- +- /// Build a [`WorktreeEventCallback`] that forwards worktree lifecycle +- /// events as [`Event`]s on this emitter. +- pub fn worktree_callback(self: Arc) -> WorktreeEventCallback { +- Arc::new(move |event| match event { +- WorktreeEvent::BranchCreated { branch, sha } => { +- self.emit(&Event::GitBranch { branch, sha }); +- } +- WorktreeEvent::WorktreeAdded { path, branch } => { +- self.emit(&Event::GitWorktreeAdd { path, branch }); +- } +- WorktreeEvent::WorktreeRemoved { path } => { +- self.emit(&Event::GitWorktreeRemove { path }); +- } +- }) +- } + } + + #[cfg(test)] +diff --git a/lib/crates/fabro-workflow/src/event/events.rs b/lib/crates/fabro-workflow/src/event/events.rs +index 0b5afb1c7..ed940c5b1 100644 +--- a/lib/crates/fabro-workflow/src/event/events.rs ++++ b/lib/crates/fabro-workflow/src/event/events.rs +@@ -3,10 +3,10 @@ use std::collections::BTreeMap; + use ::fabro_types::{ + AutomationRef, BilledTokenCounts, BlockedReason, CommandTermination, DiffSummary, + FailureReason, ForkSourceRef, GitContext, PairId, PairMessageId, PairSystemMessageKind, +- PairTarget, ParallelBranchId, PendingReason, PermissionLevel, Principal, PullRequestLink, +- RunBlobId, RunFailure, RunId, RunNoticeLevel, RunPairEndedReason, RunPairFailedReason, +- RunProvenance, RunRunnableSource, RunTiming, SandboxProviderKind, StageId, StageTiming, +- SuccessReason, run_event as fabro_types, ++ PairTarget, ParallelBranchId, ParallelBranchResult, PendingReason, PermissionLevel, Principal, ++ PullRequestLink, RunBlobId, RunFailure, RunId, RunNoticeLevel, RunPairEndedReason, ++ RunPairFailedReason, RunProvenance, RunRunnableSource, RunTiming, SandboxProviderKind, StageId, ++ StageOutcome, StageTiming, SuccessReason, run_event as fabro_types, + }; + use fabro_agent::{AgentEvent, SandboxEvent}; + use fabro_model::{ReasoningEffort, Speed}; +@@ -304,7 +304,6 @@ pub enum Event { + node_id: String, + visit: u32, + branch_count: usize, +- join_policy: String, + }, + ParallelBranchStarted { + parallel_group_id: StageId, +@@ -318,9 +317,7 @@ pub enum Event { + branch: String, + index: usize, + duration_ms: u64, +- status: String, +- #[serde(default, skip_serializing_if = "Option::is_none")] +- head_sha: Option, ++ status: StageOutcome, + }, + ParallelCompleted { + node_id: String, +@@ -329,7 +326,7 @@ pub enum Event { + success_count: usize, + failure_count: usize, + #[serde(default, skip_serializing_if = "Vec::is_empty")] +- results: Vec, ++ results: Vec, + }, + InterviewStarted { + question_id: String, +@@ -414,17 +411,6 @@ pub enum Event { + #[serde(default, skip_serializing_if = "Option::is_none")] + exec_output_tail: Option, + }, +- GitBranch { +- branch: String, +- sha: String, +- }, +- GitWorktreeAdd { +- path: String, +- branch: String, +- }, +- GitWorktreeRemove { +- path: String, +- }, + GitFetch { + branch: String, + success: bool, +@@ -1116,12 +1102,8 @@ impl Event { + "Stage retrying" + ); + } +- Self::ParallelStarted { +- branch_count, +- join_policy, +- .. +- } => { +- debug!(branch_count, join_policy, "Parallel execution started"); ++ Self::ParallelStarted { branch_count, .. } => { ++ debug!(branch_count, "Parallel execution started"); + } + Self::ParallelBranchStarted { branch, index, .. } => { + debug!(branch, index, "Parallel branch started"); +@@ -1135,7 +1117,10 @@ impl Event { + } => { + debug!( + branch, +- index, duration_ms, status, "Parallel branch completed" ++ index, ++ duration_ms, ++ status = %status, ++ "Parallel branch completed" + ); + } + Self::ParallelCompleted { +@@ -1233,15 +1218,6 @@ impl Event { + ); + } + } +- Self::GitBranch { branch, sha } => { +- debug!(branch, sha, "Git branch created"); +- } +- Self::GitWorktreeAdd { path, branch } => { +- debug!(path, branch, "Git worktree added"); +- } +- Self::GitWorktreeRemove { path } => { +- debug!(path, "Git worktree removed"); +- } + Self::GitFetch { branch, success } => { + if *success { + debug!(branch, "Git fetch succeeded"); +diff --git a/lib/crates/fabro-workflow/src/event/names.rs b/lib/crates/fabro-workflow/src/event/names.rs +index acfee89e6..58abe28e7 100644 +--- a/lib/crates/fabro-workflow/src/event/names.rs ++++ b/lib/crates/fabro-workflow/src/event/names.rs +@@ -56,9 +56,6 @@ pub fn event_name(event: &Event) -> &'static str { + Event::CheckpointFailed { .. } => "checkpoint.failed", + Event::GitCommit { .. } => "git.commit", + Event::GitPush { .. } => "git.push", +- Event::GitBranch { .. } => "git.branch", +- Event::GitWorktreeAdd { .. } => "git.worktree.added", +- Event::GitWorktreeRemove { .. } => "git.worktree.removed", + Event::GitFetch { .. } => "git.fetch", + Event::GitReset { .. } => "git.reset", + Event::EdgeSelected { .. } => "edge.selected", +diff --git a/lib/crates/fabro-workflow/src/git.rs b/lib/crates/fabro-workflow/src/git.rs +index 8fa23a208..a1f0adf99 100644 +--- a/lib/crates/fabro-workflow/src/git.rs ++++ b/lib/crates/fabro-workflow/src/git.rs +@@ -74,60 +74,6 @@ pub fn head_sha(repo: &Path) -> Result { + Ok(String::from_utf8_lossy(&output.stdout).trim().to_string()) + } + +-/// Create a new branch at HEAD without checking it out. +-pub fn create_branch(repo: &Path, name: &str) -> Result<()> { +- let output = git_cmd(repo) +- .args(["branch", "--force", name, "HEAD"]) +- .output() +- .map_err(|e| Error::engine_with_source("git branch failed", e))?; +- +- if !output.status.success() { +- let stderr = String::from_utf8_lossy(&output.stderr); +- return Err(git_error(format!("git branch failed: {stderr}"))); +- } +- +- Ok(()) +-} +- +-/// Add a git worktree for the given branch at `path`. +-pub fn add_worktree(repo: &Path, path: &Path, branch: &str) -> Result<()> { +- let output = git_cmd(repo) +- .args(["worktree", "add"]) +- .arg(path) +- .arg(branch) +- .output() +- .map_err(|e| Error::engine_with_source("git worktree add failed", e))?; +- +- if !output.status.success() { +- let stderr = String::from_utf8_lossy(&output.stderr); +- return Err(git_error(format!("git worktree add failed: {stderr}"))); +- } +- +- Ok(()) +-} +- +-/// Remove a git worktree. +-pub fn remove_worktree(repo: &Path, path: &Path) -> Result<()> { +- let output = git_cmd(repo) +- .args(["worktree", "remove", "--force"]) +- .arg(path) +- .output() +- .map_err(|e| Error::engine_with_source("git worktree remove failed", e))?; +- +- if !output.status.success() { +- let stderr = String::from_utf8_lossy(&output.stderr); +- return Err(git_error(format!("git worktree remove failed: {stderr}"))); +- } +- +- Ok(()) +-} +- +-/// Remove any stale worktree at `path` (best-effort), then add a fresh one. +-pub fn replace_worktree(repo: &Path, path: &Path, branch: &str) -> Result<()> { +- let _ = remove_worktree(repo, path); +- add_worktree(repo, path, branch) +-} +- + /// Run a `git push` command and check for success. + fn run_git_push(cmd: &mut Command) -> Result<()> { + let output = cmd +@@ -313,23 +259,6 @@ pub fn sync_status(repo: &Path, remote: &str, branch: Option<&str>) -> GitSyncSt + } + } + +-/// Sanitize a string for use as a git ref component. +-/// Lowercases, replaces non-alphanumeric chars with dashes, collapses runs. +-pub fn sanitize_ref_component(s: &str) -> String { +- let mut result = String::with_capacity(s.len()); +- let mut prev_dash = false; +- for c in s.chars() { +- if c.is_ascii_alphanumeric() { +- result.push(c.to_ascii_lowercase()); +- prev_dash = false; +- } else if !prev_dash { +- result.push('-'); +- prev_dash = true; +- } +- } +- result.trim_matches('-').to_string() +-} +- + /// Filenames allowed in per-node directories on the shadow branch. + #[cfg(test)] + #[expect( +@@ -416,39 +345,6 @@ mod tests { + assert!(sha.chars().all(|c| c.is_ascii_hexdigit())); + } + +- #[test] +- #[expect( +- clippy::disallowed_methods, +- reason = "This synchronous test verifies git branch listing against the real git CLI." +- )] +- fn create_branch_and_list() { +- let dir = tempfile::tempdir().unwrap(); +- init_repo(dir.path()); +- create_branch(dir.path(), "test-branch").unwrap(); +- +- let output = Command::new("git") +- .args(["branch", "--list", "test-branch"]) +- .current_dir(dir.path()) +- .output() +- .unwrap(); +- let stdout = String::from_utf8_lossy(&output.stdout); +- assert!(stdout.contains("test-branch")); +- } +- +- #[test] +- fn add_and_remove_worktree() { +- let dir = tempfile::tempdir().unwrap(); +- init_repo(dir.path()); +- create_branch(dir.path(), "wt-branch").unwrap(); +- +- let wt_path = dir.path().join("my-worktree"); +- add_worktree(dir.path(), &wt_path, "wt-branch").unwrap(); +- assert!(wt_path.join(".git").exists()); +- +- remove_worktree(dir.path(), &wt_path).unwrap(); +- assert!(!wt_path.exists()); +- } +- + #[tokio::test] + async fn scan_node_files_from_state_reconstructs_allowlisted_entries() { + use crate::event::{Event, append_event}; +@@ -550,7 +446,11 @@ mod tests { + duration_ms: 100, + success_count: 1, + failure_count: 0, +- results: vec![serde_json::json!({"id": "a"})], ++ results: vec![fabro_types::ParallelBranchResult { ++ id: "a".to_string(), ++ status: fabro_types::StageOutcome::Succeeded, ++ context_updates: std::collections::BTreeMap::new(), ++ }], + }) + .await + .unwrap(); +@@ -588,44 +488,6 @@ mod tests { + assert!(paths.contains(&"stages/001-work@2/parallel_results.json")); + } + +- #[test] +- fn sanitize_ref_component_lowercases() { +- assert_eq!(sanitize_ref_component("Hello"), "hello"); +- } +- +- #[test] +- fn sanitize_ref_component_replaces_special_chars() { +- assert_eq!(sanitize_ref_component("a/b:c d"), "a-b-c-d"); +- } +- +- #[test] +- fn sanitize_ref_component_collapses_consecutive_dashes() { +- assert_eq!(sanitize_ref_component("a///b"), "a-b"); +- } +- +- #[test] +- fn sanitize_ref_component_trims_leading_trailing_dashes() { +- assert_eq!(sanitize_ref_component("--abc--"), "abc"); +- } +- +- #[test] +- fn sanitize_ref_component_mixed() { +- assert_eq!(sanitize_ref_component("My Node!@#123"), "my-node-123"); +- } +- +- #[test] +- fn replace_worktree_on_clean_path() { +- let dir = tempfile::tempdir().unwrap(); +- init_repo(dir.path()); +- create_branch(dir.path(), "rw-branch").unwrap(); +- +- let wt_path = dir.path().join("rw-worktree"); +- replace_worktree(dir.path(), &wt_path, "rw-branch").unwrap(); +- assert!(wt_path.join(".git").exists()); +- +- remove_worktree(dir.path(), &wt_path).unwrap(); +- } +- + #[test] + fn push_branch_fails_for_nonexistent_remote() { + let dir = tempfile::tempdir().unwrap(); +diff --git a/lib/crates/fabro-workflow/src/handler/agent.rs b/lib/crates/fabro-workflow/src/handler/agent.rs +index 16dbe0230..5a063e68e 100644 +--- a/lib/crates/fabro-workflow/src/handler/agent.rs ++++ b/lib/crates/fabro-workflow/src/handler/agent.rs +@@ -4,7 +4,7 @@ use std::sync::Arc; + use async_trait::async_trait; + use fabro_agent::Sandbox; + use fabro_graphviz::graph::{Graph, Node}; +-use fabro_types::{RunId, StageModelUsage, StageTiming}; ++use fabro_types::{StageModelUsage, StageTiming}; + pub(crate) use structured_output::extract_status_fields; + use tokio_util::sync::CancellationToken; + +@@ -268,10 +268,7 @@ impl Handler for AgentHandler { + + // 3. Call LLM backend (agent loop) + let thread_id = context.thread_id(); +- let run_id = context +- .run_id() +- .parse::() +- .map_err(|err| Error::handler_with_source("invalid internal run_id", err))?; ++ let run_id = context.parsed_run_id()?; + let tool_hooks: Option> = + services.run.hook_runner.as_ref().map(|hr| { + Arc::new(fabro_hooks::WorkflowToolHookCallback { +diff --git a/lib/crates/fabro-workflow/src/handler/fan_in.rs b/lib/crates/fabro-workflow/src/handler/fan_in.rs +index aa03d8f74..b6b73db05 100644 +--- a/lib/crates/fabro-workflow/src/handler/fan_in.rs ++++ b/lib/crates/fabro-workflow/src/handler/fan_in.rs +@@ -2,665 +2,262 @@ use std::path::Path; + use std::sync::Arc; + + use async_trait::async_trait; +-use fabro_agent::Sandbox; + use fabro_graphviz::graph::{Graph, Node}; +-use fabro_types::{StageModelUsage, StageTiming}; +-use tokio_util::sync::CancellationToken; + +-use super::agent::{CodergenBackend, CodergenResult, CodergenRunRequest}; ++use super::agent::CodergenBackend; ++use super::prompt::PromptHandler; + use super::{EngineServices, Handler}; + use crate::context::{Context, keys}; + use crate::error::Error; +-use crate::event::{Emitter, Event, StageScope}; +-use crate::outcome::{Outcome, OutcomeExt}; +-use crate::sandbox_git::git_merge_ff_only; +- +-/// Consolidates results from a preceding parallel node and selects the best +-/// candidate. ++use crate::event::Emitter; ++use crate::outcome::Outcome; ++ ++/// Joins results from a preceding parallel node. ++/// ++/// Promptless fan-in nodes are barriers. Prompted fan-in nodes use the same ++/// execution path as standard prompt stages and synthesize the full ordered ++/// branch result set without selecting workspace state. + pub struct FanInHandler { +- backend: Option>, ++ prompt_handler: PromptHandler, + } + + impl FanInHandler { + #[must_use] + pub fn new(backend: Option>) -> Self { +- Self { backend } ++ Self { ++ prompt_handler: PromptHandler::new(backend), ++ } ++ } ++} ++ ++impl FanInHandler { ++ async fn run_join( ++ &self, ++ node: &Node, ++ context: &Context, ++ graph: &Graph, ++ run_dir: &Path, ++ services: &EngineServices, ++ simulated: bool, ++ ) -> Result { ++ let branch_count = validated_branch_count(context)?; ++ if node ++ .prompt() ++ .is_some_and(|prompt| !prompt.trim().is_empty()) ++ { ++ return if simulated { ++ self.prompt_handler ++ .simulate(node, context, graph, run_dir, services) ++ .await ++ } else { ++ self.prompt_handler ++ .execute(node, context, graph, run_dir, services) ++ .await ++ }; ++ } ++ Ok(joined_outcome(branch_count, simulated)) + } + } + + #[async_trait] + impl Handler for FanInHandler { + async fn shutdown(&self, emitter: &Arc) { +- if let Some(backend) = self.backend.as_ref() { +- backend.shutdown(emitter).await; +- } ++ self.prompt_handler.shutdown(emitter).await; + } + + async fn simulate( + &self, + node: &Node, + context: &Context, +- _graph: &Graph, +- _run_dir: &Path, +- _services: &EngineServices, ++ graph: &Graph, ++ run_dir: &Path, ++ services: &EngineServices, + ) -> Result { +- let results = context.get(keys::PARALLEL_RESULTS); +- let Some(results) = results else { +- return Ok(Outcome::fail_deterministic( +- "No parallel results to evaluate", +- )); +- }; +- +- let best = heuristic_select(&results); +- +- let mut outcome = Outcome::simulated(&node.id); +- outcome.context_updates.insert( +- keys::PARALLEL_FAN_IN_BEST_ID.to_string(), +- serde_json::json!(best.id), +- ); +- outcome.context_updates.insert( +- keys::PARALLEL_FAN_IN_BEST_OUTCOME.to_string(), +- serde_json::json!(best.status), +- ); +- // Override the generic simulated notes with handler-specific detail. +- outcome.notes = Some(format!("[Simulated] Selected best candidate: {}", best.id)); +- Ok(outcome) ++ self.run_join(node, context, graph, run_dir, services, true) ++ .await + } + + async fn execute( + &self, + node: &Node, + context: &Context, +- _graph: &Graph, ++ graph: &Graph, + run_dir: &Path, + services: &EngineServices, + ) -> Result { +- let results = context.get(keys::PARALLEL_RESULTS); +- let Some(results) = results else { +- return Ok(Outcome::fail_deterministic( +- "No parallel results to evaluate", +- )); +- }; +- +- let prompt = node.prompt().filter(|p| !p.is_empty()); +- +- let best = if let (Some(prompt_text), Some(backend)) = (prompt, &self.backend) { +- llm_evaluate( +- backend.as_ref(), +- prompt_text, +- &results, +- context, +- run_dir, +- &node.id, +- &services.run.emitter, +- &services.run.sandbox, +- services.run.cancel_token(), +- ) +- .await? +- } else { +- heuristic_select(&results) +- }; +- +- // Check if all candidates failed — if so, return fail +- let all_failed = if best.status == "failed" { +- let empty_vec = vec![]; +- let arr = results.as_array().unwrap_or(&empty_vec); +- arr.iter() +- .all(|v| v.get("status").and_then(|v| v.as_str()).unwrap_or("failed") == "failed") +- } else { +- false +- }; +- +- if all_failed { +- let mut outcome = Outcome::fail_deterministic("all candidates failed"); +- outcome.timing = Some(best.timing); +- return Ok(outcome); +- } +- +- // --- Fast-forward to winner's HEAD when git isolation is active --- +- let best_head_sha = { +- let empty_vec = vec![]; +- let arr = results.as_array().unwrap_or(&empty_vec); +- arr.iter() +- .find(|v| v.get("id").and_then(|v| v.as_str()) == Some(&best.id)) +- .and_then(|v| v.get("head_sha").and_then(|v| v.as_str()).map(String::from)) +- }; +- +- if let (Some(ref sha), Some(_)) = (&best_head_sha, services.git_state()) { +- git_merge_ff_only(&*services.run.sandbox, sha).await; +- } +- +- let mut outcome = Outcome::success(); +- outcome.context_updates.insert( +- keys::PARALLEL_FAN_IN_BEST_ID.to_string(), +- serde_json::json!(best.id), +- ); +- outcome.context_updates.insert( +- keys::PARALLEL_FAN_IN_BEST_OUTCOME.to_string(), +- serde_json::json!(best.status), +- ); +- if let Some(ref sha) = best_head_sha { +- outcome.context_updates.insert( +- keys::PARALLEL_FAN_IN_BEST_HEAD_SHA.to_string(), +- serde_json::json!(sha), +- ); +- } +- outcome.notes = Some(format!("Selected best candidate: {}", best.id)); +- outcome.timing = Some(best.timing); +- +- Ok(outcome) +- } +-} +- +-struct Candidate { +- id: String, +- status: String, +- score: f64, +- timing: StageTiming, +-} +- +-fn status_rank(status: &str) -> u32 { +- match status { +- "succeeded" => 0, +- "partially_succeeded" => 1, +- "failed" => 2, +- _ => 4, ++ self.run_join(node, context, graph, run_dir, services, false) ++ .await + } + } + +-fn heuristic_select(results: &serde_json::Value) -> Candidate { +- let empty_vec = vec![]; +- let arr = results.as_array().unwrap_or(&empty_vec); +- if arr.is_empty() { +- return Candidate { +- id: "unknown".to_string(), +- status: "failed".to_string(), +- score: 0.0, +- timing: StageTiming::default(), +- }; ++/// Validate that `parallel.results` exists and has the typed shape without ++/// cloning the (potentially hydrated) branch payloads into a full ++/// [`ParallelBranchResult`] vec that would go unused. ++fn validated_branch_count(context: &Context) -> Result { ++ #[derive(serde::Deserialize)] ++ struct BranchShape { ++ #[expect(dead_code, reason = "deserialized only to validate the shape")] ++ id: String, ++ #[expect(dead_code, reason = "deserialized only to validate the shape")] ++ status: fabro_types::StageOutcome, + } + +- let mut candidates: Vec = arr +- .iter() +- .map(|v| Candidate { +- id: v +- .get("id") +- .and_then(|v| v.as_str()) +- .unwrap_or("unknown") +- .to_string(), +- status: v +- .get("status") +- .and_then(|v| v.as_str()) +- .unwrap_or("failed") +- .to_string(), +- score: v +- .get("score") +- .and_then(serde_json::Value::as_f64) +- .unwrap_or(0.0), +- timing: StageTiming::default(), +- }) +- .collect(); +- +- candidates.sort_by(|a, b| { +- let rank_cmp = status_rank(&a.status).cmp(&status_rank(&b.status)); +- if rank_cmp != std::cmp::Ordering::Equal { +- return rank_cmp; +- } +- // Higher score is better, so reverse the comparison +- let score_cmp = b +- .score +- .partial_cmp(&a.score) +- .unwrap_or(std::cmp::Ordering::Equal); +- if score_cmp != std::cmp::Ordering::Equal { +- return score_cmp; +- } +- a.id.cmp(&b.id) +- }); +- +- candidates.into_iter().next().unwrap_or_else(|| Candidate { +- id: "unknown".to_string(), +- status: "failed".to_string(), +- score: 0.0, +- timing: StageTiming::default(), +- }) ++ let value = context ++ .get(keys::PARALLEL_RESULTS) ++ .ok_or_else(|| Error::handler("No parallel results to join"))?; ++ let results: Vec = serde_json::from_value(value) ++ .map_err(|err| Error::handler_with_source("Invalid parallel results", err))?; ++ Ok(results.len()) + } + +-/// Use an LLM backend to evaluate and rank parallel branch results. +-#[allow( +- clippy::too_many_arguments, +- reason = "Fan-in evaluation passes prompt, results, context, and runtime handles separately." +-)] +-async fn llm_evaluate( +- backend: &dyn CodergenBackend, +- prompt: &str, +- results: &serde_json::Value, +- context: &Context, +- _run_dir: &Path, +- node_id: &str, +- emitter: &Arc, +- sandbox: &Arc, +- cancel_token: CancellationToken, +-) -> Result { +- let results_text = +- serde_json::to_string_pretty(results).unwrap_or_else(|_| results.to_string()); +- +- let full_prompt = format!( +- "{prompt}\n\nParallel branch results:\n{results_text}\n\n\ +- Respond with the ID of the best candidate." +- ); +- +- let stage_scope = StageScope::for_handler(context, node_id); +- +- emitter.emit_scoped( +- &Event::Prompt { +- stage: node_id.to_string(), +- visit: stage_scope.visit, +- text: full_prompt.clone(), +- mode: Some(StageModelUsage::MODE_FAN_IN.to_string()), +- provider: None, +- model: None, +- reasoning_effort: None, +- speed: None, +- }, +- &stage_scope, +- ); +- +- // Build a synthetic node for the backend call +- let eval_node = Node::new("fan_in_eval"); +- +- // Fan-in evaluation runs outside a thread context, so pass None +- match backend +- .run(CodergenRunRequest { +- node: &eval_node, +- prompt: &full_prompt, +- context, +- thread_id: None, +- emitter, +- sandbox, +- tool_hooks: None, +- cancel_token, +- agent_tool_runtime: fabro_agent::AgentToolRuntime::default(), +- }) +- .await +- { +- Ok(CodergenResult::Full(outcome)) => { +- let timing = outcome.timing.unwrap_or_default(); +- // If the backend returned a full Outcome, extract best_id from context_updates +- let best_id = outcome +- .context_updates +- .get(keys::PARALLEL_FAN_IN_BEST_ID) +- .and_then(|v| v.as_str()) +- .map(String::from) +- .or_else(|| outcome.notes.clone()) +- .unwrap_or_else(|| "unknown".to_string()); +- let response_text = +- serde_json::to_string_pretty(&outcome).unwrap_or_else(|_| "{}".to_string()); +- emitter.emit_scoped( +- &Event::PromptCompleted { +- node_id: node_id.to_string(), +- response: response_text.clone(), +- model: String::new(), +- provider: String::new(), +- billing: None, +- }, +- &stage_scope, +- ); +- Ok(Candidate { +- id: best_id, +- status: outcome.status.to_string(), +- score: 0.0, +- timing, +- }) +- } +- Ok(CodergenResult::Text { text, timing, .. }) => { +- emitter.emit_scoped( +- &Event::PromptCompleted { +- node_id: node_id.to_string(), +- response: text.clone(), +- model: String::new(), +- provider: String::new(), +- billing: None, +- }, +- &stage_scope, +- ); +- +- // The LLM responded with text; try to find a matching candidate ID +- let text = text.trim().to_string(); +- let empty_vec = vec![]; +- let arr = results.as_array().unwrap_or(&empty_vec); +- +- // Check if the response text matches any candidate ID +- for v in arr { +- if let Some(id) = v.get("id").and_then(|v| v.as_str()) { +- if text.contains(id) { +- let status = v +- .get("status") +- .and_then(|v| v.as_str()) +- .unwrap_or("succeeded") +- .to_string(); +- let score = v +- .get("score") +- .and_then(serde_json::Value::as_f64) +- .unwrap_or(0.0); +- return Ok(Candidate { +- id: id.to_string(), +- status, +- score, +- timing, +- }); +- } +- } +- } +- +- // No match found; fall back to heuristic +- let mut fallback = heuristic_select(results); +- fallback.timing = timing; +- Ok(fallback) +- } +- Err(_) => { +- // LLM call failed; fall back to heuristic +- Ok(heuristic_select(results)) ++fn joined_outcome(branch_count: usize, simulated: bool) -> Outcome { ++ let mut outcome = Outcome::success(); ++ let prefix = if simulated { "[Simulated] " } else { "" }; ++ outcome.notes = Some(format!( ++ "{prefix}Joined {branch_count} parallel {}", ++ if branch_count == 1 { ++ "branch" ++ } else { ++ "branches" + } +- } ++ )); ++ outcome + } + + #[cfg(test)] + mod tests { ++ use fabro_graphviz::graph::AttrValue; ++ use fabro_types::StageTiming; ++ use tempfile::TempDir; ++ + use super::*; ++ use crate::handler::agent::{CodergenResult, CodergenRunRequest, OneShotRequest}; + use crate::outcome::StageOutcome; + + fn make_services() -> EngineServices { + EngineServices::test_default() + } + +- #[tokio::test] +- async fn fan_in_no_results() { +- let handler = FanInHandler::new(None); +- let node = Node::new("fan_in"); +- let context = Context::new(); +- let graph = Graph::new("test"); +- let run_dir = Path::new("/tmp/test"); +- +- let outcome = handler +- .execute(&node, &context, &graph, run_dir, &make_services()) +- .await +- .unwrap(); +- assert_eq!(outcome.status, StageOutcome::Failed { +- retry_requested: false, +- }); +- } +- +- #[tokio::test] +- async fn fan_in_selects_best() { +- let handler = FanInHandler::new(None); +- let node = Node::new("fan_in"); ++ fn context_with_results() -> Context { + let context = Context::new(); + context.set( + keys::PARALLEL_RESULTS, + serde_json::json!([ +- {"id": "branch_a", "status": "failed"}, +- {"id": "branch_b", "status": "succeeded"}, ++ { ++ "id": "branch_a", ++ "status": "failed", ++ "context_updates": {"command.output": "failure details"} ++ }, ++ { ++ "id": "branch_b", ++ "status": "succeeded", ++ "context_updates": {"response.branch_b": "complete response"} ++ } + ]), + ); +- let graph = Graph::new("test"); +- let run_dir = Path::new("/tmp/test"); +- +- let outcome = handler +- .execute(&node, &context, &graph, run_dir, &make_services()) +- .await +- .unwrap(); +- assert_eq!(outcome.status, StageOutcome::Succeeded); +- assert_eq!( +- outcome.context_updates.get(keys::PARALLEL_FAN_IN_BEST_ID), +- Some(&serde_json::json!("branch_b")) +- ); ++ context + } + + #[tokio::test] +- async fn fan_in_lexical_tiebreak() { +- let handler = FanInHandler::new(None); +- let node = Node::new("fan_in"); +- let context = Context::new(); +- context.set( +- keys::PARALLEL_RESULTS, +- serde_json::json!([ +- {"id": "c", "status": "succeeded"}, +- {"id": "a", "status": "succeeded"}, +- {"id": "b", "status": "succeeded"}, +- ]), +- ); +- let graph = Graph::new("test"); +- let run_dir = Path::new("/tmp/test"); +- +- let outcome = handler +- .execute(&node, &context, &graph, run_dir, &make_services()) ++ async fn promptless_fan_in_is_a_noop_barrier() { ++ let outcome = FanInHandler::new(None) ++ .execute( ++ &Node::new("fan_in"), ++ &context_with_results(), ++ &Graph::new("test"), ++ Path::new("/tmp/test"), ++ &make_services(), ++ ) + .await + .unwrap(); +- assert_eq!( +- outcome.context_updates.get(keys::PARALLEL_FAN_IN_BEST_ID), +- Some(&serde_json::json!("a")) +- ); +- } + +- #[test] +- fn status_rank_ordering() { +- assert!(status_rank("succeeded") < status_rank("partially_succeeded")); +- assert!(status_rank("partially_succeeded") < status_rank("failed")); +- assert!(status_rank("failed") < status_rank("unknown")); ++ assert_eq!(outcome.status, StageOutcome::Succeeded); ++ assert_eq!(outcome.notes.as_deref(), Some("Joined 2 parallel branches")); ++ assert!(outcome.context_updates.is_empty()); + } + + #[tokio::test] +- async fn fan_in_no_backend_ignores_prompt() { +- // When there's a prompt but no backend, it should fall back to heuristic +- let handler = FanInHandler::new(None); +- let mut node = Node::new("fan_in"); +- node.attrs.insert( +- "prompt".to_string(), +- fabro_graphviz::graph::AttrValue::String("Pick the best branch".to_string()), +- ); ++ async fn fan_in_requires_typed_parallel_results() { + let context = Context::new(); +- context.set( +- keys::PARALLEL_RESULTS, +- serde_json::json!([ +- {"id": "branch_a", "status": "succeeded"}, +- {"id": "branch_b", "status": "failed"}, +- ]), +- ); +- let graph = Graph::new("test"); +- let run_dir = Path::new("/tmp/test"); +- +- let outcome = handler +- .execute(&node, &context, &graph, run_dir, &make_services()) +- .await +- .unwrap(); +- assert_eq!(outcome.status, StageOutcome::Succeeded); +- // Should still pick branch_a via heuristic (success beats fail) +- assert_eq!( +- outcome.context_updates.get(keys::PARALLEL_FAN_IN_BEST_ID), +- Some(&serde_json::json!("branch_a")) +- ); ++ let missing = FanInHandler::new(None) ++ .execute( ++ &Node::new("fan_in"), ++ &context, ++ &Graph::new("test"), ++ Path::new("/tmp/test"), ++ &make_services(), ++ ) ++ .await; ++ assert!(missing.is_err()); ++ ++ context.set(keys::PARALLEL_RESULTS, serde_json::json!([{"id": "a"}])); ++ let invalid = FanInHandler::new(None) ++ .execute( ++ &Node::new("fan_in"), ++ &context, ++ &Graph::new("test"), ++ Path::new("/tmp/test"), ++ &make_services(), ++ ) ++ .await; ++ assert!(invalid.is_err()); + } + + #[tokio::test] +- async fn fan_in_with_backend_llm_eval() { +- use tempfile::TempDir; +- +- use crate::handler::agent::{CodergenBackend, CodergenRunRequest}; +- +- struct MockBackend; ++ async fn prompted_fan_in_uses_standard_prompt_response_fields() { ++ struct ReducerBackend; + + #[async_trait] +- impl CodergenBackend for MockBackend { ++ impl CodergenBackend for ReducerBackend { + async fn run(&self, _request: CodergenRunRequest<'_>) -> Result { +- // Return text that contains the ID "branch_b" +- Ok(CodergenResult::Text { +- text: "The best candidate is branch_b".to_string(), +- usage: None, +- files_touched: Vec::new(), +- last_file_touched: None, +- timing: StageTiming::default(), +- }) ++ panic!("prompted fan-in must use one_shot like a standard prompt") + } +- } + +- let handler = FanInHandler::new(Some(Box::new(MockBackend))); +- let mut node = Node::new("fan_in"); +- node.attrs.insert( +- "prompt".to_string(), +- fabro_graphviz::graph::AttrValue::String("Pick the best branch".to_string()), +- ); +- let context = Context::new(); +- context.set( +- keys::PARALLEL_RESULTS, +- serde_json::json!([ +- {"id": "branch_a", "status": "succeeded"}, +- {"id": "branch_b", "status": "succeeded"}, +- ]), +- ); +- let graph = Graph::new("test"); +- let tmp = TempDir::new().unwrap(); +- +- let outcome = handler +- .execute(&node, &context, &graph, tmp.path(), &make_services()) +- .await +- .unwrap(); +- assert_eq!(outcome.status, StageOutcome::Succeeded); +- // LLM chose branch_b +- assert_eq!( +- outcome.context_updates.get(keys::PARALLEL_FAN_IN_BEST_ID), +- Some(&serde_json::json!("branch_b")) +- ); +- } +- +- #[tokio::test] +- async fn fan_in_with_backend_copies_llm_timing_to_outcome() { +- use tempfile::TempDir; +- +- use crate::handler::agent::{CodergenBackend, CodergenRunRequest}; +- +- struct TimingBackend; +- +- #[async_trait] +- impl CodergenBackend for TimingBackend { +- async fn run(&self, _request: CodergenRunRequest<'_>) -> Result { ++ async fn one_shot(&self, request: OneShotRequest<'_>) -> Result { ++ assert!(request.prompt.contains("Synthesize every result")); + Ok(CodergenResult::Text { +- text: "branch_b".to_string(), ++ text: "combined result".to_string(), + usage: None, + files_touched: Vec::new(), + last_file_touched: None, +- timing: StageTiming::new(0, 200, 300), ++ timing: StageTiming::new(0, 20, 30), + }) + } + } + +- let handler = FanInHandler::new(Some(Box::new(TimingBackend))); ++ let handler = FanInHandler::new(Some(Box::new(ReducerBackend))); + let mut node = Node::new("fan_in"); + node.attrs.insert( + "prompt".to_string(), +- fabro_graphviz::graph::AttrValue::String("Pick the best branch".to_string()), ++ AttrValue::String("Synthesize every result".to_string()), + ); +- let context = Context::new(); +- context.set( +- keys::PARALLEL_RESULTS, +- serde_json::json!([ +- {"id": "branch_a", "status": "succeeded"}, +- {"id": "branch_b", "status": "succeeded"}, +- ]), +- ); +- let graph = Graph::new("test"); +- let tmp = TempDir::new().unwrap(); +- ++ let run_dir = TempDir::new().unwrap(); + let outcome = handler +- .execute(&node, &context, &graph, tmp.path(), &make_services()) +- .await +- .unwrap(); +- +- assert_eq!(outcome.timing, Some(StageTiming::new(0, 200, 300))); +- } +- +- #[tokio::test] +- async fn fan_in_all_fail_returns_fail() { +- let handler = FanInHandler::new(None); +- let node = Node::new("fan_in"); +- let context = Context::new(); +- context.set( +- keys::PARALLEL_RESULTS, +- serde_json::json!([ +- {"id": "branch_a", "status": "failed"}, +- {"id": "branch_b", "status": "failed"}, +- {"id": "branch_c", "status": "failed"}, +- ]), +- ); +- let graph = Graph::new("test"); +- let run_dir = Path::new("/tmp/test"); +- +- let outcome = handler +- .execute(&node, &context, &graph, run_dir, &make_services()) ++ .execute( ++ &node, ++ &context_with_results(), ++ &Graph::new("test"), ++ run_dir.path(), ++ &make_services(), ++ ) + .await + .unwrap(); +- assert_eq!(outcome.status, StageOutcome::Failed { +- retry_requested: false, +- }); +- assert!( +- outcome +- .failure_reason() +- .unwrap() +- .contains("all candidates failed") +- ); +- } +- +- #[tokio::test] +- async fn fan_in_score_tiebreak() { +- let handler = FanInHandler::new(None); +- let node = Node::new("fan_in"); +- let context = Context::new(); +- context.set( +- keys::PARALLEL_RESULTS, +- serde_json::json!([ +- {"id": "branch_a", "status": "succeeded", "score": 0.5}, +- {"id": "branch_b", "status": "succeeded", "score": 0.9}, +- {"id": "branch_c", "status": "succeeded", "score": 0.7}, +- ]), +- ); +- let graph = Graph::new("test"); +- let run_dir = Path::new("/tmp/test"); + +- let outcome = handler +- .execute(&node, &context, &graph, run_dir, &make_services()) +- .await +- .unwrap(); + assert_eq!(outcome.status, StageOutcome::Succeeded); +- // branch_b has highest score + assert_eq!( +- outcome.context_updates.get(keys::PARALLEL_FAN_IN_BEST_ID), +- Some(&serde_json::json!("branch_b")) ++ outcome.context_updates.get(&keys::response_key("fan_in")), ++ Some(&serde_json::json!("combined result")) + ); +- } +- +- #[tokio::test] +- async fn fan_in_simulate_uses_heuristic() { +- let handler = FanInHandler::new(None); +- let node = Node::new("fan_in"); +- let context = Context::new(); +- context.set( +- keys::PARALLEL_RESULTS, +- serde_json::json!([ +- {"id": "branch_a", "status": "failed"}, +- {"id": "branch_b", "status": "succeeded"}, +- ]), +- ); +- let graph = Graph::new("test"); +- let run_dir = Path::new("/tmp/test"); +- +- let outcome = handler +- .simulate(&node, &context, &graph, run_dir, &make_services()) +- .await +- .unwrap(); +- assert_eq!(outcome.status, StageOutcome::Succeeded); +- assert!(outcome.notes.as_deref().unwrap().contains("[Simulated]")); + assert_eq!( +- outcome.context_updates.get(keys::PARALLEL_FAN_IN_BEST_ID), +- Some(&serde_json::json!("branch_b")) ++ outcome.context_updates.get(keys::LAST_RESPONSE), ++ Some(&serde_json::json!("combined result")) ++ ); ++ assert_eq!(outcome.timing, Some(StageTiming::new(0, 20, 30))); ++ assert!( ++ outcome ++ .context_updates ++ .keys() ++ .all(|key| !key.starts_with("parallel.fan_in.best_")) + ); + } + } +diff --git a/lib/crates/fabro-workflow/src/handler/manager_loop.rs b/lib/crates/fabro-workflow/src/handler/manager_loop.rs +index d16291b68..431ed5d76 100644 +--- a/lib/crates/fabro-workflow/src/handler/manager_loop.rs ++++ b/lib/crates/fabro-workflow/src/handler/manager_loop.rs +@@ -14,7 +14,7 @@ use tokio::time::{sleep, timeout}; + use super::{EngineServices, Handler}; + use crate::artifact_upload::ArtifactSink; + use crate::condition::evaluate_condition; +-use crate::context::{Context, WorkflowContext, keys}; ++use crate::context::{Context, WorkflowContext, context_diff, keys}; + use crate::error::Error; + use crate::operations::{ValidateInput, WorkflowInput, validate}; + use crate::outcome::{Outcome, OutcomeExt, StageOutcome}; +@@ -133,21 +133,6 @@ fn parse_child_graph(node: &Node, services: &EngineServices) -> Result, +- after: &HashMap, +-) -> HashMap { +- let mut diff = HashMap::new(); +- for (key, value) in after { +- if before.get(key) != Some(value) { +- diff.insert(key.clone(), value.clone()); +- } +- } +- diff +-} +- + #[async_trait] + impl Handler for SubWorkflowHandler { + async fn execute( +@@ -273,7 +258,6 @@ impl Handler for SubWorkflowHandler { + run: child_run, + registry, + interviewer, +- git_state: std::sync::RwLock::new(None), + base_env, + github_token, + inputs, +@@ -299,8 +283,8 @@ impl Handler for SubWorkflowHandler { + }; + + // Compute context diff, filtering engine-internal keys +- let after_snapshot = child_final_context.snapshot(); +- let raw_diff = context_diff(&before_snapshot, &after_snapshot); ++ let raw_diff = ++ context_diff(&before_snapshot, child_final_context.snapshot()); + let diff: HashMap = raw_diff + .into_iter() + .filter(|(key, _)| !keys::is_engine_internal_key(key)) +@@ -824,7 +808,7 @@ mod tests { + let before = HashMap::new(); + let mut after = HashMap::new(); + after.insert("key".to_string(), serde_json::json!("value")); +- let diff = context_diff(&before, &after); ++ let diff = context_diff(&before, after); + assert_eq!(diff.len(), 1); + assert_eq!(diff.get("key"), Some(&serde_json::json!("value"))); + } +@@ -835,7 +819,7 @@ mod tests { + before.insert("key".to_string(), serde_json::json!("old")); + let mut after = HashMap::new(); + after.insert("key".to_string(), serde_json::json!("new")); +- let diff = context_diff(&before, &after); ++ let diff = context_diff(&before, after); + assert_eq!(diff.len(), 1); + assert_eq!(diff.get("key"), Some(&serde_json::json!("new"))); + } +@@ -846,7 +830,7 @@ mod tests { + before.insert("key".to_string(), serde_json::json!("same")); + let mut after = HashMap::new(); + after.insert("key".to_string(), serde_json::json!("same")); +- let diff = context_diff(&before, &after); ++ let diff = context_diff(&before, after); + assert!(diff.is_empty()); + } + +@@ -855,7 +839,7 @@ mod tests { + let mut before = HashMap::new(); + before.insert("removed".to_string(), serde_json::json!("gone")); + let after = HashMap::new(); +- let diff = context_diff(&before, &after); ++ let diff = context_diff(&before, after); + assert!(diff.is_empty()); + } + +@@ -876,7 +860,7 @@ mod tests { + after.insert("response.plan".to_string(), serde_json::json!("the plan")); + after.insert("review.result".to_string(), serde_json::json!("approved")); + +- let raw_diff = context_diff(&before, &after); ++ let raw_diff = context_diff(&before, after); + let filtered: HashMap = raw_diff + .into_iter() + .filter(|(key, _)| !keys::is_engine_internal_key(key)) +diff --git a/lib/crates/fabro-workflow/src/handler/parallel.rs b/lib/crates/fabro-workflow/src/handler/parallel.rs +index 22aa2aa9c..307647193 100644 +--- a/lib/crates/fabro-workflow/src/handler/parallel.rs ++++ b/lib/crates/fabro-workflow/src/handler/parallel.rs +@@ -1,59 +1,39 @@ +-use std::path::{Path, PathBuf}; ++use std::collections::{BTreeMap, HashMap, HashSet}; ++use std::path::Path; + use std::sync::Arc; + use std::time::Instant; + + use async_trait::async_trait; +-use fabro_agent::{Sandbox, WorktreeOptions, WorktreeSandbox}; + use fabro_graphviz::graph::{AttrValue, Graph, Node}; + use fabro_hooks::{HookContext, HookEvent}; +-use fabro_types::{ParallelBranchId, RunId, StageId}; ++use fabro_types::{ParallelBranchId, ParallelBranchResult, StageId, StageOutcome}; ++use futures::FutureExt; + use tokio::sync::Semaphore; ++use tokio::task::JoinHandle; + + use super::{EngineServices, Handler}; +-use crate::context::{Context, WorkflowContext, keys}; ++use crate::context::{Context, WorkflowContext, context_diff, keys}; + use crate::error::Error; + use crate::event::{Event, RunNoticeCode, RunNoticeLevel, StageScope}; +-use crate::git::sanitize_ref_component; + use crate::hook_context::set_hook_node; +-use crate::millis_u64; +-use crate::outcome::{FailureCategory, FailureDetail, Outcome, OutcomeExt, StageOutcome}; +-use crate::run_dir::visit_from_context; +-use crate::sandbox_git::{ +- GIT_REMOTE, checked_git_checkpoint, git_merge_ff_only, git_remove_worktree, +-}; ++use crate::outcome::{FailureCategory, FailureDetail, Outcome, OutcomeExt}; ++use crate::{artifact, millis_u64}; + + /// Fans out execution to multiple branches concurrently. +-/// Each branch gets an isolated context clone and runs independently. ++/// Each branch gets an isolated context fork and shares the run sandbox. + pub struct ParallelHandler; + +-/// Parse join policy from node attributes. +-#[derive(Debug, Clone)] +-enum JoinPolicy { +- WaitAll, +- FirstSuccess, +-} +- +-impl std::fmt::Display for JoinPolicy { +- fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { +- match self { +- Self::WaitAll => write!(f, "wait_all"), +- Self::FirstSuccess => write!(f, "first_success"), +- } +- } ++struct BranchResult { ++ result: ParallelBranchResult, ++ outcome: Outcome, + } + +-fn parse_join_policy(raw: &str) -> JoinPolicy { +- if raw == "first_success" { +- return JoinPolicy::FirstSuccess; +- } +- JoinPolicy::WaitAll +-} +- +-struct BranchResult { +- id: String, +- outcome: Outcome, +- head_sha: Option, +- worktree_path: Option, ++struct BranchDispatch { ++ index: usize, ++ target_id: String, ++ branch_id: ParallelBranchId, ++ scope: StageScope, ++ handle: JoinHandle>, + } + + #[async_trait] +@@ -66,58 +46,7 @@ impl Handler for ParallelHandler { + run_dir: &Path, + services: &EngineServices, + ) -> Result { +- let branches = graph.outgoing_edges(&node.id); +- if branches.is_empty() { +- return Ok(Outcome::fail_classify("No branches for parallel node")); +- } +- +- // Dispatch each branch child via dispatch_handler (which will call simulate) +- let mut branch_results: Vec = Vec::new(); +- for edge in &branches { +- let target_id = &edge.to; +- if let Some(target_node) = graph.nodes.get(target_id) { +- let handler = services.registry.resolve(target_node); +- let branch_context = context.fork(); +- let outcome = super::dispatch_handler( +- handler, +- target_node, +- &branch_context, +- graph, +- run_dir, +- services, +- ) +- .await?; +- branch_results.push(BranchResult { +- id: target_id.clone(), +- outcome, +- head_sha: None, +- worktree_path: None, +- }); +- } +- } +- +- let total = branch_results.len(); +- context.set(keys::PARALLEL_BRANCH_COUNT, serde_json::json!(total)); +- +- let results_json: Vec = branch_results +- .iter() +- .map(|r| { +- serde_json::json!({ +- "id": r.id, +- "status": r.outcome.status.to_string(), +- }) +- }) +- .collect(); +- context.set(keys::PARALLEL_RESULTS, serde_json::json!(results_json)); +- +- let join_node = find_join_node(&branch_results, graph); +- +- let mut outcome = Outcome::simulated(&node.id); +- outcome.notes = Some(format!( +- "[Simulated] Parallel node dispatched {total} branches" +- )); +- outcome.jump_to_node = join_node; +- Ok(outcome) ++ run_branches(node, context, graph, run_dir, services, true).await + } + + async fn execute( +@@ -128,567 +57,380 @@ impl Handler for ParallelHandler { + run_dir: &Path, + services: &EngineServices, + ) -> Result { +- // Build per-branch sandboxes (sequentially for git setup) +- struct BranchSetup { +- target_id: String, +- branch_index: usize, +- parallel_branch_id: ParallelBranchId, +- branch_context: Context, +- sandbox: Arc, +- worktree_path: Option, +- } +- +- let parallel_start = Instant::now(); +- let branches = graph.outgoing_edges(&node.id); +- if branches.is_empty() { +- return Ok(Outcome::fail_classify("No branches for parallel node")); +- } ++ run_branches(node, context, graph, run_dir, services, false).await ++ } ++} + +- let join_policy = parse_join_policy( +- node.attrs +- .get("join_policy") +- .and_then(|v| v.as_str()) +- .unwrap_or("wait_all"), ++async fn run_branches( ++ node: &Node, ++ context: &Context, ++ graph: &Graph, ++ run_dir: &Path, ++ services: &EngineServices, ++ simulated: bool, ++) -> Result { ++ let parallel_start = Instant::now(); ++ let branches = graph.outgoing_edges(&node.id); ++ ++ let parallel_stage_scope = StageScope::for_handler(context, &node.id); ++ let parallel_group_id = StageId::new(node.id.clone(), parallel_stage_scope.visit); ++ services.run.emitter.emit_scoped( ++ &Event::ParallelStarted { ++ node_id: node.id.clone(), ++ visit: parallel_stage_scope.visit, ++ branch_count: branches.len(), ++ }, ++ ¶llel_stage_scope, ++ ); ++ emit_parallel_hook(services, context, graph, node, HookEvent::ParallelStart).await?; ++ ++ let max_parallel = node ++ .attrs ++ .get("max_parallel") ++ .and_then(AttrValue::as_i64) ++ .unwrap_or(4); ++ let max_parallel = usize::try_from(max_parallel).unwrap_or(4).max(1); ++ let semaphore = Arc::new(Semaphore::new(max_parallel)); ++ let shared_graph = Arc::new(graph.clone()); ++ let parent_snapshot = Arc::new(context.snapshot()); ++ ++ let mut dispatches = Vec::with_capacity(branches.len()); ++ for (branch_index, edge) in branches.iter().enumerate() { ++ let target_id = edge.to.clone(); ++ let parallel_branch_id = ParallelBranchId::new( ++ parallel_group_id.clone(), ++ u32::try_from(branch_index).unwrap_or(u32::MAX), + ); +- +- let parallel_stage_scope = StageScope::for_handler(context, &node.id); +- let parallel_group_id = StageId::new(node.id.clone(), parallel_stage_scope.visit); +- +- services.run.emitter.emit_scoped( +- &Event::ParallelStarted { +- node_id: node.id.clone(), +- visit: parallel_stage_scope.visit, +- branch_count: branches.len(), +- join_policy: join_policy.to_string(), +- }, +- ¶llel_stage_scope, ++ let branch_context = Context::from_values(parent_snapshot.as_ref().clone()); ++ branch_context.set( ++ keys::INTERNAL_PARALLEL_GROUP_ID, ++ serde_json::Value::String(parallel_group_id.to_string()), ++ ); ++ branch_context.set( ++ keys::INTERNAL_PARALLEL_BRANCH_ID, ++ serde_json::Value::String(parallel_branch_id.to_string()), + ); +- { +- let run_id = context +- .run_id() +- .parse::() +- .map_err(|err| Error::handler_with_source("invalid internal run_id", err))?; +- let mut hook_ctx = +- HookContext::new(HookEvent::ParallelStart, run_id, graph.name.clone()); +- set_hook_node(&mut hook_ctx, node); +- let _ = services.run.run_hooks(&hook_ctx).await; +- } +- let max_parallel = node +- .attrs +- .get("max_parallel") +- .and_then(AttrValue::as_i64) +- .unwrap_or(4); +- let max_parallel = usize::try_from(max_parallel).unwrap_or(4).max(1); +- +- let semaphore = Arc::new(Semaphore::new(max_parallel)); +- let git_state = services.git_state(); +- +- // --- Git isolation: checkpoint "parallel base" before fan-out --- +- let base_sha: Option = if let Some(ref gs) = git_state { +- let result = checked_git_checkpoint( +- &services.run.sandbox_git, +- &*services.run.sandbox, +- &gs.run_id.to_string(), +- &node.id, +- "parallel_base", +- 0, +- None, +- &gs.checkpoint, +- &gs.git_author, +- ) +- .await; +- match result { +- Ok(sha) => Some(sha), +- Err(e) if e.to_string() == "sandbox git unavailable" => { +- return Err(Error::handler_with_source("sandbox git unavailable", e)); +- } +- Err(e) => { +- tracing::warn!( +- error = %fabro_sandbox::display_for_log(&e), +- "parallel base checkpoint failed" +- ); +- services.run.emitter.notice_with_tail( +- RunNoticeLevel::Warn, +- RunNoticeCode::ParallelBaseCheckpointFailed, +- format!("Could not checkpoint base state before parallel branches: {e}"), +- fabro_sandbox::default_redacted_output_tail(&e), +- ); +- None +- } +- } +- } else { +- None +- }; +- +- let mut branch_setups: Vec = Vec::new(); +- for (branch_index, edge) in branches.iter().enumerate() { +- let target_id = edge.to.clone(); +- let branch_context = context.fork(); +- let parallel_branch_id = ParallelBranchId::new( +- parallel_group_id.clone(), +- u32::try_from(branch_index).unwrap_or(u32::MAX), +- ); +- branch_context.set( +- keys::INTERNAL_PARALLEL_GROUP_ID, +- serde_json::Value::String(parallel_group_id.to_string()), +- ); +- branch_context.set( +- keys::INTERNAL_PARALLEL_BRANCH_ID, +- serde_json::Value::String(parallel_branch_id.to_string()), +- ); +- +- let (branch_sandbox, worktree_path): (Arc, Option) = if let ( +- Some(ref gs), +- Some(ref bsha), +- ) = +- (&git_state, &base_sha) +- { +- let branch_key = &target_id; +- let visit = visit_from_context(&branch_context); +- let branch_name = format!( +- "fabro/run/parallel/{}/{}/pass{}/{}", +- gs.run_id, +- sanitize_ref_component(&node.id), +- visit, +- sanitize_ref_component(branch_key), +- ); +- +- // Compute worktree path (each sandbox type knows its own path scheme) +- let wt_path_str = services.run.sandbox.parallel_worktree_path( +- run_dir, +- &gs.run_id.to_string(), +- &node.id, +- branch_key, +- ); +- tracing::debug!(branch = %branch_name, path = %wt_path_str, "Creating worktree for parallel branch"); +- +- // Set up worktree via WorktreeSandbox +- let wt_config = WorktreeOptions { +- branch_name: branch_name.clone(), +- base_sha: bsha.clone(), +- worktree_path: wt_path_str.clone(), +- skip_branch_creation: false, +- setup_intent: None, +- }; +- let mut wt_sandbox = +- WorktreeSandbox::new(Arc::clone(&services.run.sandbox), wt_config); +- wt_sandbox +- .set_event_callback(Arc::clone(&services.run.emitter).worktree_callback()); +- wt_sandbox +- .initialize() +- .await +- .map_err(|e| Error::handler_with_source("worktree setup failed", e))?; +- +- branch_context.set(keys::INTERNAL_WORK_DIR, serde_json::json!(&wt_path_str)); +- +- let wt_path = PathBuf::from(&wt_path_str); +- let env: Arc = Arc::new(wt_sandbox); +- (env, Some(wt_path)) +- } else { +- (Arc::clone(&services.run.sandbox), None) +- }; +- +- branch_setups.push(BranchSetup { +- target_id, +- branch_index, +- parallel_branch_id, +- branch_context, +- sandbox: branch_sandbox, +- worktree_path, +- }); +- } + +- // --- Fan out: concurrent execution --- +- let mut handles = Vec::new(); +- for setup in branch_setups { +- let parent_run = Arc::clone(&services.run); +- let registry = Arc::clone(&services.registry); +- let interviewer = Arc::clone(&services.interviewer); +- let base_env = services.base_env.clone(); +- let github_token = services.github_token.clone(); +- let inputs = services.inputs.clone(); +- let dry_run = services.dry_run; +- let workflow_path = services.workflow_path.clone(); +- let workflow_bundle = services.workflow_bundle.clone(); +- let graph = graph.clone(); +- let run_dir = run_dir.to_path_buf(); +- let sem = Arc::clone(&semaphore); +- let has_git = git_state.is_some(); +- let run_id = git_state.as_ref().map(|gs| gs.run_id); +- let git_author = git_state +- .as_ref() +- .map(|gs| gs.git_author.clone()) +- .unwrap_or_default(); +- let checkpoint = git_state +- .as_ref() +- .map(|gs| gs.checkpoint.clone()) +- .unwrap_or_default(); +- let group_id = parallel_group_id.clone(); +- let branch_scope = StageScope::for_parallel_branch( +- setup.target_id.clone(), +- 1, +- group_id.clone(), +- setup.parallel_branch_id.clone(), +- ); ++ let mut branch_services = services.clone(); ++ branch_services.dry_run = simulated || services.dry_run; ++ let parent_snapshot = Arc::clone(&parent_snapshot); ++ let graph = Arc::clone(&shared_graph); ++ let run_dir = run_dir.to_path_buf(); ++ let semaphore = Arc::clone(&semaphore); ++ let group_id = parallel_group_id.clone(); ++ let branch_scope = StageScope::for_parallel_branch( ++ target_id.clone(), ++ 1, ++ group_id.clone(), ++ parallel_branch_id.clone(), ++ ); + +- let handle = tokio::spawn(async move { +- let _permit = sem +- .acquire() +- .await +- .map_err(|e| Error::handler_with_source("semaphore error", e))?; +- +- parent_run.emitter.emit_scoped( +- &Event::ParallelBranchStarted { +- parallel_group_id: group_id.clone(), +- parallel_branch_id: setup.parallel_branch_id.clone(), +- branch: setup.target_id.clone(), +- index: setup.branch_index, +- }, +- &branch_scope, +- ); ++ dispatches.push(BranchDispatch { ++ index: branch_index, ++ target_id: target_id.clone(), ++ branch_id: parallel_branch_id.clone(), ++ scope: branch_scope.clone(), ++ handle: tokio::spawn(async move { + let branch_start = Instant::now(); ++ let task = async { ++ let permit = semaphore.acquire(); ++ tokio::pin!(permit); ++ let cancel_token = branch_services.run.cancel_token(); ++ let _permit = tokio::select! { ++ biased; ++ () = cancel_token.cancelled() => { ++ return Err(Error::Cancelled); ++ } ++ permit = &mut permit => permit ++ .map_err(|err| Error::handler_with_source("semaphore error", err))?, ++ }; ++ branch_services.run.emitter.emit_scoped( ++ &Event::ParallelBranchStarted { ++ parallel_group_id: group_id.clone(), ++ parallel_branch_id: parallel_branch_id.clone(), ++ branch: target_id.clone(), ++ index: branch_index, ++ }, ++ &branch_scope, ++ ); + +- let Some(target_node) = graph.nodes.get(&setup.target_id) else { +- let outcome = Outcome::fail_classify(format!( +- "branch target node not found: {}", +- setup.target_id +- )); +- parent_run.emitter.emit_scoped( ++ let outcome = match graph.nodes.get(&target_id) { ++ Some(target_node) => { ++ let handler = branch_services.registry.resolve(target_node); ++ match super::dispatch_handler( ++ handler, ++ target_node, ++ &branch_context, ++ &graph, ++ &run_dir, ++ &branch_services, ++ ) ++ .await ++ { ++ Ok(outcome) => outcome, ++ Err(Error::Cancelled) => return Err(Error::Cancelled), ++ Err(err) => err.to_fail_outcome(), ++ } ++ } ++ None => Outcome::fail_classify(format!( ++ "branch target node not found: {target_id}" ++ )), ++ }; ++ ++ let context_updates = branch_context_updates( ++ &parent_snapshot, ++ branch_context.snapshot(), ++ &outcome.context_updates, ++ ); ++ let result = ParallelBranchResult { ++ id: target_id.clone(), ++ status: outcome.status, ++ context_updates, ++ }; ++ branch_services.run.emitter.emit_scoped( + &Event::ParallelBranchCompleted { + parallel_group_id: group_id.clone(), +- parallel_branch_id: setup.parallel_branch_id.clone(), +- branch: setup.target_id.clone(), +- index: setup.branch_index, ++ parallel_branch_id: parallel_branch_id.clone(), ++ branch: target_id.clone(), ++ index: branch_index, + duration_ms: millis_u64(branch_start.elapsed()), +- status: "failed".to_string(), +- head_sha: None, ++ status: result.status, + }, + &branch_scope, + ); +- return Ok(BranchResult { +- id: setup.target_id.clone(), +- outcome, +- head_sha: None, +- worktree_path: setup.worktree_path, +- }); ++ Ok::(BranchResult { result, outcome }) + }; + +- let branch_services = EngineServices { +- run: parent_run.with_sandbox(Arc::clone(&setup.sandbox)), +- registry: Arc::clone(®istry), +- interviewer, +- git_state: std::sync::RwLock::new(None), +- base_env: base_env.clone(), +- github_token: github_token.clone(), +- inputs: inputs.clone(), +- dry_run, +- workflow_path, +- workflow_bundle, +- }; +- let handler = registry.resolve(target_node); +- let outcome = super::dispatch_handler( +- handler, +- target_node, +- &setup.branch_context, +- &graph, +- &run_dir, +- &branch_services, +- ) +- .await?; +- +- // Checkpoint commit after branch execution (capture head_sha) +- let head_sha = if has_git { +- let rid = +- run_id.map_or_else(|| "unknown".to_string(), |run_id| run_id.to_string()); +- let nid = &setup.target_id; +- let status_str = outcome.status.to_string(); +- // Use exec_command to commit and capture HEAD in the branch worktree +- let git_r = GIT_REMOTE; +- let add_cmd = format!("{git_r} add -A"); +- let add_result = setup +- .sandbox +- .exec_command(&add_cmd, checkpoint.commit_timeout_ms, None, None, None) +- .await; +- if add_result +- .as_ref() +- .is_ok_and(fabro_sandbox::ExecResult::is_success) +- { +- let msg = format!("fabro({rid}): {nid} ({status_str})"); +- let commit_cmd = parallel_branch_commit_cmd( +- git_r, +- &git_author.name, +- &git_author.email, +- &msg, +- checkpoint.skip_git_hooks, ++ match std::panic::AssertUnwindSafe(task).catch_unwind().await { ++ Ok(result) => result, ++ Err(payload) => { ++ let result = ++ failed_branch_result(&target_id, super::format_panic_message(&payload)); ++ branch_services.run.emitter.emit_scoped( ++ &Event::ParallelBranchCompleted { ++ parallel_group_id: group_id, ++ parallel_branch_id, ++ branch: target_id, ++ index: branch_index, ++ duration_ms: millis_u64(branch_start.elapsed()), ++ status: result.result.status, ++ }, ++ &branch_scope, + ); +- let _ = setup +- .sandbox +- .exec_command( +- &commit_cmd, +- checkpoint.commit_timeout_ms, +- None, +- None, +- None, +- ) +- .await; ++ Ok(result) + } +- let sha_cmd = format!("{git_r} rev-parse HEAD"); +- let sha_result = setup +- .sandbox +- .exec_command(&sha_cmd, 10_000, None, None, None) +- .await; +- match sha_result { +- Ok(r) if r.is_success() => { +- let sha = r.stdout.trim().to_string(); +- parent_run.emitter.emit_scoped( +- &Event::GitCommit { +- node_id: Some(setup.target_id.clone()), +- sha: sha.clone(), +- }, +- &branch_scope, +- ); +- Some(sha) +- } +- _ => None, +- } +- } else { +- None +- }; +- +- parent_run.emitter.emit_scoped( +- &Event::ParallelBranchCompleted { +- parallel_group_id: group_id.clone(), +- parallel_branch_id: setup.parallel_branch_id.clone(), +- branch: setup.target_id.clone(), +- index: setup.branch_index, +- duration_ms: millis_u64(branch_start.elapsed()), +- status: outcome.status.to_string(), +- head_sha: head_sha.clone(), +- }, +- &branch_scope, +- ); +- +- Ok::(BranchResult { +- id: setup.target_id, +- outcome, +- head_sha, +- worktree_path: setup.worktree_path, +- }) +- }); +- handles.push(handle); +- } +- +- // Collect results +- let mut results: Vec = Vec::new(); +- let mut handles = handles.into_iter(); +- while let Some(handle) = handles.next() { +- match handle.await { +- Ok(Ok(result)) => { +- results.push(result); +- } +- Ok(Err(Error::Cancelled)) => { +- for handle in handles { +- handle.abort(); +- } +- return Err(Error::Cancelled); +- } +- Ok(Err(e)) => { +- results.push(BranchResult { +- id: String::new(), +- outcome: e.to_fail_outcome(), +- head_sha: None, +- worktree_path: None, +- }); +- } +- Err(join_err) => { +- results.push(BranchResult { +- id: String::new(), +- outcome: Outcome::fail_classify(format!( +- "task join error: {join_err}" +- )), +- head_sha: None, +- worktree_path: None, +- }); +- } +- } +- } +- +- // --- Git isolation: clean up worktrees, then ff-merge winner --- +- if git_state.is_some() { +- // Clean up worktrees first +- for result in &results { +- if let Some(ref wt_path) = result.worktree_path { +- let wt_str = wt_path.to_string_lossy().into_owned(); +- git_remove_worktree(&*services.run.sandbox, &wt_str).await; +- services +- .run +- .emitter +- .emit(&Event::GitWorktreeRemove { path: wt_str }); + } +- } +- +- // Fast-forward main branch to first successful branch (lexically sorted). +- // This must happen here — before the engine creates its own checkpoint commit +- // on the main branch — so that subsequent commits are descendants of the +- // winner. +- let mut successful: Vec<_> = results +- .iter() +- .filter(|r| r.outcome.status == StageOutcome::Succeeded && r.head_sha.is_some()) +- .collect(); +- successful.sort_by(|a, b| a.id.cmp(&b.id)); +- if let Some(winner) = successful.first() { +- if let Some(sha) = winner.head_sha.as_ref() { +- git_merge_ff_only(&*services.run.sandbox, sha).await; +- } +- } +- } +- +- // Count successes and failures +- let success_count = results +- .iter() +- .filter(|r| r.outcome.status == StageOutcome::Succeeded) +- .count(); +- let fail_count = results +- .iter() +- .filter(|r| r.outcome.status.is_failure()) +- .count(); +- let total = results.len(); +- +- // Store results as JSON in context for downstream fan-in +- let results_json: Vec = results +- .iter() +- .map(|r| { +- let mut entry = serde_json::json!({ +- "id": r.id, +- "status": r.outcome.status.to_string(), +- }); +- if let Some(ref sha) = r.head_sha { +- entry["head_sha"] = serde_json::json!(sha); +- } +- entry +- }) +- .collect(); +- context.set(keys::PARALLEL_RESULTS, serde_json::json!(results_json)); +- context.set(keys::PARALLEL_BRANCH_COUNT, serde_json::json!(total)); +- +- services.run.emitter.emit_scoped( +- &Event::ParallelCompleted { +- node_id: node.id.clone(), +- visit: parallel_stage_scope.visit, +- duration_ms: millis_u64(parallel_start.elapsed()), +- success_count, +- failure_count: fail_count, +- results: results_json.clone(), +- }, +- ¶llel_stage_scope, +- ); +- { +- let run_id = context +- .run_id() +- .parse::() +- .map_err(|err| Error::handler_with_source("invalid internal run_id", err))?; +- let mut hook_ctx = +- HookContext::new(HookEvent::ParallelComplete, run_id, graph.name.clone()); +- set_hook_node(&mut hook_ctx, node); +- let _ = services.run.run_hooks(&hook_ctx).await; +- } ++ }), ++ }); ++ } + +- // Evaluate join policy +- let status = match join_policy { +- JoinPolicy::WaitAll => { +- if fail_count == 0 { +- StageOutcome::Succeeded +- } else { +- StageOutcome::PartiallySucceeded +- } +- } +- JoinPolicy::FirstSuccess => { +- if success_count > 0 { +- StageOutcome::Succeeded +- } else { +- StageOutcome::Failed { +- retry_requested: false, +- } +- } ++ // Awaiting in dispatch order keeps `results` aligned with the node's ++ // outgoing-edge order regardless of branch completion order. ++ let mut results = Vec::with_capacity(dispatches.len()); ++ let mut cancelled = false; ++ for dispatch in dispatches { ++ let (result, emit_completion) = match dispatch.handle.await { ++ Ok(Ok(result)) => (result, false), ++ Ok(Err(Error::Cancelled)) => { ++ cancelled = true; ++ ( ++ failed_branch_result(&dispatch.target_id, "branch cancelled"), ++ true, ++ ) + } ++ Ok(Err(err)) => ( ++ failed_branch_result(&dispatch.target_id, err.to_string()), ++ true, ++ ), ++ Err(join_err) => ( ++ failed_branch_result(&dispatch.target_id, format!("task join error: {join_err}")), ++ true, ++ ), + }; +- +- // Find the join/convergence node: follow each branch's outgoing edges +- // and find the common downstream target (typically the fan-in node). +- let join_node = find_join_node(&results, graph); +- +- let is_fail = status.is_failure(); +- let mut outcome = Outcome { +- status, +- notes: Some(format!( +- "Parallel node dispatched {total} branches ({success_count} succeeded, {fail_count} failed)" +- )), +- failure: if is_fail { +- Some(FailureDetail::new( +- format!("Join policy not satisfied: {success_count}/{total} succeeded"), +- FailureCategory::Deterministic, +- )) +- } else { +- None +- }, +- jump_to_node: if is_fail { None } else { join_node }, +- ..Outcome::success() +- }; +- +- if is_fail { +- outcome.suggested_next_ids.clear(); ++ if emit_completion { ++ services.run.emitter.emit_scoped( ++ &Event::ParallelBranchCompleted { ++ parallel_group_id: parallel_group_id.clone(), ++ parallel_branch_id: dispatch.branch_id, ++ branch: dispatch.target_id, ++ index: dispatch.index, ++ duration_ms: 0, ++ status: result.result.status, ++ }, ++ &dispatch.scope, ++ ); ++ } ++ if result.outcome.failure_category() == Some(FailureCategory::Canceled) { ++ cancelled = true; + } ++ results.push(result); ++ } ++ if cancelled { ++ return Err(Error::Cancelled); ++ } + +- Ok(outcome) ++ let success_count = results ++ .iter() ++ .filter(|branch| branch.outcome.status == StageOutcome::Succeeded) ++ .count(); ++ let failure_count = results ++ .iter() ++ .filter(|branch| branch.outcome.status.is_failure()) ++ .count(); ++ let total = results.len(); ++ let status = aggregate_status(&results); ++ let is_failure = status.is_failure(); ++ let jump_to_node = if is_failure { ++ None ++ } else { ++ find_join_node(&results, graph) ++ }; ++ ++ let mut typed_results = results ++ .into_iter() ++ .map(|branch| branch.result) ++ .collect::>(); ++ // Offload large leaves before the results reach the event log and ++ // projection: the artifact lifecycle's offload pass runs only after the ++ // handler returns, too late for the `parallel.completed` payload. ++ if let Err(err) = ++ artifact::offload_parallel_branch_updates(&mut typed_results, &services.run.run_store).await ++ { ++ services.run.emitter.notice( ++ RunNoticeLevel::Warn, ++ RunNoticeCode::ArtifactOffloadFailed, ++ format!("[node: {}] parallel result offload failed: {err}", node.id), ++ ); + } ++ let results_value = serde_json::to_value(&typed_results) ++ .map_err(|err| Error::handler_with_source("parallel result serialization failed", err))?; ++ let context_updates = HashMap::from([ ++ (keys::PARALLEL_RESULTS.to_string(), results_value), ++ ( ++ keys::PARALLEL_BRANCH_COUNT.to_string(), ++ serde_json::json!(total), ++ ), ++ ]); ++ ++ services.run.emitter.emit_scoped( ++ &Event::ParallelCompleted { ++ node_id: node.id.clone(), ++ visit: parallel_stage_scope.visit, ++ duration_ms: millis_u64(parallel_start.elapsed()), ++ success_count, ++ failure_count, ++ results: typed_results, ++ }, ++ ¶llel_stage_scope, ++ ); ++ emit_parallel_hook(services, context, graph, node, HookEvent::ParallelComplete).await?; ++ ++ let prefix = if simulated { "[Simulated] " } else { "" }; ++ let mut outcome = Outcome { ++ status, ++ notes: Some(format!( ++ "{prefix}Parallel node dispatched {total} branches ({success_count} succeeded, {failure_count} failed)" ++ )), ++ failure: is_failure.then(|| { ++ FailureDetail::new( ++ "All parallel branches failed", ++ FailureCategory::Deterministic, ++ ) ++ }), ++ jump_to_node, ++ context_updates, ++ ..Outcome::success() ++ }; ++ if is_failure { ++ outcome.suggested_next_ids.clear(); ++ } ++ Ok(outcome) + } + +-/// Find the convergence (join/fan-in) node by following each branch's outgoing +-/// edges and finding the first node reachable from all branches. +-fn find_join_node(results: &[BranchResult], graph: &Graph) -> Option { +- if results.is_empty() { +- return None; +- } ++async fn emit_parallel_hook( ++ services: &EngineServices, ++ context: &Context, ++ graph: &Graph, ++ node: &Node, ++ hook_event: HookEvent, ++) -> Result<(), Error> { ++ let run_id = context.parsed_run_id()?; ++ let mut hook_context = HookContext::new(hook_event, run_id, graph.name.clone()); ++ set_hook_node(&mut hook_context, node); ++ let _ = services.run.run_hooks(&hook_context).await; ++ Ok(()) ++} + +- // Collect outgoing targets for each branch +- let mut target_sets: Vec> = Vec::new(); +- for result in results { +- let targets: std::collections::HashSet = graph +- .outgoing_edges(&result.id) ++fn branch_context_updates( ++ before: &HashMap, ++ after: HashMap, ++ outcome_updates: &HashMap, ++) -> BTreeMap { ++ let mut updates = outcome_updates ++ .iter() ++ .map(|(key, value)| (key.clone(), value.clone())) ++ .collect::>(); ++ updates.extend( ++ context_diff(before, after) + .into_iter() +- .map(|e| e.to.clone()) +- .collect(); +- target_sets.push(targets); ++ .filter(|(key, _)| !keys::is_engine_internal_key(key)), ++ ); ++ updates ++} ++ ++fn failed_branch_result(id: &str, reason: impl Into) -> BranchResult { ++ let outcome = Outcome::fail_classify(reason); ++ BranchResult { ++ result: ParallelBranchResult { ++ id: id.to_string(), ++ status: outcome.status, ++ context_updates: BTreeMap::new(), ++ }, ++ outcome, + } ++} + +- // Find the intersection — nodes reachable from ALL branches +- let first = target_sets.first()?; +- let common: std::collections::HashSet<&String> = first ++fn aggregate_status(results: &[BranchResult]) -> StageOutcome { ++ if results.is_empty() { ++ StageOutcome::PartiallySucceeded ++ } else if results + .iter() +- .filter(|id| target_sets.iter().all(|set| set.contains(*id))) +- .collect(); +- +- // Return the first common target (lexically sorted for determinism) +- let mut common_sorted: Vec<&String> = common.into_iter().collect(); +- common_sorted.sort(); +- common_sorted.first().map(|id| (*id).clone()) ++ .all(|result| result.outcome.status == StageOutcome::Succeeded) ++ { ++ StageOutcome::Succeeded ++ } else if results ++ .iter() ++ .all(|result| result.outcome.status.is_failure()) ++ { ++ StageOutcome::Failed { ++ retry_requested: false, ++ } ++ } else { ++ StageOutcome::PartiallySucceeded ++ } + } + +-/// Build the parallel-branch checkpoint commit command. Appends +-/// `--no-verify` when `skip_git_hooks` is true so the commit bypasses the +-/// repository's local Git commit hooks (e.g. `pre-commit`, `commit-msg`). +-fn parallel_branch_commit_cmd( +- git_remote: &str, +- author_name: &str, +- author_email: &str, +- message: &str, +- skip_git_hooks: bool, +-) -> String { +- let no_verify = if skip_git_hooks { " --no-verify" } else { "" }; +- let name = fabro_sandbox::shell_quote(&format!("user.name={author_name}")); +- let email = fabro_sandbox::shell_quote(&format!("user.email={author_email}")); +- let msg = fabro_sandbox::shell_quote(message); +- format!("{git_remote} -c {name} -c {email} commit --allow-empty{no_verify} -m {msg}") ++/// Find the convergence node by finding a common direct target of every branch. ++fn find_join_node(results: &[BranchResult], graph: &Graph) -> Option { ++ let first_result = results.first()?; ++ let first_targets = graph ++ .outgoing_edges(&first_result.result.id) ++ .into_iter() ++ .map(|edge| edge.to.clone()) ++ .collect::>(); ++ let mut common = first_targets ++ .into_iter() ++ .filter(|target| { ++ results.iter().skip(1).all(|result| { ++ graph ++ .outgoing_edges(&result.result.id) ++ .into_iter() ++ .any(|edge| &edge.to == target) ++ }) ++ }) ++ .collect::>(); ++ common.sort(); ++ common.into_iter().next() + } + + #[cfg(test)] +@@ -728,7 +470,7 @@ mod tests { + graph: serde_json::to_value(fabro_types::Graph::new("test")).unwrap(), + workflow_source: None, + workflow_config: None, +- labels: std::collections::BTreeMap::default(), ++ labels: BTreeMap::default(), + run_dir: "/tmp".to_string(), + source_directory: None, + workflow_slug: None, +@@ -750,47 +492,18 @@ mod tests { + fn test_context() -> Context { + let context = Context::new(); + context.set( +- crate::context::keys::INTERNAL_RUN_ID, ++ keys::INTERNAL_RUN_ID, + serde_json::json!(fixtures::RUN_1.to_string()), + ); + context + } + +- #[tokio::test] +- async fn parallel_handler_no_branches() { +- let services = make_services(); +- let node = Node::new("par"); +- let context = test_context(); +- let graph = Graph::new("test"); +- let run_dir = Path::new("/tmp/test"); +- +- let outcome = ParallelHandler +- .execute(&node, &context, &graph, run_dir, &services) +- .await +- .unwrap(); +- assert_eq!(outcome.status, StageOutcome::Failed { +- retry_requested: false, +- }); +- } +- +- #[tokio::test] +- async fn parallel_handler_with_branches() { +- let store = test_store(); +- let run_store = store.create_run(&fixtures::RUN_1).await.unwrap(); +- seed_created(&run_store).await; +- let mut services = EngineServices::test_default(); +- services.run = services +- .run +- .with_emitter(Arc::new(crate::event::Emitter::new(fixtures::RUN_1))) +- .with_run_store(run_store.clone().into()); +- let logger = crate::event::StoreProgressLogger::new(run_store.clone()); +- logger.register(services.run.emitter.as_ref()); ++ fn parallel_graph() -> (Node, Graph) { + let mut node = Node::new("par"); + node.attrs.insert( + "shape".to_string(), + AttrValue::String("component".to_string()), + ); +- let context = test_context(); + let mut graph = Graph::new("test"); + graph.nodes.insert("par".to_string(), node.clone()); + graph +@@ -801,189 +514,172 @@ mod tests { + .insert("branch_b".to_string(), Node::new("branch_b")); + graph.edges.push(Edge::new("par", "branch_a")); + graph.edges.push(Edge::new("par", "branch_b")); ++ (node, graph) ++ } + +- let tmp = tempfile::tempdir().unwrap(); ++ #[tokio::test] ++ async fn parallel_handler_no_branches() { + let outcome = ParallelHandler +- .execute(&node, &context, &graph, tmp.path(), &services) ++ .execute( ++ &Node::new("par"), ++ &test_context(), ++ &Graph::new("test"), ++ Path::new("/tmp/test"), ++ &make_services(), ++ ) + .await + .unwrap(); +- logger.flush().await; +- +- assert_eq!(outcome.status, StageOutcome::Succeeded); +- assert!(outcome.notes.as_deref().unwrap().contains("2 branches")); +- +- // Check context was set +- let results = context.get(keys::PARALLEL_RESULTS); +- assert!(results.is_some()); +- +- let state = run_store.state().await.unwrap(); +- let node_state = state.stage(&StageId::new("par", 1)).unwrap(); +- let parsed = node_state.parallel_results.as_ref().unwrap(); +- assert!( +- parsed.is_array(), +- "parallel_results.json should be a JSON array" ++ assert_eq!(outcome.status, StageOutcome::PartiallySucceeded); ++ assert_eq!( ++ outcome.context_updates[keys::PARALLEL_RESULTS], ++ serde_json::json!([]) ++ ); ++ assert_eq!( ++ outcome.context_updates[keys::PARALLEL_BRANCH_COUNT], ++ serde_json::json!(0) + ); +- assert_eq!(parsed.as_array().unwrap().len(), 2); + } + + #[tokio::test] +- async fn parallel_handler_stores_results_in_run_store() { ++ async fn parallel_handler_returns_typed_ordered_results() { + let store = test_store(); + let run_store = store.create_run(&fixtures::RUN_1).await.unwrap(); + seed_created(&run_store).await; +- let mut services = EngineServices::test_default(); ++ let mut services = make_services(); + services.run = services + .run + .with_emitter(Arc::new(crate::event::Emitter::new(fixtures::RUN_1))) + .with_run_store(run_store.clone().into()); + let logger = crate::event::StoreProgressLogger::new(run_store.clone()); + logger.register(services.run.emitter.as_ref()); +- let mut node = Node::new("par"); +- node.attrs.insert( +- "shape".to_string(), +- AttrValue::String("component".to_string()), +- ); ++ let (node, graph) = parallel_graph(); + let context = test_context(); +- let mut graph = Graph::new("test"); +- graph.nodes.insert("par".to_string(), node.clone()); +- graph +- .nodes +- .insert("branch_a".to_string(), Node::new("branch_a")); +- graph +- .nodes +- .insert("branch_b".to_string(), Node::new("branch_b")); +- graph.edges.push(Edge::new("par", "branch_a")); +- graph.edges.push(Edge::new("par", "branch_b")); + +- let tmp = tempfile::tempdir().unwrap(); +- ParallelHandler +- .execute(&node, &context, &graph, tmp.path(), &services) ++ let outcome = ParallelHandler ++ .execute(&node, &context, &graph, Path::new("/tmp/test"), &services) + .await + .unwrap(); + logger.flush().await; + ++ assert_eq!(outcome.status, StageOutcome::Succeeded); ++ let results: Vec = ++ serde_json::from_value(outcome.context_updates[keys::PARALLEL_RESULTS].clone()) ++ .unwrap(); ++ assert_eq!( ++ results ++ .iter() ++ .map(|result| result.id.as_str()) ++ .collect::>(), ++ ["branch_a", "branch_b"] ++ ); ++ assert!( ++ results ++ .iter() ++ .all(|result| result.status == StageOutcome::Succeeded) ++ ); + let state = run_store.state().await.unwrap(); +- let node_state = state.stage(&fabro_store::StageId::new("par", 1)).unwrap(); +- let results = node_state.parallel_results.as_ref().unwrap(); +- assert!(results.is_array()); +- assert_eq!(results.as_array().unwrap().len(), 2); +- } +- +- #[tokio::test] +- async fn parallel_handler_first_success_policy() { +- let services = make_services(); +- let mut node = Node::new("par"); +- node.attrs.insert( +- "join_policy".to_string(), +- AttrValue::String("first_success".to_string()), ++ assert_eq!( ++ state ++ .stage(&StageId::new("par", 1)) ++ .unwrap() ++ .parallel_results ++ .as_ref() ++ .unwrap() ++ .len(), ++ 2 + ); +- let context = test_context(); +- let mut graph = Graph::new("test"); +- graph.nodes.insert("par".to_string(), node.clone()); +- graph +- .nodes +- .insert("branch_a".to_string(), Node::new("branch_a")); +- graph.edges.push(Edge::new("par", "branch_a")); +- +- let run_dir = Path::new("/tmp/test"); +- let outcome = ParallelHandler +- .execute(&node, &context, &graph, run_dir, &services) +- .await +- .unwrap(); +- +- assert_eq!(outcome.status, StageOutcome::Succeeded); +- } +- +- #[test] +- fn join_policy_display() { +- assert_eq!(JoinPolicy::WaitAll.to_string(), "wait_all"); +- assert_eq!(JoinPolicy::FirstSuccess.to_string(), "first_success"); +- } +- +- #[test] +- fn parse_join_policy_variants() { +- assert!(matches!(parse_join_policy("wait_all"), JoinPolicy::WaitAll)); +- assert!(matches!( +- parse_join_policy("first_success"), +- JoinPolicy::FirstSuccess +- )); +- // Invalid falls back to WaitAll +- assert!(matches!(parse_join_policy("invalid"), JoinPolicy::WaitAll)); + } + + #[tokio::test] +- async fn parallel_handler_simulate() { +- let services = make_services(); +- let mut node = Node::new("par"); +- node.attrs.insert( +- "shape".to_string(), +- AttrValue::String("component".to_string()), +- ); ++ async fn parallel_handler_simulate_returns_results_as_outcome_updates() { ++ let (node, graph) = parallel_graph(); + let context = test_context(); +- let mut graph = Graph::new("test"); +- graph.nodes.insert("par".to_string(), node.clone()); +- graph +- .nodes +- .insert("branch_a".to_string(), Node::new("branch_a")); +- graph +- .nodes +- .insert("branch_b".to_string(), Node::new("branch_b")); +- // Add a fan_in node reachable from both branches +- graph +- .nodes +- .insert("fan_in".to_string(), Node::new("fan_in")); +- graph.edges.push(Edge::new("par", "branch_a")); +- graph.edges.push(Edge::new("par", "branch_b")); +- graph.edges.push(Edge::new("branch_a", "fan_in")); +- graph.edges.push(Edge::new("branch_b", "fan_in")); +- +- let run_dir = Path::new("/tmp/test"); +- let mut dry_services = services; +- dry_services.dry_run = true; +- + let outcome = ParallelHandler +- .simulate(&node, &context, &graph, run_dir, &dry_services) ++ .simulate( ++ &node, ++ &context, ++ &graph, ++ Path::new("/tmp/test"), ++ &make_services(), ++ ) + .await + .unwrap(); + + assert_eq!(outcome.status, StageOutcome::Succeeded); + assert!(outcome.notes.as_deref().unwrap().contains("[Simulated]")); +- assert!(outcome.notes.as_deref().unwrap().contains("2 branches")); +- assert_eq!(outcome.jump_to_node, Some("fan_in".to_string())); +- +- let branch_count = context.get(keys::PARALLEL_BRANCH_COUNT); +- assert_eq!(branch_count, Some(serde_json::json!(2))); ++ assert_eq!( ++ outcome.context_updates[keys::PARALLEL_BRANCH_COUNT], ++ serde_json::json!(2) ++ ); + } + + #[test] +- fn parallel_branch_commit_cmd_includes_no_verify_when_skip_hooks_enabled() { +- let cmd = super::parallel_branch_commit_cmd( +- super::GIT_REMOTE, +- "Fabro", +- "fabro@example.com", +- "fabro(r1): branch_a (succeeded)", +- true, ++ fn aggregate_status_follows_parallel_truth_table() { ++ let success = |index: usize| BranchResult { ++ result: ParallelBranchResult { ++ id: format!("branch_{index}"), ++ status: StageOutcome::Succeeded, ++ context_updates: BTreeMap::new(), ++ }, ++ outcome: Outcome::success(), ++ }; ++ let failure = |index: usize| failed_branch_result(&format!("branch_{index}"), "failed"); ++ let partial = |index: usize| BranchResult { ++ result: ParallelBranchResult { ++ id: format!("branch_{index}"), ++ status: StageOutcome::PartiallySucceeded, ++ context_updates: BTreeMap::new(), ++ }, ++ outcome: Outcome { ++ status: StageOutcome::PartiallySucceeded, ++ ..Outcome::success() ++ }, ++ }; ++ ++ assert_eq!(aggregate_status(&[]), StageOutcome::PartiallySucceeded); ++ assert_eq!( ++ aggregate_status(&[success(0), success(1)]), ++ StageOutcome::Succeeded + ); +- assert!( +- cmd.contains("--no-verify"), +- "expected --no-verify when skip_git_hooks=true; got {cmd:?}" ++ assert!(aggregate_status(&[failure(0), failure(1)]).is_failure()); ++ assert_eq!( ++ aggregate_status(&[success(0), failure(1)]), ++ StageOutcome::PartiallySucceeded ++ ); ++ assert_eq!( ++ aggregate_status(&[success(0), partial(1)]), ++ StageOutcome::PartiallySucceeded ++ ); ++ assert_eq!( ++ aggregate_status(&[failure(0), partial(1)]), ++ StageOutcome::PartiallySucceeded + ); +- assert!(cmd.contains("commit --allow-empty")); + } + + #[test] +- fn parallel_branch_commit_cmd_omits_no_verify_when_skip_hooks_disabled() { +- let cmd = super::parallel_branch_commit_cmd( +- super::GIT_REMOTE, +- "Fabro", +- "fabro@example.com", +- "fabro(r1): branch_a (succeeded)", +- false, +- ); +- assert!( +- !cmd.contains("--no-verify"), +- "expected no --no-verify when skip_git_hooks=false; got {cmd:?}" ++ fn branch_context_updates_include_failed_outcome_updates_without_internal_keys() { ++ let before = HashMap::from([("shared".to_string(), serde_json::json!("parent"))]); ++ let after = HashMap::from([ ++ ("shared".to_string(), serde_json::json!("branch")), ++ ( ++ keys::INTERNAL_WORK_DIR.to_string(), ++ serde_json::json!("/workspace"), ++ ), ++ ]); ++ let outcome = HashMap::from([( ++ keys::COMMAND_OUTPUT.to_string(), ++ serde_json::json!({"stdout": "failure output"}), ++ )]); ++ ++ assert_eq!( ++ branch_context_updates(&before, after, &outcome), ++ BTreeMap::from([ ++ ( ++ keys::COMMAND_OUTPUT.to_string(), ++ serde_json::json!({"stdout": "failure output"}) ++ ), ++ ("shared".to_string(), serde_json::json!("branch")), ++ ]) + ); +- assert!(cmd.contains("commit --allow-empty")); + } + } +diff --git a/lib/crates/fabro-workflow/src/pipeline/execute.rs b/lib/crates/fabro-workflow/src/pipeline/execute.rs +index 0be068e88..cf02a736d 100644 +--- a/lib/crates/fabro-workflow/src/pipeline/execute.rs ++++ b/lib/crates/fabro-workflow/src/pipeline/execute.rs +@@ -17,7 +17,6 @@ use crate::lifecycle::WorkflowLifecycle; + use crate::node_handler::WorkflowNodeHandler; + use crate::outcome::Outcome; + use crate::records::Checkpoint; +-use crate::sandbox_git::GitState; + + fn seed_context_from_checkpoint(checkpoint: Option<&Checkpoint>) -> Context { + let context = Context::new(); +@@ -55,19 +54,6 @@ pub async fn execute(init: Initialized) -> Executed { + let graph_arc = Arc::new(graph.clone()); + let wf_graph = WorkflowGraph(Arc::clone(&graph_arc)); + +- let git_state = run_options.git.as_ref().and_then(|git| { +- let base_sha = git.base_sha.clone()?; +- Some(Arc::new(GitState { +- run_id: run_options.run_id, +- base_sha, +- run_branch: git.run_branch.clone(), +- meta_branch: git.meta_branch.clone(), +- checkpoint: run_options.checkpoint().clone(), +- git_author: run_options.git_author(), +- })) +- }); +- engine.set_git_state(git_state); +- + let handler = Arc::new(WorkflowNodeHandler { + services: Arc::clone(&engine), + run_dir: run_options.run_dir.clone(), +diff --git a/lib/crates/fabro-workflow/src/pipeline/initialize.rs b/lib/crates/fabro-workflow/src/pipeline/initialize.rs +index 54a6f1f45..8f1e56ee2 100644 +--- a/lib/crates/fabro-workflow/src/pipeline/initialize.rs ++++ b/lib/crates/fabro-workflow/src/pipeline/initialize.rs +@@ -611,7 +611,6 @@ pub async fn initialize( + run: Arc::clone(&run_services), + registry, + interviewer: Arc::clone(&options.interviewer), +- git_state: std::sync::RwLock::new(None), + base_env, + github_token, + inputs: options.run_options.settings.run.inputs.clone(), +diff --git a/lib/crates/fabro-workflow/src/sandbox_git.rs b/lib/crates/fabro-workflow/src/sandbox_git.rs +index 68b7e1bed..914153955 100644 +--- a/lib/crates/fabro-workflow/src/sandbox_git.rs ++++ b/lib/crates/fabro-workflow/src/sandbox_git.rs +@@ -4,7 +4,6 @@ use fabro_agent::Sandbox; + use fabro_checkpoint::trailer as trailerlink; + use fabro_checkpoint::trailer::Trailer; + use fabro_sandbox::shell_quote; +-use fabro_types::RunId; + use fabro_types::settings::run::RunCheckpointSettings; + use fabro_util::error::SharedError; + +@@ -20,17 +19,6 @@ pub struct GitCommandError { + pub source: fabro_sandbox::Error, + } + +-/// Captured git state for a workflow run, shared with handlers. +-#[derive(Debug, Clone)] +-pub struct GitState { +- pub run_id: RunId, +- pub base_sha: String, +- pub run_branch: Option, +- pub meta_branch: Option, +- pub checkpoint: RunCheckpointSettings, +- pub git_author: GitAuthor, +-} +- + pub const GIT_REMOTE: &str = + "git -c maintenance.auto=0 -c gc.auto=0 -c commit.gpgsign=false -c tag.gpgsign=false"; + +@@ -237,48 +225,6 @@ pub(crate) async fn git_diff_with_timeout( + } + } + +-/// Create a branch at a specific SHA via the sandbox. +-pub async fn git_create_branch_at(sandbox: &dyn Sandbox, name: &str, sha: &str) -> bool { +- let cmd = format!("{GIT_REMOTE} branch --force {name} {sha}"); +- matches!( +- sandbox.exec_command(&cmd, 30_000, None, None, None).await, +- Ok(r) if r.is_success() +- ) +-} +- +-/// Add a git worktree via the sandbox. +-pub async fn git_add_worktree(sandbox: &dyn Sandbox, path: &str, branch: &str) -> bool { +- let cmd = format!("{GIT_REMOTE} worktree add {path} {branch}"); +- matches!( +- sandbox.exec_command(&cmd, 30_000, None, None, None).await, +- Ok(r) if r.is_success() +- ) +-} +- +-/// Remove a git worktree via the sandbox. +-pub async fn git_remove_worktree(sandbox: &dyn Sandbox, path: &str) -> bool { +- let cmd = format!("{GIT_REMOTE} worktree remove --force {path}"); +- matches!( +- sandbox.exec_command(&cmd, 30_000, None, None, None).await, +- Ok(r) if r.is_success() +- ) +-} +- +-/// Fast-forward merge to a given SHA via the sandbox. +-pub async fn git_merge_ff_only(sandbox: &dyn Sandbox, sha: &str) -> bool { +- let cmd = format!("{GIT_REMOTE} merge --ff-only {sha}"); +- matches!( +- sandbox.exec_command(&cmd, 30_000, None, None, None).await, +- Ok(r) if r.is_success() +- ) +-} +- +-/// Remove any stale worktree at `path` (best-effort), then add a fresh one. +-pub async fn git_replace_worktree(sandbox: &dyn Sandbox, path: &str, branch: &str) -> bool { +- let _ = git_remove_worktree(sandbox, path).await; +- git_add_worktree(sandbox, path, branch).await +-} +- + // ── Machine-readable diff enumeration (Run Files endpoint) ───────────────── + + /// Hardened git-command prefix for the Run Files endpoint. +diff --git a/lib/crates/fabro-workflow/src/services.rs b/lib/crates/fabro-workflow/src/services.rs +index 44e0eca21..506206f45 100644 +--- a/lib/crates/fabro-workflow/src/services.rs ++++ b/lib/crates/fabro-workflow/src/services.rs +@@ -20,7 +20,6 @@ use crate::handler::HandlerRegistry; + use crate::interview_runtime::RunInterviewBlocker; + use crate::run_metadata::{RunMetadataRuntime, RunMetadataWriterHandle}; + use crate::runtime_store::RunStoreHandle; +-use crate::sandbox_git::GitState; + use crate::sandbox_git_runtime::SandboxGitRuntime; + use crate::workflow_bundle::WorkflowBundle; + +@@ -225,26 +224,24 @@ impl RunServices { + } + + /// Services available only while executing workflow nodes. ++#[derive(Clone)] + pub struct EngineServices { +- pub run: Arc, +- pub registry: Arc, +- pub interviewer: Arc, +- /// Git state for the current run. Set via `set_git_state` at the start of +- /// `execute` and read by parallel/fan-in handlers. +- pub(crate) git_state: std::sync::RwLock>>, ++ pub run: Arc, ++ pub registry: Arc, ++ pub interviewer: Arc, + /// Environment variables from `[sandbox.env]` config. +- pub base_env: HashMap, ++ pub base_env: HashMap, + /// GitHub token source used to inject `GITHUB_TOKEN` at the point of use. +- pub github_token: Option>, ++ pub github_token: Option>, + /// Typed values from `[run.inputs]`, available to prompt templates. +- pub inputs: HashMap, ++ pub inputs: HashMap, + /// When true, handlers should skip real execution and return simulated + /// results. +- pub dry_run: bool, ++ pub dry_run: bool, + /// Manifest path of the current workflow when running from a bundle. +- pub workflow_path: Option, ++ pub workflow_path: Option, + /// Bundled workflows available for child-workflow resolution. +- pub workflow_bundle: Option>, ++ pub workflow_bundle: Option>, + } + + impl EngineServices { +@@ -252,23 +249,6 @@ impl EngineServices { + resolve_workflow_env(&self.base_env, self.github_token.as_ref()).await + } + +- /// Read the current git state (if any). +- pub fn git_state(&self) -> Option> { +- self.git_state +- .read() +- .expect("git_state lock is never poisoned: no code panics while holding this lock") +- .clone() +- } +- +- /// Set the git state for the current run. +- pub fn set_git_state(&self, state: Option>) { +- *self +- .git_state +- .write() +- .expect("git_state lock is never poisoned: no code panics while holding this lock") = +- state; +- } +- + /// Test-only default: empty registry and cross-phase services. + #[cfg(test)] + #[expect( +@@ -343,7 +323,6 @@ impl EngineServices { + ), + registry: Arc::new(HandlerRegistry::new(Box::new(start::StartHandler))), + interviewer: Arc::new(fabro_interview::AutoApproveInterviewer::engine()), +- git_state: std::sync::RwLock::new(None), + base_env: HashMap::new(), + github_token: None, + inputs: HashMap::new(), +diff --git a/lib/crates/fabro-workflow/src/stage_scope.rs b/lib/crates/fabro-workflow/src/stage_scope.rs +index fa309044b..396e84156 100644 +--- a/lib/crates/fabro-workflow/src/stage_scope.rs ++++ b/lib/crates/fabro-workflow/src/stage_scope.rs +@@ -37,8 +37,7 @@ impl StageScope { + } + + /// Build scope for the branch-lifecycle events emitted by the parallel +- /// handler (`ParallelBranchStarted`, `ParallelBranchCompleted`, and the +- /// pre-dispatch `GitCommit` for the branch worktree). ++ /// handler (`ParallelBranchStarted` and `ParallelBranchCompleted`). + /// + /// `target_visit` is the visit count of `target_node_id` for this + /// particular branch dispatch. The parallel handler currently passes +diff --git a/lib/crates/fabro-workflow/src/test_support.rs b/lib/crates/fabro-workflow/src/test_support.rs +index 1acf410a4..35c341a83 100644 +--- a/lib/crates/fabro-workflow/src/test_support.rs ++++ b/lib/crates/fabro-workflow/src/test_support.rs +@@ -241,7 +241,6 @@ async fn initialized( + ), + registry: Arc::new(registry), + interviewer: Arc::new(AutoApproveInterviewer::engine()), +- git_state: std::sync::RwLock::new(None), + base_env: options.env, + github_token: None, + inputs: run_options.settings.run.inputs.clone(), +diff --git a/lib/crates/fabro-workflow/tests/it/daytona_integration.rs b/lib/crates/fabro-workflow/tests/it/daytona_integration.rs +index 692911491..ba72bd221 100644 +--- a/lib/crates/fabro-workflow/tests/it/daytona_integration.rs ++++ b/lib/crates/fabro-workflow/tests/it/daytona_integration.rs +@@ -35,7 +35,7 @@ use fabro_workflow::event::Emitter; + use fabro_workflow::handler::exit::ExitHandler; + use fabro_workflow::handler::start::StartHandler; + use fabro_workflow::handler::{Handler, HandlerRegistry}; +-use fabro_workflow::outcome::{Outcome, OutcomeExt, StageOutcome}; ++use fabro_workflow::outcome::{Outcome, StageOutcome}; + use fabro_workflow::records::Checkpoint; + use fabro_workflow::run_options::{GitCheckpointOptions, RunOptions}; + use fabro_workflow::test_support::{WorkflowRunner, test_store_dir}; +@@ -767,234 +767,6 @@ async fn daytona_git_checkpoint_remote_emits_events() { + env.cleanup().await.unwrap(); + } + +-// --------------------------------------------------------------------------- +-// Parallel git branching on Daytona +-// --------------------------------------------------------------------------- +- +-use fabro_workflow::handler::fan_in::FanInHandler; +-use fabro_workflow::handler::parallel::ParallelHandler; +- +-/// End-to-end: parallel branches get isolated worktrees in Daytona sandbox, +-/// fan-in fast-forwards to winner. +-#[fabro_macros::e2e_test(live("DAYTONA_API_KEY"), live("GITHUB_APP_PRIVATE_KEY"))] +-async fn daytona_parallel_git_branching_e2e() { +- let env = create_env().await; +- env.initialize().await.unwrap(); +- let env: Arc = Arc::new(env); +- +- // Install git if not available +- let git_check = env +- .exec_command("git --version", 10_000, None, None, None) +- .await; +- if git_check.as_ref().map_or(true, |r| !r.is_success()) { +- let install = env +- .exec_command( +- "apt-get update -qq && apt-get install -y -qq git >/dev/null 2>&1", +- 120_000, +- None, +- None, +- None, +- ) +- .await +- .expect("apt-get install git should not error"); +- assert_eq!( +- install.exit_code, +- Some(0), +- "git install failed: {}", +- install.stderr +- ); +- } +- +- // Set up git in the sandbox (uses existing repo from Daytona project clone) +- let (run_id, base_sha, branch_name) = setup_daytona_git(&*env).await; +- +- // Pipeline: start -> fan_out -> {branch_a, branch_b} -> fan_in -> exit +- let mut graph = Graph::new("DaytonaParallelGitBranching"); +- graph.attrs.insert( +- "goal".to_string(), +- AttrValue::String("Test parallel git branching on Daytona".to_string()), +- ); +- +- let mut start = Node::new("start"); +- start.attrs.insert( +- "shape".to_string(), +- AttrValue::String("Mdiamond".to_string()), +- ); +- graph.nodes.insert("start".to_string(), start); +- +- let mut fan_out = Node::new("fan_out"); +- fan_out.attrs.insert( +- "shape".to_string(), +- AttrValue::String("component".to_string()), +- ); +- graph.nodes.insert("fan_out".to_string(), fan_out); +- +- let branch_a = Node::new("branch_a"); +- graph.nodes.insert("branch_a".to_string(), branch_a); +- +- let branch_b = Node::new("branch_b"); +- graph.nodes.insert("branch_b".to_string(), branch_b); +- +- let mut fan_in = Node::new("fan_in"); +- fan_in.attrs.insert( +- "shape".to_string(), +- AttrValue::String("tripleoctagon".to_string()), +- ); +- graph.nodes.insert("fan_in".to_string(), fan_in); +- +- let mut exit_node = Node::new("exit"); +- exit_node.attrs.insert( +- "shape".to_string(), +- AttrValue::String("Msquare".to_string()), +- ); +- graph.nodes.insert("exit".to_string(), exit_node); +- +- graph.edges.push(Edge::new("start", "fan_out")); +- graph.edges.push(Edge::new("fan_out", "branch_a")); +- graph.edges.push(Edge::new("fan_out", "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 run_tmp = tempfile::tempdir().unwrap(); +- let emitter = Emitter::default(); +- let events = Arc::new(std::sync::Mutex::new(Vec::new())); +- { +- let events_clone = Arc::clone(&events); +- emitter.on_event(move |event| { +- events_clone.lock().unwrap().push(event.clone()); +- }); +- } +- +- let mut registry = HandlerRegistry::new(Box::new(FileWriterHandler)); +- 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(None))); +- +- let engine = WorkflowRunner::new(registry, Arc::new(emitter), Arc::clone(&env)); +- +- let run_options = RunOptions { +- settings: WorkflowSettings::default(), +- run_dir: run_tmp.path().to_path_buf(), +- cancel_token: CancellationToken::new(), +- run_id, +- 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: Some(GitCheckpointOptions { +- base_sha: Some(base_sha), +- run_branch: Some(branch_name), +- meta_branch: None, +- }), +- }; +- let outcome = engine +- .run(&graph, &run_options) +- .await +- .expect("daytona parallel pipeline should succeed"); +- assert_eq!( +- outcome.status, +- StageOutcome::Succeeded, +- "pipeline failed: {:?}", +- outcome.failure_reason() +- ); +- +- // Verify parallel.results has head_sha for each branch +- let checkpoint = load_run_checkpoint(run_tmp.path()).expect("checkpoint should load"); +- let parallel_results = checkpoint +- .context_values +- .get("parallel.results") +- .expect("parallel.results should be in context"); +- let results_arr = parallel_results.as_array().expect("should be an array"); +- assert_eq!(results_arr.len(), 2, "should have 2 branch results"); +- +- // Both branches should have head_sha (40-char hex) +- let has_sha = results_arr.iter().all(|v| { +- v.get("head_sha") +- .and_then(|v| v.as_str()) +- .is_some_and(|s| s.len() == 40 && s.chars().all(|c| c.is_ascii_hexdigit())) +- }); +- assert!(has_sha, "all branches should have 40-char hex head_sha"); +- +- // Branch SHAs should differ (each branch made unique changes) +- let sha_a = results_arr +- .iter() +- .find(|v| v.get("id").and_then(|v| v.as_str()) == Some("branch_a")) +- .and_then(|v| v.get("head_sha").and_then(|v| v.as_str())) +- .unwrap(); +- let sha_b = results_arr +- .iter() +- .find(|v| v.get("id").and_then(|v| v.as_str()) == Some("branch_b")) +- .and_then(|v| v.get("head_sha").and_then(|v| v.as_str())) +- .unwrap(); +- assert_ne!(sha_a, sha_b, "branch SHAs should differ"); +- +- // Verify fan_in selected a winner and set best_head_sha +- let best_id = checkpoint +- .context_values +- .get("parallel.fan_in.best_id") +- .and_then(|v| v.as_str().map(String::from)) +- .expect("fan_in should have selected a best_id"); +- assert_eq!( +- best_id, "branch_a", +- "heuristic should pick branch_a (lexical)" +- ); +- +- let best_head_sha = checkpoint +- .context_values +- .get("parallel.fan_in.best_head_sha") +- .and_then(|v| v.as_str().map(String::from)); +- assert!( +- best_head_sha.is_some(), +- "fan_in should have set best_head_sha" +- ); +- +- // Verify winner's file exists in sandbox +- let winner_check = env +- .exec_command("cat branch_a.txt", 10_000, None, None, None) +- .await +- .expect("cat should succeed"); +- assert_eq!( +- winner_check.exit_code, +- Some(0), +- "winner's file should exist" +- ); +- assert!( +- winner_check.stdout.contains("branch_a"), +- "winner's file should have correct content, got: {}", +- winner_check.stdout +- ); +- +- // Verify events +- { +- let events = events.lock().unwrap(); +- let parallel_started: Vec<_> = events +- .iter() +- .filter(|e| e.event_name() == "parallel.started") +- .collect(); +- assert_eq!( +- parallel_started.len(), +- 1, +- "should have exactly one ParallelStarted event" +- ); +- let parallel_completed: Vec<_> = events +- .iter() +- .filter(|e| e.event_name() == "parallel.completed") +- .collect(); +- assert_eq!( +- parallel_completed.len(), +- 1, +- "should have exactly one ParallelCompleted event" +- ); +- } +- +- env.cleanup().await.expect("Daytona cleanup should succeed"); +-} +- + // --------------------------------------------------------------------------- + // Daytona shadow commit E2E with sandbox-native metadata + // --------------------------------------------------------------------------- +diff --git a/lib/crates/fabro-workflow/tests/it/git_integration.rs b/lib/crates/fabro-workflow/tests/it/git_integration.rs +index 8ff8f342d..bcdf8f9e0 100644 +--- a/lib/crates/fabro-workflow/tests/it/git_integration.rs ++++ b/lib/crates/fabro-workflow/tests/it/git_integration.rs +@@ -12,10 +12,7 @@ use fabro_agent::Sandbox; + use fabro_graphviz::graph::{AttrValue, Edge, Graph, Node}; + use fabro_types::{RunEvent, WorkflowSettings, fixtures}; + use fabro_workflow::event::Emitter; +-use fabro_workflow::git::{ +- add_worktree, branch_needs_push, create_branch, push_branch, push_ref, remove_worktree, +- replace_worktree, +-}; ++use fabro_workflow::git::{branch_needs_push, push_branch, push_ref}; + use fabro_workflow::handler::HandlerRegistry; + use fabro_workflow::handler::exit::ExitHandler; + use fabro_workflow::handler::start::StartHandler; +@@ -169,22 +166,6 @@ fn test_run_options(run_dir: &Path) -> RunOptions { + } + } + +-#[test] +-fn replace_worktree_replaces_stale() { +- let dir = tempfile::tempdir().unwrap(); +- init_repo(dir.path()); +- create_branch(dir.path(), "stale-branch").unwrap(); +- +- let wt_path = dir.path().join("stale-wt"); +- add_worktree(dir.path(), &wt_path, "stale-branch").unwrap(); +- assert!(wt_path.join(".git").exists()); +- +- replace_worktree(dir.path(), &wt_path, "stale-branch").unwrap(); +- assert!(wt_path.join(".git").exists()); +- +- remove_worktree(dir.path(), &wt_path).unwrap(); +-} +- + #[test] + fn push_ref_to_bare_remote() { + let dir = tempfile::tempdir().unwrap(); +@@ -195,7 +176,7 @@ fn push_ref_to_bare_remote() { + init_repo(&repo_dir); + add_origin(&repo_dir, &remote_dir); + +- create_branch(&repo_dir, "test-push").unwrap(); ++ rename_branch(&repo_dir, "test-push"); + let url = format!("file://{}", remote_dir.display()); + push_ref(&repo_dir, &url, "refs/heads/test-push").unwrap(); + +diff --git a/lib/crates/fabro-workflow/tests/it/integration.rs b/lib/crates/fabro-workflow/tests/it/integration.rs +index 87ebeee4d..3f26b21f9 100644 +--- a/lib/crates/fabro-workflow/tests/it/integration.rs ++++ b/lib/crates/fabro-workflow/tests/it/integration.rs +@@ -9755,7 +9755,7 @@ async fn artifact_pointers_rewritten_for_remote_sandbox() { + } + + #[tokio::test] +-async fn downstream_local_execution_materializes_blob_refs_to_runtime_files() { ++async fn downstream_local_execution_resolves_response_blob_refs_as_text() { + let mut graph = make_graph_with_start_exit("ArtifactMaterializeLocal"); + graph.attrs.insert( + "goal".to_string(), +@@ -9818,31 +9818,19 @@ async fn downstream_local_execution_materializes_blob_refs_to_runtime_files() { + .expect("pipeline should succeed"); + assert_eq!(outcome.status, StageOutcome::Succeeded); + +- let expected_blob_id = fabro_types::RunBlobId::new( +- &serde_json::to_vec(&serde_json::json!("x".repeat(150 * 1024))) +- .expect("large value should serialize"), +- ); + let captured_value = captured.lock().unwrap().first().cloned().unwrap(); +- let expected_path = RunScratch::new(dir.path()) +- .runtime_dir() +- .join("blobs") +- .join(format!("{expected_blob_id}.json")); +- assert_eq!( +- captured_value, +- format!("file://{}", expected_path.display()), +- "downstream handlers should receive a local file ref" +- ); +- let artifact_content = std::fs::read_to_string(&expected_path).expect("should read artifact"); +- let artifact_value: serde_json::Value = +- serde_json::from_str(&artifact_content).expect("should parse artifact JSON"); +- let artifact_str = artifact_value +- .as_str() +- .expect("artifact should be a string"); +- assert_eq!(artifact_str.len(), 150 * 1024); ++ assert_eq!(captured_value, "x".repeat(150 * 1024)); ++ assert!( ++ !RunScratch::new(dir.path()) ++ .runtime_dir() ++ .join("blobs") ++ .exists(), ++ "textual response values should resolve without file materialization" ++ ); + } + + #[tokio::test] +-async fn downstream_remote_execution_materializes_blob_refs_to_sandbox_files() { ++async fn downstream_remote_execution_resolves_response_blob_refs_as_text() { + let mut graph = make_graph_with_start_exit("ArtifactMaterializeRemote"); + graph.attrs.insert( + "goal".to_string(), +@@ -9906,27 +9894,11 @@ async fn downstream_remote_execution_materializes_blob_refs_to_sandbox_files() { + .expect("pipeline should succeed"); + assert_eq!(outcome.status, StageOutcome::Succeeded); + +- let expected_blob_id = fabro_types::RunBlobId::new( +- &serde_json::to_vec(&serde_json::json!("x".repeat(150 * 1024))) +- .expect("large value should serialize"), +- ); + let captured_value = captured.lock().unwrap().first().cloned().unwrap(); +- assert_eq!( +- captured_value, +- format!("file:///sandbox/.fabro/blobs/{expected_blob_id}.json"), +- "downstream handlers should receive a sandbox-local file ref" +- ); +- +- let written = remote_env.written.lock().unwrap(); +- assert_eq!(written.len(), 1, "should materialize the blob once"); +- assert_eq!( +- written[0].0, +- format!("/sandbox/.fabro/blobs/{expected_blob_id}.json") +- ); ++ assert_eq!(captured_value, "x".repeat(150 * 1024)); + assert!( +- written[0].1.len() > 100 * 1024, +- "written content should be >100KB, got {} bytes", +- written[0].1.len() ++ remote_env.written.lock().unwrap().is_empty(), ++ "textual response values should resolve without sandbox file materialization" + ); + } + +@@ -10065,7 +10037,7 @@ use fabro_workflow::handler::fan_in::FanInHandler; + use fabro_workflow::handler::parallel::ParallelHandler; + + /// A handler that writes a file named `{node_id}.txt` into the sandbox's +-/// working directory. Used to verify git worktree isolation in parallel ++/// working directory. Used to verify shared-checkout writes from parallel + /// branches. + struct FileWriterHandler; + +@@ -10413,18 +10385,13 @@ async fn git_checkpoint_host_skips_metadata_branch_without_writer_prereqs() { + } + + // --------------------------------------------------------------------------- +-// Host e2e: parallel git branching with worktree isolation ++// Host e2e: shared-checkout parallel execution + // --------------------------------------------------------------------------- + +-/// End-to-end: parallel branches get isolated worktrees, fan-in fast-forwards +-/// to winner. +-/// +-/// Pipeline: start -> fan_out -> {branch_a, branch_b} -> fan_in -> exit +-/// +-/// Each branch writes a unique file. After fan-in, only the winner's file +-/// should be present in the main worktree. ++/// End-to-end: parallel branches write to one shared checkout and normal ++/// run-level checkpointing captures all branch changes after the parallel node. + #[tokio::test] +-async fn parallel_git_branching_host_e2e() { ++async fn parallel_shared_checkout_host_e2e() { + // 1. Create a temporary git repo with an initial commit + let repo = tempfile::tempdir().unwrap(); + std::process::Command::new("git") +@@ -10532,10 +10499,7 @@ async fn parallel_git_branching_host_e2e() { + 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(None)), // heuristic select — picks branch_a (lexical tiebreak) +- ); ++ registry.register("parallel.fan_in", Box::new(FanInHandler::new(None))); + + let engine = WorkflowRunner::new(registry, Arc::new(emitter), env); + +@@ -10569,113 +10533,107 @@ async fn parallel_git_branching_host_e2e() { + outcome.failure_reason() + ); + +- // 6. Verify parallel.results has head_sha for each branch ++ // 6. Verify ordered typed results and that no fan-in selection state exists. + let checkpoint = load_run_checkpoint(run_dir.path()).expect("checkpoint should load"); + let parallel_results = checkpoint + .context_values + .get("parallel.results") + .expect("parallel.results should be in context"); +- let results_arr = parallel_results.as_array().expect("should be an array"); +- assert_eq!(results_arr.len(), 2, "should have 2 branch results"); +- +- // Both branches should have head_sha +- let branch_a_result = results_arr +- .iter() +- .find(|v| v.get("id").and_then(|v| v.as_str()) == Some("branch_a")) +- .expect("branch_a result should exist"); +- let branch_b_result = results_arr +- .iter() +- .find(|v| v.get("id").and_then(|v| v.as_str()) == Some("branch_b")) +- .expect("branch_b result should exist"); +- +- let sha_a = branch_a_result +- .get("head_sha") +- .and_then(|v| v.as_str()) +- .expect("branch_a should have head_sha"); +- let sha_b = branch_b_result +- .get("head_sha") +- .and_then(|v| v.as_str()) +- .expect("branch_b should have head_sha"); +- +- assert_eq!(sha_a.len(), 40, "SHA should be 40 hex chars"); +- assert_eq!(sha_b.len(), 40, "SHA should be 40 hex chars"); +- assert_ne!(sha_a, sha_b, "branch SHAs should differ"); +- +- // 7. Verify fan_in selected a winner and set best_head_sha +- let best_id = checkpoint +- .context_values +- .get("parallel.fan_in.best_id") +- .and_then(|v| v.as_str().map(String::from)) +- .expect("fan_in should have selected a best_id"); +- let best_head_sha = checkpoint +- .context_values +- .get("parallel.fan_in.best_head_sha") +- .and_then(|v| v.as_str().map(String::from)) +- .expect("fan_in should have set best_head_sha"); +- +- // Heuristic select with both success: lexical tiebreak picks "branch_a" ++ let results: Vec = ++ serde_json::from_value(parallel_results.clone()).expect("results should be typed"); + assert_eq!( +- best_id, "branch_a", +- "heuristic should pick branch_a (lexical)" ++ results ++ .iter() ++ .map(|result| (result.id.as_str(), result.status)) ++ .collect::>(), ++ [ ++ ("branch_a", fabro_types::StageOutcome::Succeeded), ++ ("branch_b", fabro_types::StageOutcome::Succeeded), ++ ] + ); +- +- // 8. Verify winner's file is in the main worktree, loser's is NOT +- let winner_file = worktree_path.join(format!("{best_id}.txt")); + assert!( +- winner_file.exists(), +- "winner's file ({best_id}.txt) should exist in main worktree after ff-merge" ++ results ++ .iter() ++ .all(|result| result.context_updates.is_empty()) + ); +- let winner_content = std::fs::read_to_string(&winner_file).unwrap(); + assert!( +- winner_content.contains(&format!("written by {best_id}")), +- "winner's file should have correct content" ++ checkpoint ++ .context_values ++ .keys() ++ .all(|key| !key.starts_with("parallel.fan_in.")) + ); + +- let loser_id = if best_id == "branch_a" { +- "branch_b" +- } else { +- "branch_a" +- }; +- let loser_file = worktree_path.join(format!("{loser_id}.txt")); +- assert!( +- !loser_file.exists(), +- "loser's file ({loser_id}.txt) should NOT exist in main worktree" +- ); ++ // 7. Both branches wrote into the one shared checkout. ++ for branch in ["branch_a", "branch_b"] { ++ let file = worktree_path.join(format!("{branch}.txt")); ++ assert!( ++ file.exists(), ++ "{branch} output should remain in the checkout" ++ ); ++ assert_eq!( ++ std::fs::read_to_string(file).unwrap(), ++ format!("written by {branch}") ++ ); ++ } + +- // 9. Verify the main worktree HEAD matches the winner's head_sha +- let main_head = { +- let out = std::process::Command::new("git") +- .args(["rev-parse", "HEAD"]) +- .current_dir(&worktree_path) +- .output() +- .unwrap(); +- String::from_utf8_lossy(&out.stdout).trim().to_string() +- }; +- // After fan-in ff-only + engine's own checkpoint commits, HEAD should be a +- // descendant of best_head_sha. +- let is_ancestor = std::process::Command::new("git") +- .args(["merge-base", "--is-ancestor", &best_head_sha, &main_head]) ++ // 8. Normal run-level checkpointing captured both files together. ++ let committed_files = std::process::Command::new("git") ++ .args(["ls-tree", "-r", "--name-only", "HEAD"]) + .current_dir(&worktree_path) + .output() + .unwrap(); ++ assert!(committed_files.status.success()); ++ let committed_files = String::from_utf8_lossy(&committed_files.stdout); ++ assert!(committed_files.lines().any(|path| path == "branch_a.txt")); ++ assert!(committed_files.lines().any(|path| path == "branch_b.txt")); ++ ++ // 9. Fabro created no branch-specific refs, commits, or worktrees. ++ let parallel_refs = std::process::Command::new("git") ++ .args([ ++ "for-each-ref", ++ "--format=%(refname)", ++ "refs/heads/fabro/run/parallel/", ++ ]) ++ .current_dir(repo.path()) ++ .output() ++ .unwrap(); ++ assert!(parallel_refs.status.success()); + assert!( +- is_ancestor.status.success(), +- "best_head_sha ({best_head_sha}) should be an ancestor of current HEAD ({main_head})" ++ parallel_refs.stdout.is_empty(), ++ "parallel refs must not exist" + ); + +- // 10. Verify parallel branch refs still exist (for debugging) +- let branch_ref_a = format!("fabro/run/parallel/{run_id}/fan-out/pass1/branch-a"); +- let ref_check = std::process::Command::new("git") +- .args(["rev-parse", "--verify", &branch_ref_a]) ++ let worktrees = std::process::Command::new("git") ++ .args(["worktree", "list", "--porcelain"]) + .current_dir(repo.path()) + .output() + .unwrap(); ++ assert!(worktrees.status.success()); ++ let worktree_count = String::from_utf8_lossy(&worktrees.stdout) ++ .lines() ++ .filter(|line| line.starts_with("worktree ")) ++ .count(); ++ assert_eq!( ++ worktree_count, 2, ++ "parallel branches must not add worktrees" ++ ); ++ ++ let commit_count = std::process::Command::new("git") ++ .args(["rev-list", "--count", &format!("{base_sha}..HEAD")]) ++ .current_dir(&worktree_path) ++ .output() ++ .unwrap(); ++ assert!(commit_count.status.success()); ++ let commit_count: usize = String::from_utf8_lossy(&commit_count.stdout) ++ .trim() ++ .parse() ++ .unwrap(); + assert!( +- ref_check.status.success(), +- "parallel branch ref should still exist for debugging" ++ (1..=2).contains(&commit_count), ++ "only run-level parallel/fan-in checkpoints should be committed, got {commit_count}" + ); + +- // 11. Verify events ++ // 10. Verify lifecycle events without parallel Git/worktree events. + let events = events.lock().unwrap(); + let parallel_started: Vec<_> = events + .iter() +@@ -10696,6 +10654,13 @@ async fn parallel_git_branching_host_e2e() { + 1, + "should have exactly one ParallelCompleted event" + ); ++ assert!( ++ events.iter().all(|event| !matches!( ++ event.event_name(), ++ "git.branch" | "git.worktree.added" | "git.worktree.removed" ++ )), ++ "parallel execution must not emit Git branch or worktree lifecycle events" ++ ); + + // Cleanup + let _ = std::process::Command::new("git") +diff --git a/lib/packages/fabro-api-client/src/.openapi-generator/FILES b/lib/packages/fabro-api-client/src/.openapi-generator/FILES +index 6c4bc5e4e..e7d9f3584 100644 +--- a/lib/packages/fabro-api-client/src/.openapi-generator/FILES ++++ b/lib/packages/fabro-api-client/src/.openapi-generator/FILES +@@ -265,6 +265,7 @@ models/pair-transcript-system-message.ts + models/pair-transcript-tool-call.ts + models/pair-transcript-user-message.ts + models/pair-transcript-warning.ts ++models/parallel-branch-result.ts + models/pending-interview-record.ts + models/pending-reason.ts + models/permission-level.ts +@@ -293,6 +294,8 @@ models/principal-webhook.ts + models/principal-worker.ts + models/principal.ts + models/project-namespace.ts ++models/provider-credential-test-request.ts ++models/provider-credential-test-response.ts + models/provider-list.ts + models/provider-test-list.ts + models/provider-test-result.ts +diff --git a/lib/packages/fabro-api-client/src/api/models-api.ts b/lib/packages/fabro-api-client/src/api/models-api.ts +index 6b19c88c3..783f597ad 100644 +--- a/lib/packages/fabro-api-client/src/api/models-api.ts ++++ b/lib/packages/fabro-api-client/src/api/models-api.ts +@@ -30,6 +30,10 @@ import type { ModelTestResult } from '../models'; + // @ts-ignore + import type { PaginatedModelList } from '../models'; + // @ts-ignore ++import type { ProviderCredentialTestRequest } from '../models'; ++// @ts-ignore ++import type { ProviderCredentialTestResponse } from '../models'; ++// @ts-ignore + import type { ProviderList } from '../models'; + // @ts-ignore + import type { ProviderTestList } from '../models'; +@@ -175,6 +179,51 @@ export const ModelsApiAxiosParamCreator = function (configuration?: Configuratio + options: localVarRequestOptions, + }; + }, ++ /** ++ * Validates an LLM provider API key against the server\'s effective catalog without persisting it. ++ * @summary Test Provider Credentials ++ * @param {string} provider The provider identifier. ++ * @param {ProviderCredentialTestRequest} providerCredentialTestRequest ++ * @param {*} [options] Override http request option. ++ * @throws {RequiredError} ++ */ ++ testProviderCredentials: async (provider: string, providerCredentialTestRequest: ProviderCredentialTestRequest, options: RawAxiosRequestConfig = {}): Promise => { ++ // verify required parameter 'provider' is not null or undefined ++ assertParamExists('testProviderCredentials', 'provider', provider) ++ // verify required parameter 'providerCredentialTestRequest' is not null or undefined ++ assertParamExists('testProviderCredentials', 'providerCredentialTestRequest', providerCredentialTestRequest) ++ const localVarPath = `/api/v1/providers/{provider}/credentials/test` ++ .replace(`{${"provider"}}`, encodeURIComponent(String(provider))); ++ // use dummy base URL string because the URL constructor only accepts absolute URLs. ++ const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); ++ let baseOptions; ++ if (configuration) { ++ baseOptions = configuration.baseOptions; ++ } ++ ++ const localVarRequestOptions = { method: 'POST', ...baseOptions, ...options}; ++ const localVarHeaderParameter = {} as any; ++ const localVarQueryParameter = {} as any; ++ ++ // authentication SessionCookie required ++ ++ // authentication BearerAuth required ++ // http bearer authentication required ++ await setBearerAuthToObject(localVarHeaderParameter, configuration) ++ ++ localVarHeaderParameter['Content-Type'] = 'application/json'; ++ localVarHeaderParameter['Accept'] = 'application/json'; ++ ++ setSearchParams(localVarUrlObj, localVarQueryParameter); ++ let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; ++ localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; ++ localVarRequestOptions.data = serializeDataIfNeeded(providerCredentialTestRequest, localVarRequestOptions, configuration) ++ ++ return { ++ url: toPathString(localVarUrlObj), ++ options: localVarRequestOptions, ++ }; ++ }, + /** + * Tests every configured LLM provider once using the catalog probe model. Provider-level failures are returned in the response body with HTTP 200. + * @summary Test Providers +@@ -262,6 +311,20 @@ export const ModelsApiFp = function(configuration?: Configuration) { + const localVarOperationServerBasePath = operationServerMap['ModelsApi.testModel']?.[localVarOperationServerIndex]?.url; + return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath); + }, ++ /** ++ * Validates an LLM provider API key against the server\'s effective catalog without persisting it. ++ * @summary Test Provider Credentials ++ * @param {string} provider The provider identifier. ++ * @param {ProviderCredentialTestRequest} providerCredentialTestRequest ++ * @param {*} [options] Override http request option. ++ * @throws {RequiredError} ++ */ ++ async testProviderCredentials(provider: string, providerCredentialTestRequest: ProviderCredentialTestRequest, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { ++ const localVarAxiosArgs = await localVarAxiosParamCreator.testProviderCredentials(provider, providerCredentialTestRequest, options); ++ const localVarOperationServerIndex = configuration?.serverIndex ?? 0; ++ const localVarOperationServerBasePath = operationServerMap['ModelsApi.testProviderCredentials']?.[localVarOperationServerIndex]?.url; ++ return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath); ++ }, + /** + * Tests every configured LLM provider once using the catalog probe model. Provider-level failures are returned in the response body with HTTP 200. + * @summary Test Providers +@@ -316,6 +379,17 @@ export const ModelsApiFactory = function (configuration?: Configuration, basePat + testModel(id: string, mode?: ModelTestMode, options?: RawAxiosRequestConfig): AxiosPromise { + return localVarFp.testModel(id, mode, options).then((request) => request(axios, basePath)); + }, ++ /** ++ * Validates an LLM provider API key against the server\'s effective catalog without persisting it. ++ * @summary Test Provider Credentials ++ * @param {string} provider The provider identifier. ++ * @param {ProviderCredentialTestRequest} providerCredentialTestRequest ++ * @param {*} [options] Override http request option. ++ * @throws {RequiredError} ++ */ ++ testProviderCredentials(provider: string, providerCredentialTestRequest: ProviderCredentialTestRequest, options?: RawAxiosRequestConfig): AxiosPromise { ++ return localVarFp.testProviderCredentials(provider, providerCredentialTestRequest, options).then((request) => request(axios, basePath)); ++ }, + /** + * Tests every configured LLM provider once using the catalog probe model. Provider-level failures are returned in the response body with HTTP 200. + * @summary Test Providers +@@ -368,6 +442,18 @@ export class ModelsApi extends BaseAPI { + return ModelsApiFp(this.configuration).testModel(id, mode, options).then((request) => request(this.axios, this.basePath)); + } + ++ /** ++ * Validates an LLM provider API key against the server\'s effective catalog without persisting it. ++ * @summary Test Provider Credentials ++ * @param {string} provider The provider identifier. ++ * @param {ProviderCredentialTestRequest} providerCredentialTestRequest ++ * @param {*} [options] Override http request option. ++ * @throws {RequiredError} ++ */ ++ public testProviderCredentials(provider: string, providerCredentialTestRequest: ProviderCredentialTestRequest, options?: RawAxiosRequestConfig) { ++ return ModelsApiFp(this.configuration).testProviderCredentials(provider, providerCredentialTestRequest, options).then((request) => request(this.axios, this.basePath)); ++ } ++ + /** + * Tests every configured LLM provider once using the catalog probe model. Provider-level failures are returned in the response body with HTTP 200. + * @summary Test Providers +diff --git a/lib/packages/fabro-api-client/src/models/hook-definition.ts b/lib/packages/fabro-api-client/src/models/hook-definition.ts +index a7d2f7cf1..85092d14f 100644 +--- a/lib/packages/fabro-api-client/src/models/hook-definition.ts ++++ b/lib/packages/fabro-api-client/src/models/hook-definition.ts +@@ -27,6 +27,9 @@ export interface HookDefinition { + 'type'?: HookDefinitionTypeEnum | null; + 'url'?: string | null; + 'headers'?: { [key: string]: string; } | null; ++ /** ++ * Allowlist of environment variable names that an http hook header may read via `{{ env.NAME }}`. An empty list (the default) permits no env vars in headers. ++ */ + 'allowed_env_vars'?: Array; + 'tls'?: TlsMode; + 'prompt'?: string | null; +diff --git a/lib/packages/fabro-api-client/src/models/index.ts b/lib/packages/fabro-api-client/src/models/index.ts +index 7b51ee78c..937ec7900 100644 +--- a/lib/packages/fabro-api-client/src/models/index.ts ++++ b/lib/packages/fabro-api-client/src/models/index.ts +@@ -235,6 +235,7 @@ export * from './pair-transcript-system-message'; + export * from './pair-transcript-tool-call'; + export * from './pair-transcript-user-message'; + export * from './pair-transcript-warning'; ++export * from './parallel-branch-result'; + export * from './pending-interview-record'; + export * from './pending-reason'; + export * from './permission-level'; +@@ -264,6 +265,8 @@ export * from './principal-webhook'; + export * from './principal-worker'; + export * from './project-namespace'; + export * from './provider'; ++export * from './provider-credential-test-request'; ++export * from './provider-credential-test-response'; + export * from './provider-list'; + export * from './provider-test-list'; + export * from './provider-test-result'; +diff --git a/lib/packages/fabro-api-client/src/models/parallel-branch-result.ts b/lib/packages/fabro-api-client/src/models/parallel-branch-result.ts +new file mode 100644 +index 000000000..994117dec +--- /dev/null ++++ b/lib/packages/fabro-api-client/src/models/parallel-branch-result.ts +@@ -0,0 +1,27 @@ ++/* tslint:disable */ ++/* eslint-disable */ ++/** ++ * Fabro Run API ++ * HTTP API for managing Fabro workflow run executions. ++ * ++ * The version of the OpenAPI document: 0.1.0 ++ * ++ * ++ * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). ++ * https://openapi-generator.tech ++ * Do not edit the class manually. ++ */ ++ ++ ++// May contain unused imports in some cases ++// @ts-ignore ++import type { StageOutcome } from './stage-outcome'; ++ ++/** ++ * The outcome and isolated context updates from one parallel branch. ++ */ ++export interface ParallelBranchResult { ++ 'id': string; ++ 'status': StageOutcome; ++ 'context_updates': { [key: string]: any; }; ++} +diff --git a/lib/packages/fabro-api-client/src/models/provider-credential-test-request.ts b/lib/packages/fabro-api-client/src/models/provider-credential-test-request.ts +new file mode 100644 +index 000000000..390b00848 +--- /dev/null ++++ b/lib/packages/fabro-api-client/src/models/provider-credential-test-request.ts +@@ -0,0 +1,22 @@ ++/* tslint:disable */ ++/* eslint-disable */ ++/** ++ * Fabro Run API ++ * HTTP API for managing Fabro workflow run executions. ++ * ++ * The version of the OpenAPI document: 0.1.0 ++ * ++ * ++ * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). ++ * https://openapi-generator.tech ++ * Do not edit the class manually. ++ */ ++ ++ ++ ++/** ++ * API key to validate against an LLM provider without persisting it. ++ */ ++export interface ProviderCredentialTestRequest { ++ 'api_key': string; ++} +diff --git a/lib/packages/fabro-api-client/src/models/provider-credential-test-response.ts b/lib/packages/fabro-api-client/src/models/provider-credential-test-response.ts +new file mode 100644 +index 000000000..b74a688eb +--- /dev/null ++++ b/lib/packages/fabro-api-client/src/models/provider-credential-test-response.ts +@@ -0,0 +1,22 @@ ++/* tslint:disable */ ++/* eslint-disable */ ++/** ++ * Fabro Run API ++ * HTTP API for managing Fabro workflow run executions. ++ * ++ * The version of the OpenAPI document: 0.1.0 ++ * ++ * ++ * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). ++ * https://openapi-generator.tech ++ * Do not edit the class manually. ++ */ ++ ++ ++ ++/** ++ * Successful response from provider credential validation. ++ */ ++export interface ProviderCredentialTestResponse { ++ 'ok': boolean; ++} +diff --git a/lib/packages/fabro-api-client/src/models/stage-projection.ts b/lib/packages/fabro-api-client/src/models/stage-projection.ts +index b49e864ca..6e9f8c446 100644 +--- a/lib/packages/fabro-api-client/src/models/stage-projection.ts ++++ b/lib/packages/fabro-api-client/src/models/stage-projection.ts +@@ -30,6 +30,9 @@ import type { CommandTermination } from './command-termination'; + import type { McpServerProjection } from './mcp-server-projection'; + // May contain unused imports in some cases + // @ts-ignore ++import type { ParallelBranchResult } from './parallel-branch-result'; ++// May contain unused imports in some cases ++// @ts-ignore + import type { PermissionLevel } from './permission-level'; + // May contain unused imports in some cases + // @ts-ignore +@@ -75,9 +78,9 @@ export interface StageProjection { + */ + 'script_timing'?: object | null; + /** +- * Per-branch result objects produced by a parallel stage. ++ * Ordered per-branch results produced by a parallel stage. + */ +- 'parallel_results'?: Array | null; ++ 'parallel_results'?: Array | null; + 'output'?: string | null; + 'output_bytes'?: number | null; + 'live_streaming'?: boolean | null; +diff --git a/test/attractor/reference_template.dot b/test/attractor/reference_template.dot +index 92767f3b3..35c2fac31 100644 +--- a/test/attractor/reference_template.dot ++++ b/test/attractor/reference_template.dot +@@ -106,8 +106,8 @@ digraph reference_template { + // PROMPT: consolidate_dod + // Role: synthesize dod_a/b/c into consensus DoD + // Must address: +- // - Read branch outputs via parallel_results.json + worktree_dir +- // - Fall back to current worktree if parallel_results.json missing ++ // - Review every branch result in parallel.results from prompt context ++ // - Read branch files from the shared checkout + // - Read .ai/spec.md for context + // - Resolve contradictions, apply DoD rubric + coverage checklist + // Writes: .ai/definition_of_done.md +@@ -138,8 +138,8 @@ digraph reference_template { + // PROMPT: debate_consolidate + // Role: synthesize plan_a/b/c into best-of-breed final plan + // Must address: +- // - Read branch outputs via parallel_results.json + worktree_dir +- // - Fall back to current worktree if parallel_results.json missing ++ // - Review every branch result in parallel.results from prompt context ++ // - Read branch files from the shared checkout + // - If .ai/postmortem_latest.md exists, verify plan addresses + // every identified issue + // - Resolve conflicts, ensure dependency order +@@ -269,8 +269,8 @@ digraph reference_template { + // PROMPT: review_consensus + // Role: synthesize reviews into consensus verdict + // Must address: +- // - Read branch outputs via parallel_results.json + worktree_dir +- // - Fall back to current worktree if parallel_results.json missing ++ // - Review every branch result in parallel.results from prompt context ++ // - Read branch files from the shared checkout + // - Read .ai/definition_of_done.md for criteria + // - Consensus: 2+ APPROVED with no critical gaps -> success; + // otherwise -> retry with specific issues +@@ -287,8 +287,8 @@ digraph reference_template { + // Must address: + // - Read .ai/review_consensus.md (if review stage reached) + // - Read .ai/verify_fidelity.md (if semantic verify ran) +- // - Read branch review outputs via parallel_results.json + +- // worktree_dir if available ++ // - Review branch status and context updates in parallel.results ++ // from prompt context if available + // - Read .ai/implementation_log.md + // - Output: root causes, what worked (preserve), what failed + // (fix), concrete next changes +diff --git a/test/docs/examples/clone-substack/clone-substack.fabro b/test/docs/examples/clone-substack/clone-substack.fabro +index 7c06e36a7..f3404383c 100644 +--- a/test/docs/examples/clone-substack/clone-substack.fabro ++++ b/test/docs/examples/clone-substack/clone-substack.fabro +@@ -130,8 +130,8 @@ Write to .workflow/plan_b.md." + label="Debate & Consolidate", + prompt="Synthesize the two implementation plans into a single best-of-breed \ + final plan.\n\n\ +-Read branch outputs via parallel_results.json. If parallel_results.json is missing, \ +-fall back to reading .workflow/plan_a.md and .workflow/plan_b.md.\n\n\ ++Review every branch result in parallel.results from the prompt context, then read \ ++.workflow/plan_a.md and .workflow/plan_b.md from the shared checkout.\n\n\ + If .workflow/postmortem_latest.md exists, read it FIRST. The postmortem contains \ + root-cause analysis and concrete fixes from the previous iteration. The final plan \ + MUST be adjusted to address every issue identified in the postmortem — add new \ +@@ -458,8 +458,8 @@ Write to .workflow/review_b.md." + goal_gate=true, + retry_target="postmortem", + prompt="Synthesize the two reviews into a consensus verdict.\n\n\ +-Read branch outputs via parallel_results.json. If parallel_results.json is \ +-missing, fall back to reading .workflow/review_a.md and .workflow/review_b.md.\n\n\ ++Review every branch result in parallel.results from the prompt context, then read \ ++.workflow/review_a.md and .workflow/review_b.md from the shared checkout.\n\n\ + Read .workflow/definition_of_done.md for acceptance criteria reference.\n\n\ + Consensus rules:\n\ + - Both APPROVED with no critical gaps: the implementation passes\n\ +@@ -490,7 +490,7 @@ Read (if they exist):\n\ + - .workflow/test-evidence/latest/manifest.json\n\ + - Evidence files referenced by manifest entries for failed or suspicious IT \ + scenarios\n\ +-- Branch review outputs via parallel_results.json (if available)\n\n\ ++- Parallel branch review status and context updates from parallel.results (if available)\n\n\ + Output to .workflow/postmortem_latest.md (overwrite previous):\n\ + - Root causes of failure\n\ + - What works and must be preserved\n\ +diff --git a/test/docs/tutorials/ensemble/ensemble.fabro b/test/docs/tutorials/ensemble/ensemble.fabro +index 3a8655cde..87aadf36c 100644 +--- a/test/docs/tutorials/ensemble/ensemble.fabro ++++ b/test/docs/tutorials/ensemble/ensemble.fabro +@@ -14,7 +14,7 @@ digraph Ensemble { + start [shape=Mdiamond, label="Start"] + exit [shape=Msquare, label="Exit"] + +- fork [label="Fan Out", shape=component, join_policy="wait_all"] ++ fork [label="Fan Out", shape=component] + + opus [label="Opus", prompt="Analyze the goal. Provide your independent assessment, recommendations, and any code or prose needed. Be thorough.", shape=tab] + gemini [label="Gemini", prompt="Analyze the goal. Provide your independent assessment, recommendations, and any code or prose needed. Be thorough.", shape=tab] +diff --git a/test/docs/tutorials/parallel-review/parallel.fabro b/test/docs/tutorials/parallel-review/parallel.fabro +index a4cab30e9..c699750bc 100644 +--- a/test/docs/tutorials/parallel-review/parallel.fabro ++++ b/test/docs/tutorials/parallel-review/parallel.fabro +@@ -5,7 +5,7 @@ digraph Parallel { + start [shape=Mdiamond, label="Start"] + exit [shape=Msquare, label="Exit"] + +- fork [label="Fork Analysis", shape=component, join_policy="wait_all"] ++ fork [label="Fork Analysis", shape=component] + + security [label="Security Audit", prompt="Examine the codebase for security concerns: hardcoded secrets, injection risks, unsafe dependencies. List findings as bullet points.", shape=tab, reasoning_effort="low"] + architecture [label="Architecture Review", prompt="Assess the codebase architecture: separation of concerns, dependency structure, modularity. List findings as bullet points.", shape=tab, reasoning_effort="low"] +diff --git a/test/docs/workflows/stages-and-nodes/all-node-types.fabro b/test/docs/workflows/stages-and-nodes/all-node-types.fabro +index 75d4f3fc5..1970c147e 100644 +--- a/test/docs/workflows/stages-and-nodes/all-node-types.fabro ++++ b/test/docs/workflows/stages-and-nodes/all-node-types.fabro +@@ -7,7 +7,7 @@ digraph AllNodeTypes { + test [label="Run Tests", shape=parallelogram, script="cargo test 2>&1 || true"] + gate [shape=diamond, label="Tests passing?"] + cooldown [label="Wait 30s", shape=insulator, duration="30s"] +- fork [label="Fan Out", shape=component, join_policy="wait_all"] ++ fork [label="Fan Out", shape=component] + security [label="Security Review"] + architecture [label="Architecture Review"] + quality [label="Quality Review"] +diff --git a/test/parallel.fabro b/test/parallel.fabro +index 96cd323fa..381a9f2c5 100644 +--- a/test/parallel.fabro ++++ b/test/parallel.fabro +@@ -4,7 +4,7 @@ digraph Parallel { + start [shape=Mdiamond] + exit [shape=Msquare] + +- fork [label="Fork Work", shape=component, join_policy="wait_all"] ++ fork [label="Fork Work", shape=component] + branch1 [label="Branch 1"] + branch2 [label="Branch 2"] + merge [label="Merge Results", shape=tripleoctagon] diff --git a/stages/006-simplify_fable@1/response.md b/stages/006-simplify_fable@1/response.md new file mode 100644 index 000000000..aecdaa9a4 --- /dev/null +++ b/stages/006-simplify_fable@1/response.md @@ -0,0 +1,32 @@ +Review complete. Three parallel review agents (reuse, quality, efficiency) analyzed the shared-checkout parallel execution diff, and I fixed the verified findings. + +## Fixed + +**Correctness (high)** +- **Old stored events failed to deserialize**: `ParallelBranchResult.context_updates` was a required serde field, so pre-change `parallel.completed` events (which lacked it) would hard-fail event replay. Added `#[serde(default)]`. + +**Stringly-typed status → `StageOutcome` (medium)** +- `ParallelBranchResult.status`, `ParallelBranchCompletedProps.status`, the workflow `Event` variant, and the CLI `ProgressEvent` all carried `String` where the existing `StageOutcome` enum (same serde strings, existing OpenAPI schema + build.rs replacement) applied. Typed them end-to-end: OpenAPI now `$ref`s `StageOutcome`, regenerated the TS client, and `run_state.rs` dropped its parse-back `from_str`. This also eliminated the mixed `result.status == "failed"` / `failure_category()` predicate in the reap loop. + +**Payload lifecycle efficiency (high/medium)** +- **Offload before emit**: `parallel.completed` previously carried raw (un-offloaded) branch results into the event log, projection, and per-event projection-cache clones. The handler now runs the new `artifact::offload_parallel_branch_updates` on the typed results before emitting/returning them. +- **Removed the double-apply**: dropped the handler's direct `context.apply_updates(...)` (updates flow only through `outcome.context_updates`, like every other handler), which also kept raw results out of `StageCompleted.context_values`. +- **Reduced per-branch copies**: parent snapshot taken once and shared via `Arc` (was 3 full context clones per branch), `graph` cloned once into an `Arc`, results moved instead of cloned into the event, removed the no-op `sort_by_key`, and `materialize_blob_ref` now checks file existence before reading the blob (content-addressed, so safe). Added a cheap size fast-path to `offload_value` for short strings. + +**Code reuse / structure** +- `EngineServices` now derives `Clone` — deleted the field-by-field reconstruction in the branch task. +- Extracted shared `context_diff` into `context.rs` (was duplicated in `manager_loop` and reimplemented in `parallel.rs`), taking `after` by value to move entries. +- Added `WorkflowContext::parsed_run_id()` replacing identical 4-line parse blocks in `parallel.rs` and `agent.rs`. +- Replaced the 5-tuple `handles` collection with a named `BranchDispatch` struct; unified simulated/normal notes with a prefix variable. +- Unified `FanInHandler::simulate`/`execute` copy-paste twins into `run_join(..., simulated)`; fan-in now validates result shape via a slim deserialize instead of cloning the full hydrated payload it never used. +- Fixed the stale `stage_scope.rs` doc referencing removed worktree/`GitCommit` behavior and renamed the change-narrating `..._omits_git_state_...` test. + +**Web** +- Deleted dead `extractStageNotes` and `asUnknownRecord` (production callers removed by this change) plus their tests. +- `parallel-children.tsx` now reuses shared `stageStatusTone`/`stageStatusLabel` from `stage-sidebar` (dropping its parallel tone/label maps with the impossible `cancelled` key) via a local `BranchRow` view type; `parseParallelOverview` validates statuses against the typed `StageOutcome` enum. +- New tests use the existing `setupReactTestEnv` (with teardown) and a new shared `makeEventEnvelope` fixture builder in `test-utils`. + +## Validated +`cargo build -p fabro-api` + API round-trip tests, TS client regen + typecheck, `cargo nextest run --workspace` (7,007 passed), `fmt --check`, `clippy -D warnings`, web `bun test` (679 passed) + typecheck + build, and `cargo dev docs check` — all clean. + +Noted but intentionally skipped: per-run blob memoization across resolution passes and concurrent blob resolution (worthwhile but a larger cross-cutting change beyond this cleanup's scope), and the negative string-absence assertions in new tests (harmless short-lived regression guards). \ No newline at end of file diff --git a/stages/006-simplify_fable@1/status.json b/stages/006-simplify_fable@1/status.json new file mode 100644 index 000000000..2e70d26c2 --- /dev/null +++ b/stages/006-simplify_fable@1/status.json @@ -0,0 +1,6 @@ +{ + "outcome": "succeeded", + "notes": "Stage completed: simplify_fable", + "failure_reason": null, + "timestamp": "2026-07-24T06:45:17.450080596Z" +} \ No newline at end of file diff --git a/stages/007-simplify_sol@1/prompt.md b/stages/007-simplify_sol@1/prompt.md new file mode 100644 index 000000000..b37702b55 --- /dev/null +++ b/stages/007-simplify_sol@1/prompt.md @@ -0,0 +1,188 @@ +Goal: +# Shared-Checkout Parallel Execution + +## Summary + +Simplify parallel execution so every branch: + +- Receives an isolated context fork. +- Executes concurrently against the same sandbox and Git checkout. +- Creates no Git branch, worktree, checkpoint, or branch commit. +- Always waits for every branch; remove `first_success` and `join_policy`. +- Produces a collision-free result containing its context updates. + +Fan-in remains an explicit join node. Without a prompt it is a no-op barrier; with a prompt it behaves like a standard prompt stage that reduces the aggregated results without selecting a branch. + +## Implementation Changes + +### Parallel execution + +- Remove all parallel-specific Git setup, checkpointing, committing, cleanup, and fast-forwarding. +- Give each branch the same `Arc` and inherited `internal.work_dir`. +- Retain `max_parallel`, branch-scoped contexts, IDs, hooks, and lifecycle events. +- Preserve outgoing-edge order in the final results regardless of completion order. +- Define the parallel outcome as: + - `succeeded` when every branch succeeds. + - `failed` when every branch fails. + - `partially_succeeded` for every mixed or partial result. +- Preserve branch ID/index when handler or task execution fails. +- Keep the current single-node-per-branch topology. + +### Results and fan-in + +Introduce a shared typed result: + +```rust +ParallelBranchResult { + id: String, + status: String, + context_updates: BTreeMap, +} +``` + +- Store the ordered array in `parallel.results` and keep `parallel.branch_count`. +- Include updates from successful and failed branches; never merge them into the parent context at top level. +- Offload large leaf values using existing artifact storage while keeping `parallel.results` itself a structured array. +- Resolve nested textual `response.*` and `command.output` blob references when building downstream prompt context. +- Emit the same typed results through `parallel.completed` and project them into `StageProjection.parallel_results`. +- A promptless fan-in verifies results exist and succeeds with a joined-branches note. +- A prompted fan-in uses standard prompt execution and records `response.`, `last_response`, usage, and prompt events. It performs no ranking or selection. + +Remove: + +- Heuristic and LLM branch-selection code. +- `parallel.fan_in.best_id`, `best_outcome`, and `best_head_sha`. +- Per-result `head_sha` and `score`. +- The special fan-in model-usage mode. +- Fan-in selected-branch notes and UI. + +### Delete obsolete Git/worktree infrastructure + +- Delete the parallel-only `WorktreeSandbox`, its options/callbacks, exports, tests, and sandbox trait path helpers. +- Remove `EngineServices`’ parallel-only `GitState` and pipeline wiring. +- Remove now-unused branch/worktree/fast-forward Git helpers and parallel ref sanitization. +- Remove the parallel-base-checkpoint notice code. +- Remove the now-unemitted `git.branch`, `git.worktree.added`, and `git.worktree.removed` event variants and property types. +- Leave the server’s independent Git checkout/worktree implementation unchanged. +- Continue normal run-level checkpointing after the parallel node; any shared workspace changes are captured together. + +## Public Interfaces and Documentation + +- Remove `join_policy` from DOT documentation, examples, fixtures, events, and UI. +- Add a validation error directing users to remove any `join_policy` attribute. +- Update `parallel.started` to contain only visit and branch count. +- Update `parallel.branch.completed` to remove `head_sha`. +- Define `ParallelBranchResult` in OpenAPI and change `StageProjection.parallel_results` from untyped objects to that typed array; regenerate Rust and TypeScript clients. +- Update the parallel UI to show branch status and links without commit SHAs or a join-policy badge. +- Replace the fan-in trophy/selection UI with joined-state information and the optional standard reducer transcript. +- Rewrite the active parallel strategy and public docs around shared-checkout concurrency: + - Read-only behavior is best effort. + - Concurrent writes are allowed but entirely user-managed. + - Fabro performs no write enforcement, detection, or warnings. + - Results are available through `parallel.results`, not a workspace `parallel_results.json`. + - A fan-in prompt synthesizes results but never selects workspace state. +- Remove `join_policy` from all checked-in tutorial/demo workflows and generated documentation fixtures. + +## Test Plan + +- Unit-test that every branch receives the same sandbox working directory while retaining independent contexts. +- Verify deterministic result ordering and complete per-branch context updates, including failed branches and structured/command outputs. +- Cover all-success, mixed, partial, all-failed, zero-branch, `max_parallel`, dry-run, and run-cancellation behavior. +- In a temporary Git repository, run parallel branches that write distinct files and assert: + - Both files remain in the shared checkout. + - No `fabro/run/parallel/*` refs exist. + - No parallel worktrees, branch commits, worktree events, or fast-forward commands occur. +- Test promptless fan-in as a no-op join and prompted fan-in as a standard reducer that sees every branch result and emits a normal response. +- Update event serialization, store projection, API round-trip, web parser, and renderer tests for the new typed payloads and removed fields. +- Remove the obsolete host and Daytona parallel-Git-selection tests; retain provider-independent shared-sandbox coverage. +- Verify with: + - `cargo build -p fabro-api` + - `cd lib/packages/fabro-api-client && bun run generate && bun run typecheck` + - `cargo build --workspace` + - `ulimit -n 4096 && cargo nextest run --workspace` + - `cargo +nightly-2026-04-14 fmt --check --all` + - `cargo +nightly-2026-04-14 clippy --workspace --all-targets -- -D warnings` + - `cd apps/fabro-web && bun test && bun run typecheck && bun run build` + - `cargo --locked dev docs check` + +## Assumptions and Unresolved Questions + +- The application is greenfield; removed fields, events, and attributes receive no compatibility layer. +- Parallel branches remain single-node executions. +- `max_parallel` remains supported. +- Shared-workspace write races are explicitly the user’s responsibility. +- No Git workspace warning or read-only enforcement will be added. +- Unresolved questions: none. + + + +## 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.96.0 (30a34c682 2026-05-25) + ``` +- **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, 45.3k tokens in / 135.5k out + - Files: /home/daytona/workspace/fabro/apps/fabro-web/app/components/stage-renderers/fan-in-results.test.tsx, /home/daytona/workspace/fabro/apps/fabro-web/app/components/stage-renderers/fan-in-results.tsx, /home/daytona/workspace/fabro/apps/fabro-web/app/components/stage-renderers/helpers.test.ts, /home/daytona/workspace/fabro/apps/fabro-web/app/components/stage-renderers/helpers.ts, /home/daytona/workspace/fabro/apps/fabro-web/app/components/stage-renderers/parallel-children.test.tsx, /home/daytona/workspace/fabro/apps/fabro-web/app/components/stage-renderers/parallel-children.tsx, /home/daytona/workspace/fabro/apps/fabro-web/app/routes/run-stages.tsx, /home/daytona/workspace/fabro/docs/internal/events.md, /home/daytona/workspace/fabro/docs/internal/parallel-strategy.md, /home/daytona/workspace/fabro/docs/public/api-reference/fabro-api.yaml, /home/daytona/workspace/fabro/docs/public/examples/clone-substack.mdx, /home/daytona/workspace/fabro/docs/public/execution/context.mdx, /home/daytona/workspace/fabro/docs/public/execution/outcomes.mdx, /home/daytona/workspace/fabro/docs/public/reference/dot-language.mdx, /home/daytona/workspace/fabro/docs/public/tutorials/ensemble.mdx, /home/daytona/workspace/fabro/docs/public/tutorials/parallel-review.mdx, /home/daytona/workspace/fabro/docs/public/workflows/stages-and-nodes.mdx, /home/daytona/workspace/fabro/lib/crates/fabro-agent/src/lib.rs, /home/daytona/workspace/fabro/lib/crates/fabro-agent/src/sandbox.rs, /home/daytona/workspace/fabro/lib/crates/fabro-api/build.rs, /home/daytona/workspace/fabro/lib/crates/fabro-api/src/lib.rs, /home/daytona/workspace/fabro/lib/crates/fabro-api/tests/run_event_round_trip.rs, /home/daytona/workspace/fabro/lib/crates/fabro-api/tests/stage_projection_round_trip.rs, /home/daytona/workspace/fabro/lib/crates/fabro-cli/src/commands/run/run_progress/mod.rs, /home/daytona/workspace/fabro/lib/crates/fabro-cli/tests/it/workflow/dry_run_examples.rs, /home/daytona/workspace/fabro/lib/crates/fabro-dump/src/lib.rs, /home/daytona/workspace/fabro/lib/crates/fabro-sandbox/src/daytona/mod.rs, /home/daytona/workspace/fabro/lib/crates/fabro-sandbox/src/docker.rs, /home/daytona/workspace/fabro/lib/crates/fabro-sandbox/src/lib.rs, /home/daytona/workspace/fabro/lib/crates/fabro-sandbox/src/sandbox.rs, /home/daytona/workspace/fabro/lib/crates/fabro-store/src/run_state.rs, /home/daytona/workspace/fabro/lib/crates/fabro-store/tests/serializable_projection.rs, /home/daytona/workspace/fabro/lib/crates/fabro-types/src/lib.rs, /home/daytona/workspace/fabro/lib/crates/fabro-types/src/parallel.rs, /home/daytona/workspace/fabro/lib/crates/fabro-types/src/run_event/infra.rs, /home/daytona/workspace/fabro/lib/crates/fabro-types/src/run_event/misc.rs, /home/daytona/workspace/fabro/lib/crates/fabro-types/src/run_event/mod.rs, /home/daytona/workspace/fabro/lib/crates/fabro-types/src/run_projection.rs, /home/daytona/workspace/fabro/lib/crates/fabro-validate/src/lib.rs, /home/daytona/workspace/fabro/lib/crates/fabro-validate/src/rules/inert_attribute.rs, /home/daytona/workspace/fabro/lib/crates/fabro-validate/src/rules/join_policy_removed.rs, /home/daytona/workspace/fabro/lib/crates/fabro-validate/src/rules/mod.rs, /home/daytona/workspace/fabro/lib/crates/fabro-workflow/README.md, /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/event/convert.rs, /home/daytona/workspace/fabro/lib/crates/fabro-workflow/src/event/emitter.rs, /home/daytona/workspace/fabro/lib/crates/fabro-workflow/src/event/events.rs, /home/daytona/workspace/fabro/lib/crates/fabro-workflow/src/event/names.rs, /home/daytona/workspace/fabro/lib/crates/fabro-workflow/src/git.rs, /home/daytona/workspace/fabro/lib/crates/fabro-workflow/src/handler/fan_in.rs, /home/daytona/workspace/fabro/lib/crates/fabro-workflow/src/handler/manager_loop.rs, /home/daytona/workspace/fabro/lib/crates/fabro-workflow/src/handler/parallel.rs, /home/daytona/workspace/fabro/lib/crates/fabro-workflow/src/pipeline/execute.rs, /home/daytona/workspace/fabro/lib/crates/fabro-workflow/src/pipeline/initialize.rs, /home/daytona/workspace/fabro/lib/crates/fabro-workflow/src/sandbox_git.rs, /home/daytona/workspace/fabro/lib/crates/fabro-workflow/src/services.rs, /home/daytona/workspace/fabro/lib/crates/fabro-workflow/src/test_support.rs, /home/daytona/workspace/fabro/lib/crates/fabro-workflow/tests/it/daytona_integration.rs, /home/daytona/workspace/fabro/lib/crates/fabro-workflow/tests/it/git_integration.rs, /home/daytona/workspace/fabro/lib/crates/fabro-workflow/tests/it/integration.rs, /home/daytona/workspace/fabro/test/attractor/reference_template.dot, /home/daytona/workspace/fabro/test/docs/examples/clone-substack/clone-substack.fabro +- **simplify_fable**: succeeded + - Model: anthropic/claude-fable-5, 166.8k tokens in / 461.6k out + - Files: /home/daytona/workspace/fabro/apps/fabro-web/app/components/stage-renderers/fan-in-results.test.tsx, /home/daytona/workspace/fabro/apps/fabro-web/app/components/stage-renderers/helpers.test.ts, /home/daytona/workspace/fabro/apps/fabro-web/app/components/stage-renderers/helpers.ts, /home/daytona/workspace/fabro/apps/fabro-web/app/components/stage-renderers/parallel-children.test.tsx, /home/daytona/workspace/fabro/apps/fabro-web/app/components/stage-renderers/parallel-children.tsx, /home/daytona/workspace/fabro/apps/fabro-web/app/lib/test-utils.tsx, /home/daytona/workspace/fabro/docs/public/api-reference/fabro-api.yaml, /home/daytona/workspace/fabro/lib/crates/fabro-cli/src/commands/run/run_progress/event.rs, /home/daytona/workspace/fabro/lib/crates/fabro-cli/src/commands/run/run_progress/mod.rs, /home/daytona/workspace/fabro/lib/crates/fabro-cli/src/commands/run/run_progress/stage_display.rs, /home/daytona/workspace/fabro/lib/crates/fabro-store/src/run_state.rs, /home/daytona/workspace/fabro/lib/crates/fabro-types/src/parallel.rs, /home/daytona/workspace/fabro/lib/crates/fabro-types/src/run_event/misc.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/event/convert.rs, /home/daytona/workspace/fabro/lib/crates/fabro-workflow/src/event/events.rs, /home/daytona/workspace/fabro/lib/crates/fabro-workflow/src/git.rs, /home/daytona/workspace/fabro/lib/crates/fabro-workflow/src/handler/agent.rs, /home/daytona/workspace/fabro/lib/crates/fabro-workflow/src/handler/fan_in.rs, /home/daytona/workspace/fabro/lib/crates/fabro-workflow/src/handler/manager_loop.rs, /home/daytona/workspace/fabro/lib/crates/fabro-workflow/src/handler/parallel.rs, /home/daytona/workspace/fabro/lib/crates/fabro-workflow/src/services.rs, /home/daytona/workspace/fabro/lib/crates/fabro-workflow/src/stage_scope.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/007-simplify_sol@1/provider_used.json b/stages/007-simplify_sol@1/provider_used.json new file mode 100644 index 000000000..d1797a7bf --- /dev/null +++ b/stages/007-simplify_sol@1/provider_used.json @@ -0,0 +1,6 @@ +{ + "mode": "agent", + "provider": "openrouter", + "model": "openai/gpt-5.6-sol", + "reasoning_effort": "max" +} \ No newline at end of file