Skip to content

Add runtime for_each item injection - #653

Open
brynary wants to merge 6 commits into
mainfrom
feat/for-each-item-injection
Open

Add runtime for_each item injection#653
brynary wants to merge 6 commits into
mainfrom
feat/for-each-item-injection

Conversation

@brynary

@brynary brynary commented Jul 27, 2026

Copy link
Copy Markdown
Member

Summary

  • add a validated for_each contract for runtime fan-out from inline or managed artifact-backed JSON arrays
  • inject each item into a cloned agent or prompt target as fenced engine-owned data
  • preserve ordered (target id, index) identities while applying bounded concurrency, retries, timeouts, cancellation, and empty/partial failure semantics
  • expose item labels through events, projections, the API client, CLI progress, and the web timeline
  • document the workflow contract, trust boundary, observability, and security-review-shaped usage

Verification

  • cargo +nightly-2026-04-14 fmt --check --all
  • cargo +nightly-2026-04-14 clippy -p fabro-types -p fabro-validate -p fabro-workflow -p fabro-api -p fabro-cli --all-targets -- -D warnings
  • ulimit -n 4096 && cargo nextest run -p fabro-types -p fabro-validate -p fabro-workflow -p fabro-store -p fabro-dump -p fabro-cli — 3,015 passed, 66 skipped
  • cargo nextest run -p fabro-api — 202 passed
  • twin-backed structured-array → for_each agents → fan-in scenario — passed
  • web feature tests — 16 passed
  • fabro-web and API client typechecks — passed

Test-suite note

The repository-wide bun test run reports 731 passed and 13 failed in unrelated stale-build and Run Files tests. Those failing files pass when run in isolation, indicating existing shared-global/order sensitivity in the parallel suite; the changed feature tests pass both directly and within the full run.

Copilot AI review requested due to automatic review settings July 27, 2026 15:56

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR adds first-class runtime for_each fan-out support to the parallel stage: a parallel node can now take a flat context key that resolves to a JSON array (inline or managed artifact), dispatch one agent/prompt execution per item with bounded concurrency and retries/timeouts, and preserve stable (target_id, index) identities plus human-friendly item_label across events, projections, API client, CLI progress, and the web timeline.

Changes:

  • Implement for_each planning + item→prompt injection (engine-owned fenced data) and ordered result identity (index, item_label) in the parallel handler.
  • Refactor node execution to share a single-attempt envelope between the core executor and parallel-branch runner; add detached stage-execution reservations for concurrent branch dispatch.
  • Extend types, OpenAPI + TS client/web parsing, CLI rendering, validation rule(s), and docs to reflect the new contract and observability/security implications.

Reviewed changes

Copilot reviewed 32 out of 32 changed files in this pull request and generated 1 comment.

Show a summary per file
File Description
lib/packages/fabro-api-client/src/models/parallel-branch-result.ts Adds index and item_label fields to the TS API client model for parallel results.
lib/foundation/fabro-types/src/run_event/misc.rs Extends parallel branch started/completed event props with optional item_label.
lib/foundation/fabro-types/src/parallel.rs Extends ParallelBranchResult with optional index/item_label plus round-trip/legacy tests.
lib/foundation/fabro-types/src/graph.rs Adds Node::for_each() accessor and tests for the new graph attribute.
lib/components/fabro-workflow/tests/it/integration.rs Adds an E2E twin scenario covering structured-array → for_each fan-out → fan-in semantics and trust-boundary prompt text.
lib/components/fabro-workflow/src/stage_execution.rs Introduces reserve_detached for concurrent branch executions without taking active-scope/resume provenance.
lib/components/fabro-workflow/src/node_handler.rs Extracts shared execute_single_attempt + finalize_retries_exhausted for reuse by branch execution paths.
lib/components/fabro-workflow/src/handler/parallel.rs Implements for_each contract: source resolution, item labeling, prompt injection, per-item retries/timeouts/cancellation, join selection, and new events/results metadata.
lib/components/fabro-workflow/src/git.rs Updates tests/fixtures to include new ParallelBranchResult fields.
lib/components/fabro-workflow/src/event/names.rs Updates event name tests for new optional item_label field.
lib/components/fabro-workflow/src/event/events.rs Adds optional item_label to parallel branch started/completed event variants.
lib/components/fabro-workflow/src/event/convert.rs Converts new parallel-branch item_label fields into stored event bodies and updates tests.
lib/components/fabro-workflow/src/artifact.rs Adds resolve_json_value to hydrate structured JSON from blob/managed-file refs; includes tests.
lib/components/fabro-validate/src/rules/mod.rs Registers the new for_each_contract lint rule.
lib/components/fabro-validate/src/rules/for_each_contract.rs Adds validation enforcing the for_each structural contract (parallel-only, single template edge, agent/prompt target, no nesting).
lib/components/fabro-store/tests/serializable_projection.rs Updates projection round-trip tests for new parallel result identity fields.
lib/components/fabro-store/src/run_state.rs Updates reducer tests to account for new optional item_label on parallel branch events.
lib/components/fabro-dump/src/lib.rs Updates dump tests for new parallel result fields.
lib/apps/fabro-cli/src/commands/run/run_progress/mod.rs Updates CLI progress UI tests to display labeled for_each branches distinctly.
lib/apps/fabro-cli/src/commands/run/run_progress/event.rs Renders parallel branch display names using item_label + (node #index) formatting.
docs/public/workflows/stages-and-nodes.mdx Documents the for_each attribute and its behavioral contract on parallel nodes.
docs/public/tutorials/parallel-review.mdx Adds a runtime-candidates tutorial section showing for_each usage and security caveats.
docs/public/reference/dot-language.mdx Extends DOT reference for for_each semantics, trust boundary, ordering, and identity fields.
docs/public/execution/outcomes.mdx Clarifies parallel outcomes, including “empty for_each array succeeds” behavior.
docs/public/execution/observability.mdx Documents index/item_label in parallel branch events and notes source-bearing prompt retention.
docs/public/execution/context.mdx Updates context docs for parallel.results shape and adds for_each runtime-array section.
docs/public/api-reference/fabro-api.yaml Extends OpenAPI schema for ParallelBranchResult with index and item_label.
docs/internal/parallel-strategy.md Updates internal strategy docs for for_each execution model, identity, retries, and observability semantics.
apps/fabro-web/app/components/stage-renderers/parallel-children.tsx Displays itemLabel for branch rows and avoids ambiguous links when multiple results share a template target id.
apps/fabro-web/app/components/stage-renderers/parallel-children.test.tsx Adds UI test ensuring item labels render and that ambiguous id-only links are suppressed.
apps/fabro-web/app/components/stage-renderers/helpers.ts Parses index and item_label from parallel.completed results into web view models.
apps/fabro-web/app/components/stage-renderers/helpers.test.ts Adds parsing test coverage for dynamic item identity fields.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread lib/components/fabro-workflow/src/handler/parallel.rs
Copilot AI review requested due to automatic review settings July 27, 2026 16:13

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 32 out of 32 changed files in this pull request and generated 2 comments.

Comment thread lib/components/fabro-workflow/src/handler/parallel.rs
Comment thread lib/apps/fabro-cli/src/commands/run/run_progress/event.rs
Simplification pass over the for_each branch. No behavior change.

Share what was duplicated:
- Node::prompt_or_label replaces the "prompt, else label" fallback that
  agent, prompt, and the for_each item injector each wrote out.
- context::lookup_flat replaces the "exact key, then strip context."
  lookup that condition.rs had twice and the for_each source had again.
- is_llm_handler_type replaces the inline agent/prompt match, so the
  runtime and the for_each_contract rule agree by construction.
- find_join_node now takes branch ids, so a for_each fan-out passes its
  template target instead of needing find_join_for_target.
- collect_events moves to test_support; parallel and integration tests
  shared one copy already.
- One ScriptedHandler replaces four test handlers that differed only in
  what they returned.

Straighten the branch retry loop:
- Reserve the branch scope once before the loop instead of guarding it
  with an Option, which removes three expect() calls.
- acquire_branch_permit and backoff_or_cancel replace the cancel-aware
  select! blocks the loop repeated verbatim.
- Keep the match arms in Executor::execute_with_retry order so the two
  loops stay easy to compare.

Drop redundant state:
- BranchPlan::is_for_each derives from template_target_id.
- for_each_contract checks node type before source shape, so one
  mistake reports one diagnostic.

Prove the new wire fields survive the OpenAPI boundary: the fabro-api
round-trip fixture now carries index and item_label.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings July 28, 2026 22:42
@brynary

brynary commented Jul 28, 2026

Copy link
Copy Markdown
Member Author

Ran a simplification pass over this branch (commit ee9189d). No behavior change; cargo nextest run --workspace is green (7428 tests), plus fmt and clippy.

Reused what already existed

  • Node::prompt_or_label() — the "prompt, else label" fallback was written out in agent.rs, prompt.rs, and again in the item injector.
  • context::lookup_flat() — the "exact key, then strip context." lookup existed twice in condition.rs; the for_each source added a third copy.
  • is_llm_handler_type() — replaces the inline Some("agent" | "prompt") match, so the runtime and the new for_each_contract rule can no longer drift.
  • find_join_node now takes branch ids, so a for_each fan-out passes its template target. find_join_for_target is gone.
  • collect_events moved to test_support; parallel.rs and integration.rs each had a copy.
  • One ScriptedHandler replaces CountingHandler, AlwaysRetryHandler, SlowHandler, and CancellingHandler, which differed only in their return.

Straightened the branch retry loop

  • The branch scope is reserved once before the loop instead of being guarded by an Option, which removes three expect() calls.
  • acquire_branch_permit and backoff_or_cancel replace the cancel-aware select! blocks the loop repeated verbatim.
  • Match arms keep Executor::execute_with_retry order so the two loops stay easy to compare.

Dropped redundant state

  • BranchPlan::is_for_each now derives from template_target_id.
  • for_each_contract checks node type before source shape, so one mistake reports one diagnostic.

Added coverage

  • The fabro-api round-trip fixture now carries index and item_label, so the new wire fields are actually proven across the OpenAPI boundary. They were both skip_serializing_if, so the test passed without exercising them.

Left alone, for you to decide

  1. Branch retries emit only StageRetrying. An ordinary stage emits StageFailed { will_retry: true } first (lifecycle/event.rs:290), so a retried branch reads differently to the CLI and web. parallel-strategy.md says lifecycle callbacks are excluded on purpose, so I did not change the event shape — but the missing failure event is a real observability gap if it was not intended.
  2. ParallelBranchResult::index is Option<usize> only to read pre-PR records. Making it required would remove the ?? i fallback in the web, the docs caveats, and the legacy test — but it changes how stored projections deserialize, so it is your call, not a cleanup.
  3. reserve_detached still repeats two lines of reserve_locked. Extracting them costs more indirection than it saves; the divergence is the point.

net -53 lines. The 13 failing fabro-web tests (chunk loading, RunFiles) fail identically on this branch before my commit.

@brynary
brynary marked this pull request as ready for review July 28, 2026 22:42

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 38 out of 38 changed files in this pull request and generated no new comments.

Comments suppressed due to low confidence (1)

lib/components/fabro-workflow/src/stage_execution.rs:173

  • reserve_detached increments the per-node high-water mark but leaves any pending resumed_from provenance untouched. If a run is resumed and this node later has a normal reserve/ensure, that later (potentially unrelated) execution will incorrectly consume the stale provenance link. A detached reservation should still clear (and ideally return) the pending provenance while keeping the execution non-active.

build_branch_plan read the runtime array before run_branches looked at
`simulated`, so every for_each workflow failed under --dry-run with
"for_each source '...' was not found in workflow context". Nothing had
populated the key yet: upstream LLM nodes take Handler::simulate, which
returns no context updates.

A dry run now stands in one placeholder item when the source is absent or
unusable, and simulates the template target once. Graph-shape mistakes
still fail, since catching those is the point of a dry run.

Also from review:
- ITEM_FENCE_PREFIX replaces the bare "untrusted-" literal that
  render_item_data and its test each spelled out.
- ItemRecordingHandler no longer guesses an item label by substring
  search. Nothing asserted it, and the third item's label "2" matched
  stray hex from the random fence tag about two thirds of the time.
- The twin test reads keys::PARALLEL_RESULTS instead of the raw string.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings July 28, 2026 22:49
@brynary

brynary commented Jul 28, 2026

Copy link
Copy Markdown
Member Author

Follow-up. Two more review passes landed after my first comment; both surfaced things the reuse pass missed. Fixed one real bug (commit 4efaf5c), and below is what I verified but did not change.

Fixed: --dry-run failed on every for_each workflow

build_branch_plan read the runtime array before run_branches looked at simulated, so the node failed with for_each source 'context.candidates' was not found in workflow context. Nothing had populated the key: upstream LLM nodes take Handler::simulate, which returns no context updates. I confirmed this with a test against simulate() before fixing it.

A dry run now stands in one placeholder item when the source is absent or unusable, and simulates the template target once. Graph-shape mistakes (non-string source, wrong edge count, non-LLM target, nested for_each) still fail — catching those is the point of a dry run. Three tests cover it, and parallel-strategy.md documents it.

Also folded in: ITEM_FENCE_PREFIX replaces the bare "untrusted-" literal; the twin test uses keys::PARALLEL_RESULTS; and ItemRecordingHandler no longer guesses labels by substring search — nothing asserted the result, and the third item's label "2" matched stray hex from the random fence tag roughly two thirds of the time.

Verified, not fixed — your call

1. Every branch prompt carries the whole source array (O(N²) tokens). The branch context is a fork of the parent snapshot, which still holds the source key. A flat key like candidates is not filtered by is_context_key_excluded (only internal./current./graph./thread./response. prefixes), is not in stage_rendered_keys, and format_value does not truncate — it emits val.to_string() in full. Default fidelity is Compact, which calls append_filtered_context. So each of N calls gets the entire array plus its own fenced item. 50 items totalling 60 KB is roughly 3 MB of redundant prompt per fan-out. It self-limits only above the 100 KB offload threshold, so the worst case is an array just under it. Fix: drop or blob-replace the source key in the branch fork. I verified the mechanism by reading each link; I did not measure a live run.

2. resumed_from_stage_id is now always None. main passed execution.resumed_from.clone(); the switch to reserve_detached hardcodes None at parallel.rs:455. run_state.rs:610 still writes the field onto the branch stage projection, and ParallelBranchStartedProps's doc comment still describes provenance that no longer happens. Either restore it for the static case or delete the field from the event, props, OpenAPI schema, and store. I left resume semantics alone deliberately.

3. All N branches are forked and spawned before any runs. max_parallel throttles execution, not memory: the dispatch loop deep-clones the parent snapshot per branch and spawns each task before the semaphore is acquired. There is also no cap on N, so an agent-produced array decides the task count.

4. Each branch attempt re-resolves context the parent already resolved. Routing branches through execute_single_attempt is right — it closes a real gap where branches saw unhydrated blob refs — but attempt 1's resolve_context_for_execution is redundant by construction and now paid N times, walking all N array items each time.

5. CLI progress adds one never-evicted bar per item. on_tool_call_started caps at MAX_TOOL_CALLS = 5 with eviction; on_parallel_branch_started has no equivalent, and completion matching is a linear scan by display string. Relatedly, parallel_branch_display output is used as both display_name and tool_call_id; format!("{node_id}#{index}") would be a stable key and would also fix the pre-existing collision when two static edges target the same node.

6. Smaller items. offload_parallel_branch_updates awaits blob writes one at a time across all results. The for_each resolve error interpolates {err}, dropping the chain that FailureDetail::causes exists for. Event::StageRetrying.index gets the branch ordinal from this emitter and the run-wide stage index from every other one. In the web, BranchRow.index is only read in a React key where it always equals i, and resultCountByNode answers a question itemLabel != null already answers.

I did not touch items 1–6: each changes behavior, wire shape, or performance characteristics rather than simplifying existing code.

Full suite green at 4efaf5c: 7431 tests, plus fmt and clippy.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 38 out of 38 changed files in this pull request and generated no new comments.

Comments suppressed due to low confidence (1)

lib/components/fabro-workflow/src/handler/parallel.rs:486

  • In the branch retry loop, CoreError::Cancelled is returned as Err(Error::Cancelled), which skips emit_branch_completed(...) inside the task. The join-loop fallback then emits parallel.branch.completed with duration_ms: 0 (and only when a scope exists), producing misleading timing and leaving completion emission to an outer path.

Consider treating CoreError::Cancelled as a terminal per-branch failure outcome (so the branch task can emit parallel.branch.completed with the real elapsed time) while still allowing the group to end as cancelled via FailureCategory::Canceled.

                                break node_handler::finalize_retries_exhausted(&target, outcome);
                            }
                            Ok(outcome) => break outcome,
                            Err(CoreError::Cancelled) => return Err(Error::Cancelled),
                            Err(err) if can_retry && err.is_retryable() => {}
                            Err(err @ CoreError::Handler { .. }) => break err.to_fail_outcome(),

Addresses three Copilot review comments on #653.

`item_label` comes from a model or a workflow author, and it reaches the
terminal through the CLI progress display. A label could carry ANSI
escapes, newlines, or bidi overrides and rewrite what the operator sees.
It could also be whitespace-only, giving a branch a blank identity.

Add `text::sanitize_display_label`: strip ANSI sequences, drop control
and bidi-reordering characters, trim, and elide past 80 characters.
Return an empty string when nothing printable survives so callers fall
back to an identity they control.

Apply it where the label is created, so events, the store, and the web
UI all get a clean value instead of each consumer having to remember.
`parallel_branch_display` sanitizes again, because a run recorded before
this commit still has raw labels in its event log.

`emit_branch_retrying` now sets `stage.retrying`'s `index` from the
branch stage's execution ordinal, matching the envelope `stage_id` and
the meaning every other emitter gives that field. The branch's position
in the fan-out is already on `parallel.branch.started`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings July 28, 2026 23:53

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 39 out of 39 changed files in this pull request and generated no new comments.

…jection

# Conflicts:
#	apps/fabro-web/app/components/stage-renderers/parallel-children.tsx
Copilot AI review requested due to automatic review settings July 29, 2026 00:03
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants