feat: add structured foreach back-edge execution

foreach back-edge execution and control-region validation
This commit is contained in:
lda
2026-09-04 19:59:37 +07:00 Verified
39 changed files with 3601 additions and 705 deletions
+6 -7
View File
@@ -795,12 +795,6 @@ stable.
- Native subgraph polish: optional per-use-site child deployment overrides and - Native subgraph polish: optional per-use-site child deployment overrides and
clearer child trace inspection. clearer child trace inspection.
- Active concurrent foreach correction: replace item-body `END` routes with
canonical back-edges to the owning foreach. The approved semantics are in the
[`foreach back-edge design`](superpowers/specs/2026-09-04-foreach-back-edge-design.md).
This slice also makes foreach control regions fail-closed, rejects unreachable
workflow nodes, and gives repeated visits to one foreach node distinct
persisted activation identities.
- Proposed follow-up: replace innermost-only foreach values with same-scope - Proposed follow-up: replace innermost-only foreach values with same-scope
[`structured runtime context`](superpowers/specs/2026-09-04-structured-runtime-context-design.md), [`structured runtime context`](superpowers/specs/2026-09-04-structured-runtime-context-design.md),
including typed Python lookup, graph paths, schema analysis, authoring refs, including typed Python lookup, graph paths, schema analysis, authoring refs,
@@ -808,7 +802,7 @@ stable.
- Proposed runtime guard: add a persisted, run-wide - Proposed runtime guard: add a persisted, run-wide
[`step budget`](superpowers/specs/2026-09-04-run-step-budget-design.md) for [`step budget`](superpowers/specs/2026-09-04-run-step-budget-design.md) for
valid graph cycles that cannot be proven terminating during validation. valid graph cycles that cannot be proven terminating during validation.
- After that correction, reuse the foreach barrier/lineage machinery for - Next, reuse the foreach barrier/lineage machinery for
fork/gather. The proposed control semantics are recorded in fork/gather. The proposed control semantics are recorded in
[`ADR-0006`](adr/0006-explicit-fork-and-topology-driven-gather.md). [`ADR-0006`](adr/0006-explicit-fork-and-topology-driven-gather.md).
- Protocol-native progress: investigate MCP tasks/progress or WebSocket/SSE only - Protocol-native progress: investigate MCP tasks/progress or WebSocket/SSE only
@@ -949,6 +943,11 @@ stable.
routes, or outputs. Python, JSON-RPC, MCP, and local/remote CLI surfaces are routes, or outputs. Python, JSON-RPC, MCP, and local/remote CLI surfaces are
aligned. Implementation plan: aligned. Implementation plan:
[`capability step updates`](historical/superpowers/plans/2026-07-26-capability-step-update.md). [`capability step updates`](historical/superpowers/plans/2026-07-26-capability-step-update.md).
- Completed: foreach bodies now return through validated back-edges to their
immediate owner, with unique static control regions and fresh persisted
activation identities for every dynamic visit. Design:
[`foreach back-edge design`](superpowers/specs/2026-09-04-foreach-back-edge-design.md).
Fork/gather remains explicitly deferred.
Agent evaluation cohort status and policy: Agent evaluation cohort status and policy:
@@ -3,7 +3,7 @@
> **For agentic workers:** REQUIRED SUB-SKILL: Use > **For agentic workers:** REQUIRED SUB-SKILL: Use
> superpowers:subagent-driven-development (recommended) or > superpowers:subagent-driven-development (recommended) or
> superpowers:executing-plans to implement this plan task-by-task. Steps use > superpowers:executing-plans to implement this plan task-by-task. Steps use
> checkbox (`- [ ]`) syntax for tracking. > checkbox (`- [x]`) syntax for tracking.
**Goal:** Make foreach bodies return through validated back-edges to their **Goal:** Make foreach bodies return through validated back-edges to their
immediate owner, with unique static control regions and fresh persisted state immediate owner, with unique static control regions and fresh persisted state
@@ -19,7 +19,7 @@ back-edge as item completion.
pytest, pytest-asyncio, Ruff, basedpyright, markdownlint-cli2. pytest, pytest-asyncio, Ruff, basedpyright, markdownlint-cli2.
**Spec:** **Spec:**
[`docs/superpowers/specs/2026-09-04-foreach-back-edge-design.md`](../specs/2026-09-04-foreach-back-edge-design.md) [`docs/superpowers/specs/2026-09-04-foreach-back-edge-design.md`](../../../superpowers/specs/2026-09-04-foreach-back-edge-design.md)
## Global Constraints ## Global Constraints
@@ -85,7 +85,7 @@ pytest, pytest-asyncio, Ruff, basedpyright, markdownlint-cli2.
- `owner_stack_by_node` contains only nodes whose region is unambiguous. Later - `owner_stack_by_node` contains only nodes whose region is unambiguous. Later
context analysis must not grant foreach fields to a conflicted node. context analysis must not grant foreach fields to a conflicted node.
- [ ] **Step 1: Write failing acceptance tests for legal regions** - [x] **Step 1: Write failing acceptance tests for legal regions**
Add explicit tests named: Add explicit tests named:
@@ -108,7 +108,7 @@ pytest, pytest-asyncio, Ruff, basedpyright, markdownlint-cli2.
assert analysis.issues == () assert analysis.issues == ()
``` ```
- [ ] **Step 2: Run legal-region tests and confirm the missing module fails** - [x] **Step 2: Run legal-region tests and confirm the missing module fails**
Run: Run:
@@ -119,7 +119,7 @@ pytest, pytest-asyncio, Ruff, basedpyright, markdownlint-cli2.
Expected: collection fails because `wf_core.analysis.control_regions` does Expected: collection fails because `wf_core.analysis.control_regions` does
not exist. not exist.
- [ ] **Step 3: Implement semantic traversal over node and owner stack** - [x] **Step 3: Implement semantic traversal over node and owner stack**
Use a bounded worklist of `(node_id, owner_stack)` states. The special edge Use a bounded worklist of `(node_id, owner_stack)` states. The special edge
handling must follow this order: handling must follow this order:
@@ -149,7 +149,7 @@ pytest, pytest-asyncio, Ruff, basedpyright, markdownlint-cli2.
Ignore unknown sources and targets here because ordinary edge validation Ignore unknown sources and targets here because ordinary edge validation
already owns those diagnostics. already owns those diagnostics.
- [ ] **Step 4: Write failing tests for every invalid pressure case** - [x] **Step 4: Write failing tests for every invalid pressure case**
Add one explicit test per topology: Add one explicit test per topology:
@@ -172,7 +172,7 @@ pytest, pytest-asyncio, Ruff, basedpyright, markdownlint-cli2.
) )
``` ```
- [ ] **Step 5: Implement conflicts, reachability, and returnability** - [x] **Step 5: Implement conflicts, reachability, and returnability**
Record the first stack for each node. If a second distinct stack reaches the Record the first stack for each node. If a second distinct stack reaches the
same node, remove it from `owner_stack_by_node` and emit one region conflict. same node, remove it from `owner_stack_by_node` and emit one region conflict.
@@ -185,7 +185,7 @@ pytest, pytest-asyncio, Ruff, basedpyright, markdownlint-cli2.
Suppress cascading no-return diagnostics when a region conflict, invalid Suppress cascading no-return diagnostics when a region conflict, invalid
return, invalid terminal, or empty body already makes that state ambiguous. return, invalid terminal, or empty body already makes that state ambiguous.
- [ ] **Step 6: Run analyzer tests** - [x] **Step 6: Run analyzer tests**
Run: Run:
@@ -198,7 +198,7 @@ pytest, pytest-asyncio, Ruff, basedpyright, markdownlint-cli2.
Expected: all commands pass. Expected: all commands pass.
- [ ] **Step 7: Commit the analyzer** - [x] **Step 7: Commit the analyzer**
```bash ```bash
git add src/wf_core/analysis/control_regions.py \ git add src/wf_core/analysis/control_regions.py \
@@ -223,7 +223,7 @@ pytest, pytest-asyncio, Ruff, basedpyright, markdownlint-cli2.
`loop_item`, `loop_index`, and its alias; completing it restores the outer `loop_item`, `loop_index`, and its alias; completing it restores the outer
context. It does not expose all enclosing aliases. context. It does not expose all enclosing aliases.
- [ ] **Step 1: Rewrite context tests to canonical back-edges** - [x] **Step 1: Rewrite context tests to canonical back-edges**
Replace successful item routes such as: Replace successful item routes such as:
@@ -244,7 +244,7 @@ pytest, pytest-asyncio, Ruff, basedpyright, markdownlint-cli2.
{"from": "after_inner", "outcome": "ok", "to": "outer"} {"from": "after_inner", "outcome": "ok", "to": "outer"}
``` ```
- [ ] **Step 2: Replace the mixed-reachability expectation** - [x] **Step 2: Replace the mixed-reachability expectation**
Delete the test that expects one node to receive conditional loop fields when Delete the test that expects one node to receive conditional loop fields when
reached both inside and outside a foreach. Add a test proving conflicted nodes reached both inside and outside a foreach. Add a test proving conflicted nodes
@@ -260,7 +260,7 @@ pytest, pytest-asyncio, Ruff, basedpyright, markdownlint-cli2.
assert "inner_item" not in after_inner assert "inner_item" not in after_inner
``` ```
- [ ] **Step 3: Run the context tests and confirm old traversal fails** - [x] **Step 3: Run the context tests and confirm old traversal fails**
Run: Run:
@@ -271,7 +271,7 @@ pytest, pytest-asyncio, Ruff, basedpyright, markdownlint-cli2.
Expected: failures show that the single `FrameScope` traversal neither pops Expected: failures show that the single `FrameScope` traversal neither pops
canonical return edges nor consumes region-conflict diagnostics. canonical return edges nor consumes region-conflict diagnostics.
- [ ] **Step 4: Replace duplicate traversal with the analyzer result** - [x] **Step 4: Replace duplicate traversal with the analyzer result**
Remove the local breadth-first scope traversal. For each unambiguous node, Remove the local breadth-first scope traversal. For each unambiguous node,
derive its active context from the final stack item: derive its active context from the final stack item:
@@ -286,7 +286,7 @@ pytest, pytest-asyncio, Ruff, basedpyright, markdownlint-cli2.
diagnostics to bounded context warnings. Do not reintroduce multiple scopes diagnostics to bounded context warnings. Do not reintroduce multiple scopes
or conditional fields for a single node use. or conditional fields for a single node use.
- [ ] **Step 5: Run context and authoring-contract tests** - [x] **Step 5: Run context and authoring-contract tests**
Run: Run:
@@ -300,7 +300,7 @@ pytest, pytest-asyncio, Ruff, basedpyright, markdownlint-cli2.
Expected: all commands pass. Expected: all commands pass.
- [ ] **Step 6: Commit context integration** - [x] **Step 6: Commit context integration**
```bash ```bash
git add src/wf_core/analysis/context_scopes.py \ git add src/wf_core/analysis/context_scopes.py \
@@ -360,7 +360,7 @@ pytest, pytest-asyncio, Ruff, basedpyright, markdownlint-cli2.
- Callers compare owner fields by name; remove tuple slicing and positional - Callers compare owner fields by name; remove tuple slicing and positional
unpacking. unpacking.
- [ ] **Step 1: Write failing activation-lifecycle tests** - [x] **Step 1: Write failing activation-lifecycle tests**
Add tests proving: Add tests proving:
@@ -379,7 +379,7 @@ pytest, pytest-asyncio, Ruff, basedpyright, markdownlint-cli2.
Also test malformed metadata, mode mismatch, closing a stale activation, and Also test malformed metadata, mode mismatch, closing a stale activation, and
JSON round-trip through `ExecutionFrame.metadata`. JSON round-trip through `ExecutionFrame.metadata`.
- [ ] **Step 2: Run activation tests and confirm failure** - [x] **Step 2: Run activation tests and confirm failure**
Run: Run:
@@ -389,7 +389,7 @@ pytest, pytest-asyncio, Ruff, basedpyright, markdownlint-cli2.
Expected: imports fail because activation lifecycle helpers do not exist. Expected: imports fail because activation lifecycle helpers do not exist.
- [ ] **Step 3: Implement the activation metadata seam** - [x] **Step 3: Implement the activation metadata seam**
Hide the JSON dictionary shape inside `foreach_state.py`. Persist, per parent Hide the JSON dictionary shape inside `foreach_state.py`. Persist, per parent
frame and foreach node id, a monotonically increasing visit sequence plus at frame and foreach node id, a monotonically increasing visit sequence plus at
@@ -400,7 +400,7 @@ pytest, pytest-asyncio, Ruff, basedpyright, markdownlint-cli2.
retain a compatibility reader for the old barrier-only shape because the spec retain a compatibility reader for the old barrier-only shape because the spec
found no real persisted foreach data. found no real persisted foreach data.
- [ ] **Step 4: Add activation identity to item metadata and helpers** - [x] **Step 4: Add activation identity to item metadata and helpers**
Require this shape: Require this shape:
@@ -417,14 +417,14 @@ pytest, pytest-asyncio, Ruff, basedpyright, markdownlint-cli2.
Child frame and lineage ids must include `activation.id`, so a later visit at Child frame and lineage ids must include `activation.id`, so a later visit at
item index zero cannot collide with the first visit. item index zero cannot collide with the first visit.
- [ ] **Step 5: Move runtime callers onto named owner and activation state** - [x] **Step 5: Move runtime callers onto named owner and activation state**
Update lineage reads, node-result buffering, async batching, failure Update lineage reads, node-result buffering, async batching, failure
collection, refill, and barrier commit to load the activation named by the collection, refill, and barrier commit to load the activation named by the
child. Fail closed when a child result names a closed or different active child. Fail closed when a child result names a closed or different active
activation. activation.
- [ ] **Step 6: Update focused metadata tests** - [x] **Step 6: Update focused metadata tests**
Replace hand-written item metadata in `test_foreach_barrier_state.py` and Replace hand-written item metadata in `test_foreach_barrier_state.py` and
`test_scheduler.py` with required activation ids. Update hard-coded child `test_scheduler.py` with required activation ids. Update hard-coded child
@@ -432,7 +432,7 @@ pytest, pytest-asyncio, Ruff, basedpyright, markdownlint-cli2.
include the activation identity. Assert `item_frame_owner` returns include the activation identity. Assert `item_frame_owner` returns
`ForeachItemOwner`, not a tuple. `ForeachItemOwner`, not a tuple.
- [ ] **Step 7: Run runtime-state tests** - [x] **Step 7: Run runtime-state tests**
Run: Run:
@@ -449,7 +449,7 @@ pytest, pytest-asyncio, Ruff, basedpyright, markdownlint-cli2.
Expected: all commands pass. Expected: all commands pass.
- [ ] **Step 8: Commit activation identity** - [x] **Step 8: Commit activation identity**
```bash ```bash
git add src/wf_core/runtime/foreach_state.py \ git add src/wf_core/runtime/foreach_state.py \
@@ -488,7 +488,7 @@ pytest, pytest-asyncio, Ruff, basedpyright, markdownlint-cli2.
- Produces an internal helper that derives ancestor foreach owners from frame - Produces an internal helper that derives ancestor foreach owners from frame
ancestry for defensive non-local-return rejection. ancestry for defensive non-local-return rejection.
- [ ] **Step 1: Write failing serial return tests** - [x] **Step 1: Write failing serial return tests**
Add tests with canonical edges: Add tests with canonical edges:
@@ -501,7 +501,7 @@ pytest, pytest-asyncio, Ruff, basedpyright, markdownlint-cli2.
Prove two items execute, the child finishes at `each`, the parent wakes, and Prove two items execute, the child finishes at `each`, the parent wakes, and
the final workflow outcome remains `ok`. the final workflow outcome remains `ok`.
- [ ] **Step 2: Write failing cycle, nested, and re-entry runtime tests** - [x] **Step 2: Write failing cycle, nested, and re-entry runtime tests**
Add explicit tests named: Add explicit tests named:
@@ -518,7 +518,7 @@ pytest, pytest-asyncio, Ruff, basedpyright, markdownlint-cli2.
direct defensive test constructs an invalid frame chain without running direct defensive test constructs an invalid frame chain without running
workflow preparation and asserts `WorkflowExecutionError`. workflow preparation and asserts `WorkflowExecutionError`.
- [ ] **Step 3: Implement immediate-owner return in frame advancement** - [x] **Step 3: Implement immediate-owner return in frame advancement**
Before `END` handling or ordinary enqueue: Before `END` handling or ordinary enqueue:
@@ -540,20 +540,20 @@ pytest, pytest-asyncio, Ruff, basedpyright, markdownlint-cli2.
an item targets `END`, or targets a foreach found below its immediate owner in an item targets `END`, or targets a foreach found below its immediate owner in
the active ancestor chain, raise `WorkflowExecutionError` defensively. the active ancestor chain, raise `WorkflowExecutionError` defensively.
- [ ] **Step 4: Close activations before controller completion edges** - [x] **Step 4: Close activations before controller completion edges**
In both serial and concurrent completion paths, close the active activation In both serial and concurrent completion paths, close the active activation
before calling `advance_frame` for `done` or `completed_with_errors`. This before calling `advance_frame` for `done` or `completed_with_errors`. This
makes a self-looping or later returning completion edge start a fresh visit. makes a self-looping or later returning completion edge start a fresh visit.
- [ ] **Step 5: Migrate executable foreach fixtures** - [x] **Step 5: Migrate executable foreach fixtures**
Change item-success routes from `END` to their owner in every file listed for Change item-success routes from `END` to their owner in every file listed for
this task. Keep controller completion routes to `END` or their real outer this task. Keep controller completion routes to `END` or their real outer
continuation. For nested fixtures, return inner bodies to the inner foreach continuation. For nested fixtures, return inner bodies to the inner foreach
and outer-tail nodes to the outer foreach. and outer-tail nodes to the outer foreach.
- [ ] **Step 6: Prove concurrent, async, error, and interrupt behavior** - [x] **Step 6: Prove concurrent, async, error, and interrupt behavior**
Run: Run:
@@ -572,7 +572,7 @@ pytest, pytest-asyncio, Ruff, basedpyright, markdownlint-cli2.
fail-closed test showing a completed activation cannot accept a result or fail-closed test showing a completed activation cannot accept a result or
wake-up from another activation. wake-up from another activation.
- [ ] **Step 7: Run runtime static checks** - [x] **Step 7: Run runtime static checks**
Run: Run:
@@ -588,7 +588,7 @@ pytest, pytest-asyncio, Ruff, basedpyright, markdownlint-cli2.
Expected: all commands pass. Expected: all commands pass.
- [ ] **Step 8: Commit runtime back-edges** - [x] **Step 8: Commit runtime back-edges**
```bash ```bash
git add src/wf_core/runtime/ops/flow.py \ git add src/wf_core/runtime/ops/flow.py \
@@ -624,7 +624,7 @@ pytest, pytest-asyncio, Ruff, basedpyright, markdownlint-cli2.
- Keeps `Workflow.validate_structure()` and `ValidationReport` signatures - Keeps `Workflow.validate_structure()` and `ValidationReport` signatures
unchanged. unchanged.
- [ ] **Step 1: Add failing public-validation assertions** - [x] **Step 1: Add failing public-validation assertions**
For every pressure-case test, call both the pure analyzer and For every pressure-case test, call both the pure analyzer and
`workflow.validate_structure()`. Invalid cases must assert the public code and `workflow.validate_structure()`. Invalid cases must assert the public code and
@@ -642,7 +642,7 @@ pytest, pytest-asyncio, Ruff, basedpyright, markdownlint-cli2.
Legal cases assert `report.ok`. Add a test proving every unreachable node in Legal cases assert `report.ok`. Add a test proving every unreachable node in
one component receives its own `UNREACHABLE_NODE` issue. one component receives its own `UNREACHABLE_NODE` issue.
- [ ] **Step 2: Run public-validation tests and confirm failure** - [x] **Step 2: Run public-validation tests and confirm failure**
Run: Run:
@@ -652,7 +652,7 @@ pytest, pytest-asyncio, Ruff, basedpyright, markdownlint-cli2.
Expected: analyzer tests pass, but public reports lack the new issue codes. Expected: analyzer tests pass, but public reports lack the new issue codes.
- [ ] **Step 3: Wire analysis into validation once** - [x] **Step 3: Wire analysis into validation once**
Add enum members with exactly the analyzer values. Call Add enum members with exactly the analyzer values. Call
`analyze_control_regions(workflow)` after ordinary node and edge validation, `analyze_control_regions(workflow)` after ordinary node and edge validation,
@@ -668,7 +668,7 @@ pytest, pytest-asyncio, Ruff, basedpyright, markdownlint-cli2.
Do not add a second graph traversal inside validation. Do not add a second graph traversal inside validation.
- [ ] **Step 4: Canonicalize policy and draft fixtures** - [x] **Step 4: Canonicalize policy and draft fixtures**
Policy-only workflow helpers must include a distinct body node and route it Policy-only workflow helpers must include a distinct body node and route it
back to the foreach owner. Draft fixtures with: back to the foreach owner. Draft fixtures with:
@@ -688,7 +688,7 @@ pytest, pytest-asyncio, Ruff, basedpyright, markdownlint-cli2.
Parse-only policy fixtures still use a distinct body; do not preserve an Parse-only policy fixtures still use a distinct body; do not preserve an
invalid `loop -> __end__` shortcut just because the test does not execute it. invalid `loop -> __end__` shortcut just because the test does not execute it.
- [ ] **Step 5: Run validation and draft suites** - [x] **Step 5: Run validation and draft suites**
Run: Run:
@@ -703,7 +703,7 @@ pytest, pytest-asyncio, Ruff, basedpyright, markdownlint-cli2.
`UNREACHABLE_NODE` when disconnection is the behavior under test. Do not add `UNREACHABLE_NODE` when disconnection is the behavior under test. Do not add
an allow-unreachable flag. an allow-unreachable flag.
- [ ] **Step 6: Run core validation static checks** - [x] **Step 6: Run core validation static checks**
Run: Run:
@@ -717,7 +717,7 @@ pytest, pytest-asyncio, Ruff, basedpyright, markdownlint-cli2.
Expected: all commands pass. Expected: all commands pass.
- [ ] **Step 7: Commit fail-closed validation** - [x] **Step 7: Commit fail-closed validation**
```bash ```bash
git add src/wf_core/validation tests/core/test_foreach_control_regions.py \ git add src/wf_core/validation tests/core/test_foreach_control_regions.py \
@@ -744,7 +744,7 @@ pytest, pytest-asyncio, Ruff, basedpyright, markdownlint-cli2.
surface. surface.
- Preserves historical reports and recorded agent-challenge outputs verbatim. - Preserves historical reports and recorded agent-challenge outputs verbatim.
- [ ] **Step 1: Search for stale canonical foreach returns** - [x] **Step 1: Search for stale canonical foreach returns**
Run targeted searches: Run targeted searches:
@@ -760,7 +760,7 @@ pytest, pytest-asyncio, Ruff, basedpyright, markdownlint-cli2.
completion changes to the owner. Do not rewrite unrelated ordinary terminal completion changes to the owner. Do not rewrite unrelated ordinary terminal
routes or immutable historical evidence. routes or immutable historical evidence.
- [ ] **Step 2: Update live architecture and authoring docs** - [x] **Step 2: Update live architecture and authoring docs**
Replace the old architecture statement that item children reach `END` with: Replace the old architecture statement that item children reach `END` with:
@@ -781,13 +781,13 @@ pytest, pytest-asyncio, Ruff, basedpyright, markdownlint-cli2.
Document that region conflicts, unreachable nodes, body terminals, non-local Document that region conflicts, unreachable nodes, body terminals, non-local
returns, empty bodies, and bodies without possible returns fail validation. returns, empty bodies, and bodies without possible returns fail validation.
- [ ] **Step 3: Mark the design implemented and roadmap item complete** - [x] **Step 3: Mark the design implemented and roadmap item complete**
Set the spec status to `Implemented on 2026-09-04`. Move the roadmap bullet Set the spec status to `Implemented on 2026-09-04`. Move the roadmap bullet
from active correction to recently completed runtime work. Keep fork/gather from active correction to recently completed runtime work. Keep fork/gather
explicitly deferred. explicitly deferred.
- [ ] **Step 4: Run the focused acceptance matrix** - [x] **Step 4: Run the focused acceptance matrix**
Run: Run:
@@ -806,7 +806,7 @@ pytest, pytest-asyncio, Ruff, basedpyright, markdownlint-cli2.
Expected: every current pressure-case row passes. The future-fork row remains Expected: every current pressure-case row passes. The future-fork row remains
documented and unimplemented because no fork node exists. documented and unimplemented because no fork node exists.
- [ ] **Step 5: Run repository verification** - [x] **Step 5: Run repository verification**
Run: Run:
@@ -826,7 +826,7 @@ pytest, pytest-asyncio, Ruff, basedpyright, markdownlint-cli2.
unrelated lint debt, do not run an unsafe global auto-fix; report it and keep unrelated lint debt, do not run an unsafe global auto-fix; report it and keep
this slice's edited documents clean. this slice's edited documents clean.
- [ ] **Step 6: Commit implementation documentation** - [x] **Step 6: Commit implementation documentation**
```bash ```bash
git add docs/wf_core_architecture.md docs/wf_authoring_control_flow.md \ git add docs/wf_core_architecture.md docs/wf_authoring_control_flow.md \
@@ -835,7 +835,7 @@ pytest, pytest-asyncio, Ruff, basedpyright, markdownlint-cli2.
git commit -m "docs: publish foreach back-edge semantics" git commit -m "docs: publish foreach back-edge semantics"
``` ```
- [ ] **Step 7: Archive the completed plan** - [x] **Step 7: Archive the completed plan**
After every prior task is complete and committed: After every prior task is complete and committed:
@@ -2,9 +2,9 @@
## Status ## Status
Approved in conversation on 2026-09-04. This document specifies canonical Implemented on 2026-09-04. This document specifies canonical foreach
foreach body-return semantics. It does not include the separately planned body-return semantics. It does not include the separately planned ergonomic
ergonomic Python DSL or authorize fork/gather implementation. Python DSL or authorize fork/gather implementation.
## Purpose ## Purpose
@@ -203,9 +203,10 @@ foreach_owner_stack)` rather than merely looking for graph cycles:
A node use therefore belongs to one static control region, while remaining free A node use therefore belongs to one static control region, while remaining free
to execute in any number of dynamic frames, lineages, or items. When the same to execute in any number of dynamic frames, lineages, or items. When the same
capability is needed at two program locations, authoring creates two node uses capability is needed at two program locations, authoring creates two node uses
with distinct identifiers. Existing context-contract analysis may still report with distinct identifiers. Context-contract analysis reports one field set per
fields as conditional because multiple paths can reach a node within its one static region: fields are available when the region is inside a foreach body
region; it must not use multiple owner stacks to represent that case. and absent outside it. A node reached under two stacks is a region conflict
and receives no foreach fields rather than a conditional union.
The unique-owner rule rejects both ways of crossing a foreach boundary. An The unique-owner rule rejects both ways of crossing a foreach boundary. An
outside edge into a body node reaches that node under both the outer and item outside edge into a body node reaches that node under both the outer and item
@@ -431,9 +432,24 @@ may independently return and complete the item.
## State and Failure Behavior ## State and Failure Behavior
Back-edge return changes control representation, not state semantics. Back-edge return changes control representation, not state semantics.
Iteration writes remain buffered in the item lineage. Serial behavior and the Concurrent iteration writes remain buffered in the item lineage for the
concurrent barrier continue to commit or merge those writes according to the barrier to merge, while serial owners pass writes outward to the scope
accepted concurrent-foreach ADR and declared reducers. root, which commits them according to the accepted concurrent-foreach ADR
and declared reducers. One shared helper
routes every item write: it climbs through each serial owner to the scope
root, where it commits, or selects the first concurrent boundary as the
buffer target, where it buffers for that barrier to merge (the walk
continues past the selected boundary to validate the full ancestry, so
parent cycles fail closed even when they pass through a concurrent
owner; the concurrent barrier finish
routes its combined patch through the same helper, so nested serial owners
cannot strand it). Parent cycles, missing parents, and orphaned item frames
fail closed. Buffered failure records must carry an error whose index and
frame match the enclosing result. The completed item is
registered with its barrier at the owner back-edge, keyed by the returning
frame rather than by whichever operation ran last, so node, subgraph, and
nested-control endings all count. A return naming a closed or superseded
activation fails closed instead of buffering into the wrong visit.
An ordinary node outcome named `error` remains domain control. An exception An ordinary node outcome named `error` remains domain control. An exception
remains a runtime item failure handled by `fail`, `skip`, or `collect`. Neither remains a runtime item failure handled by `fail`, `skip`, or `collect`. Neither
@@ -490,7 +506,7 @@ semantics also run through the runtime.
| Closed body cycle | Reject missing owner return | N/A | | Closed body cycle | Reject missing owner return | N/A |
| Unreachable nodes | Reject each node | N/A | | Unreachable nodes | Reject each node | N/A |
| Re-enter foreach after `done` | Accept | Fresh activation and children | | Re-enter foreach after `done` | Accept | Fresh activation and children |
| Subgraph inside foreach | Accept | Child `END`, then item return | | Subgraph inside foreach | Accept | Child `END`, then item return (serial and concurrent) |
| Interrupt inside foreach | Accept | Resume the same item activation | | Interrupt inside foreach | Accept | Resume the same item activation |
| Future fork in foreach | Deferred with fork/gather | Gather before return | | Future fork in foreach | Deferred with fork/gather | Gather before return |
+11
View File
@@ -228,6 +228,17 @@ step with item-local child lineages:
In async execution, admitted async item node handlers may run at the same time. In async execution, admitted async item node handlers may run at the same time.
Run-state mutation, tracing, and barrier commits remain deterministic. Run-state mutation, tracing, and barrier commits remain deterministic.
An iteration body returns through its immediate owning foreach:
```python
g.connect(each, "loop", record)
g.connect(record, "ok", each)
g.connect(each, "done", END)
```
Region conflicts, unreachable nodes, body terminals, non-local returns, empty
bodies, and bodies without possible returns fail validation.
See `examples/authoring_concurrent_foreach.py` for a runnable example covering: See `examples/authoring_concurrent_foreach.py` for a runnable example covering:
- sync concurrent foreach with `item_error={"action": "collect", ...}`; - sync concurrent foreach with `item_error={"action": "collect", ...}`;
+7 -3
View File
@@ -74,9 +74,10 @@ interrupted, failed, or deadlocked. This replaces the older assumption that
## Foreach ## Foreach
Serial foreach creates one iteration child frame, records typed Serial foreach creates one iteration child frame, records typed
`ForeachIterationMetadata`, blocks on that child, and enqueues the child. When `ForeachIterationMetadata`, blocks on that child, and enqueues the child. An
the child reaches `END`, `wake_parent_if_children_complete` wakes the blocked item child returns by targeting its immediate owning foreach. The child
parent so it can create the next iteration or emit `done`. finishes at that owner location without executing the controller; the parent
activation consumes the result and continues or completes its barrier.
Concurrent foreach uses the same frame machinery but admits multiple item Concurrent foreach uses the same frame machinery but admits multiple item
lineages according to `ForeachConcurrentPolicy`. Each item lineage reads through lineages according to `ForeachConcurrentPolicy`. Each item lineage reads through
@@ -98,6 +99,9 @@ See `examples/raw_concurrent_foreach.py` for the canonical raw workflow shape an
- validate edge sources, destinations, duplicate outcomes, and declared outcomes - validate edge sources, destinations, duplicate outcomes, and declared outcomes
- validate reachable nodes have all required outcome edges - validate reachable nodes have all required outcome edges
- validate explicit `EndNode` outcomes against `Workflow.outcomes` - validate explicit `EndNode` outcomes against `Workflow.outcomes`
- validate foreach control regions once: region conflicts, unreachable nodes,
body terminals, non-local returns, empty bodies, and bodies without possible
returns fail validation
Validation reports multiple issues through `ValidationReport` instead of Validation reports multiple issues through `ValidationReport` instead of
raising at the first failure. raising at the first failure.
+2 -2
View File
@@ -114,7 +114,7 @@ def build_concurrent_foreach_workflow(
) )
builder.set_entry_point(each) builder.set_entry_point(each)
builder.connect(each, "loop", record) builder.connect(each, "loop", record)
builder.connect(record, "ok", END) builder.connect(record, "ok", each)
builder.connect(each, "done", END) builder.connect(each, "done", END)
if _item_error_action(item_error) in {"collect", "skip"}: if _item_error_action(item_error) in {"collect", "skip"}:
builder.connect(each, "completed_with_errors", END) builder.connect(each, "completed_with_errors", END)
@@ -165,7 +165,7 @@ def run_replace_conflict_example() -> None:
) )
builder.set_entry_point(each) builder.set_entry_point(each)
builder.connect(each, "loop", record) builder.connect(each, "loop", record)
builder.connect(record, "ok", END) builder.connect(record, "ok", each)
builder.connect(each, "done", END) builder.connect(each, "done", END)
try: try:
builder.execute({"items": ["a", "b"]}) builder.execute({"items": ["a", "b"]})
+1 -1
View File
@@ -255,7 +255,7 @@ def build_demo_workflow() -> Workflow:
"outcome": "done", "outcome": "done",
"to": "combine_summaries", "to": "combine_summaries",
}, },
{"from": "summarize_one", "outcome": "ok", "to": END}, {"from": "summarize_one", "outcome": "ok", "to": "summarize_each"},
{"from": "combine_summaries", "outcome": "ok", "to": "should_email"}, {"from": "combine_summaries", "outcome": "ok", "to": "should_email"},
{"from": "should_email", "outcome": "true", "to": "approve_email"}, {"from": "should_email", "outcome": "true", "to": "approve_email"},
{"from": "should_email", "outcome": "false", "to": "skip_email"}, {"from": "should_email", "outcome": "false", "to": "skip_email"},
+1 -1
View File
@@ -97,7 +97,7 @@ def build_raw_concurrent_foreach_workflow() -> Workflow:
], ],
"edges": [ "edges": [
{"from": "each", "outcome": "loop", "to": "record"}, {"from": "each", "outcome": "loop", "to": "record"},
{"from": "record", "outcome": "ok", "to": END}, {"from": "record", "outcome": "ok", "to": "each"},
{"from": "each", "outcome": "done", "to": END}, {"from": "each", "outcome": "done", "to": END},
{"from": "each", "outcome": "completed_with_errors", "to": END}, {"from": "each", "outcome": "completed_with_errors", "to": END},
], ],
+12
View File
@@ -5,9 +5,21 @@ from .context_scopes import (
context_analysis_warnings, context_analysis_warnings,
context_fields_by_node, context_fields_by_node,
) )
from .control_regions import (
ControlRegionAnalysis,
ControlRegionIssue,
ControlRegionIssueKind,
ForeachOwnerStack,
analyze_control_regions,
)
__all__ = [ __all__ = [
"ContextFieldAvailability", "ContextFieldAvailability",
"ControlRegionAnalysis",
"ControlRegionIssue",
"ControlRegionIssueKind",
"ForeachOwnerStack",
"analyze_control_regions",
"context_analysis_warnings", "context_analysis_warnings",
"context_fields_by_node", "context_fields_by_node",
] ]
+80 -101
View File
@@ -1,11 +1,14 @@
from __future__ import annotations from __future__ import annotations
from collections import deque
from collections.abc import Mapping from collections.abc import Mapping
from copy import deepcopy from copy import deepcopy
from dataclasses import dataclass from dataclasses import dataclass
from typing import Literal from typing import Literal
from wf_core.analysis.control_regions import (
ForeachOwnerStack,
analyze_control_regions,
)
from wf_core.context_contracts import ( from wf_core.context_contracts import (
STANDARD_CONTEXT_FIELDS, STANDARD_CONTEXT_FIELDS,
ContextFieldContract, ContextFieldContract,
@@ -65,11 +68,12 @@ def context_fields_by_node(
) -> dict[str, tuple[ContextFieldAvailability, ...]]: ) -> dict[str, tuple[ContextFieldAvailability, ...]]:
"""Return runtime context contracts for every reachable graph node. """Return runtime context contracts for every reachable graph node.
This is an abstract execution-frame analysis rather than ordinary graph This is an abstract execution-frame analysis keyed by static control
reachability: the same node can execute in the root frame and in a region: each node use belongs to exactly one foreach-owner stack, and
foreach child frame, and those frames expose different context keys. that stack decides which foreach aliases the node exposes. A node
The traversal memoizes both node id and active frame scope so cyclic reachable under two stacks is a region conflict and receives no foreach
graphs terminate without granting aliases from an impossible scope. fields. The traversal still memoizes node id and owner stack so cyclic
graphs terminate.
""" """
return _analyze(workflow).fields_by_node return _analyze(workflow).fields_by_node
@@ -80,6 +84,13 @@ def context_analysis_warnings(workflow: Workflow) -> tuple[str, ...]:
def _analyze(workflow: Workflow) -> _ContextAnalysis: def _analyze(workflow: Workflow) -> _ContextAnalysis:
"""Derive context contracts from static foreach control regions.
Each unambiguous node use has exactly one owner stack; its active foreach
is the final stack item. A canonical return edge pops the stack, so the
controller itself stays in the outer context. Conflicted nodes receive no
foreach fields.
"""
nodes = {node.id: node for node in workflow.nodes} nodes = {node.id: node for node in workflow.nodes}
foreach_nodes = { foreach_nodes = {
node.id: node for node in workflow.nodes if isinstance(node, ForeachNode) node.id: node for node in workflow.nodes if isinstance(node, ForeachNode)
@@ -107,35 +118,20 @@ def _analyze(workflow: Workflow) -> _ContextAnalysis:
warnings.add(f"workflow start targets missing node {workflow.start!r}") warnings.add(f"workflow start targets missing node {workflow.start!r}")
return _ContextAnalysis({}, tuple(warnings.values)) return _ContextAnalysis({}, tuple(warnings.values))
scopes_by_node: dict[str, set[FrameScope]] = {} analysis = analyze_control_regions(workflow)
pending: deque[tuple[str, FrameScope]] = deque([(workflow.start, None)]) for issue in analysis.issues:
visited: set[tuple[str, FrameScope]] = set() warnings.add(
while pending: f"control region {issue.kind.value} at {issue.path}: {issue.message}"
node_id, active_scope = pending.popleft() )
state = (node_id, active_scope)
if state in visited:
continue
visited.add(state)
node = nodes.get(node_id)
if node is None:
continue
scopes_by_node.setdefault(node_id, set()).add(active_scope)
for edge in edges_by_node.get(node_id, []):
if edge.to == END or edge.to not in nodes:
continue
next_scope = active_scope
if isinstance(node, ForeachNode) and edge.outcome == "loop":
next_scope = node.id
pending.append((edge.to, next_scope))
fields_by_node: dict[str, tuple[ContextFieldAvailability, ...]] = {} fields_by_node: dict[str, tuple[ContextFieldAvailability, ...]] = {}
for node_id, scopes in scopes_by_node.items(): for node_id, stack in analysis.owner_stack_by_node.items():
active_foreach_id = stack[-1] if stack else None
fields_by_node[node_id] = _available_fields( fields_by_node[node_id] = _available_fields(
workflow, workflow,
foreach_nodes, foreach_nodes,
scopes, analysis.owner_stack_by_node,
scopes_by_node, active_foreach_id,
) )
return _ContextAnalysis(fields_by_node, tuple(warnings.values)) return _ContextAnalysis(fields_by_node, tuple(warnings.values))
@@ -143,83 +139,67 @@ def _analyze(workflow: Workflow) -> _ContextAnalysis:
def _available_fields( def _available_fields(
workflow: Workflow, workflow: Workflow,
foreach_nodes: Mapping[str, ForeachNode], foreach_nodes: Mapping[str, ForeachNode],
scopes: set[FrameScope], owner_stack_by_node: Mapping[str, ForeachOwnerStack],
scopes_by_node: Mapping[str, set[FrameScope]], active_scope: FrameScope,
) -> tuple[ContextFieldAvailability, ...]: ) -> tuple[ContextFieldAvailability, ...]:
fields_by_name: dict[str, ContextFieldContract] = {} """Return contracts for one static owner stack; all are guaranteed.
scopes_by_field: dict[str, set[FrameScope]] = {}
for scope in sorted(scopes, key=lambda value: value or ""): A single node use has one control region, so foreach fields are either
contracts = STANDARD_CONTEXT_FIELDS present (inside a body) or absent (outside). Conditional availability is
if scope is not None: not used to represent multiple owner stacks: a node reached under two
foreach = foreach_nodes.get(scope) stacks is a region conflict and receives no foreach fields at all.
if foreach is not None: """
contracts = ( contracts = list(STANDARD_CONTEXT_FIELDS)
*contracts, if active_scope is not None:
*foreach_context_fields( foreach = foreach_nodes.get(active_scope)
foreach.as_, if foreach is not None:
_foreach_item_schema( contracts.extend(
workflow, foreach_context_fields(
foreach, foreach.as_,
scopes_by_node.get(foreach.id, {None}), _foreach_item_schema(
foreach_nodes, workflow,
scopes_by_node, foreach,
), foreach_nodes,
owner_stack_by_node,
), ),
) )
for contract in contracts: )
fields_by_name.setdefault( return tuple(
ContextFieldAvailability(
contract=ContextFieldContract(
contract.name, contract.name,
ContextFieldContract( deepcopy(contract.schema),
contract.name, contract.description,
deepcopy(contract.schema), ),
contract.description, availability="available",
),
)
scopes_by_field.setdefault(contract.name, set()).add(scope)
field_count = len(scopes)
result: list[ContextFieldAvailability] = []
for contract in fields_by_name.values():
field_scopes = scopes_by_field[contract.name]
availability: ContextAvailability = (
"available" if len(field_scopes) == field_count else "conditional"
) )
reason = None for contract in contracts
if availability == "conditional": )
reason = "Available only in some reachable execution frames."
result.append(
ContextFieldAvailability(
contract=contract,
availability=availability,
reason=reason,
)
)
return tuple(result)
def _foreach_item_schema( def _foreach_item_schema(
workflow: Workflow, workflow: Workflow,
foreach: ForeachNode, foreach: ForeachNode,
source_scopes: set[FrameScope],
foreach_nodes: Mapping[str, ForeachNode], foreach_nodes: Mapping[str, ForeachNode],
scopes_by_node: Mapping[str, set[FrameScope]], owner_stack_by_node: Mapping[str, ForeachOwnerStack],
) -> ContextSchema: ) -> ContextSchema:
source_schemas = [ """Resolve one controller's item schema in its own static context.
_schema_at_path(
workflow, An inner foreach may declare ``over="context.outer_item"``; the lookup
foreach.over.root, uses the controller's own owner stack, not the inner body stack.
foreach.over.parts, """
source_scope, controller_stack = owner_stack_by_node.get(foreach.id)
foreach_nodes, if controller_stack is None:
scopes_by_node,
)
for source_scope in sorted(source_scopes, key=lambda value: value or "")
]
if not source_schemas or any(
schema != source_schemas[0] for schema in source_schemas
):
return {} return {}
source_schema = source_schemas[0] controller_scope: FrameScope = controller_stack[-1] if controller_stack else None
source_schema = _schema_at_path(
workflow,
foreach.over.root,
foreach.over.parts,
controller_scope,
foreach_nodes,
owner_stack_by_node,
)
if not isinstance(source_schema, Mapping): if not isinstance(source_schema, Mapping):
return {} return {}
source_type = source_schema.get("type") source_type = source_schema.get("type")
@@ -235,7 +215,7 @@ def _foreach_item_schema(
workflow, workflow,
foreach.over.root, foreach.over.root,
foreach_nodes=foreach_nodes, foreach_nodes=foreach_nodes,
scopes_by_node=scopes_by_node, owner_stack_by_node=owner_stack_by_node,
), ),
items, items,
) )
@@ -250,7 +230,7 @@ def _schema_at_path(
parts: tuple[str, ...], parts: tuple[str, ...],
active_scope: FrameScope, active_scope: FrameScope,
foreach_nodes: Mapping[str, ForeachNode], foreach_nodes: Mapping[str, ForeachNode],
scopes_by_node: Mapping[str, set[FrameScope]], owner_stack_by_node: Mapping[str, ForeachOwnerStack],
) -> Mapping[str, object] | None: ) -> Mapping[str, object] | None:
try: try:
schema_document = _schema_document( schema_document = _schema_document(
@@ -258,7 +238,7 @@ def _schema_at_path(
root, root,
active_scope=active_scope, active_scope=active_scope,
foreach_nodes=foreach_nodes, foreach_nodes=foreach_nodes,
scopes_by_node=scopes_by_node, owner_stack_by_node=owner_stack_by_node,
) )
current: object = schema_document current: object = schema_document
for part in parts: for part in parts:
@@ -282,7 +262,7 @@ def _schema_document(
*, *,
active_scope: FrameScope = None, active_scope: FrameScope = None,
foreach_nodes: Mapping[str, ForeachNode] | None = None, foreach_nodes: Mapping[str, ForeachNode] | None = None,
scopes_by_node: Mapping[str, set[FrameScope]] | None = None, owner_stack_by_node: Mapping[str, ForeachOwnerStack] | None = None,
) -> Mapping[str, object]: ) -> Mapping[str, object]:
if root == "input": if root == "input":
return workflow.input_schema.model_dump(mode="json", exclude_none=True) return workflow.input_schema.model_dump(mode="json", exclude_none=True)
@@ -295,7 +275,7 @@ def _schema_document(
if ( if (
active_scope is not None active_scope is not None
and foreach_nodes is not None and foreach_nodes is not None
and scopes_by_node is not None and owner_stack_by_node is not None
): ):
foreach = foreach_nodes.get(active_scope) foreach = foreach_nodes.get(active_scope)
if foreach is not None: if foreach is not None:
@@ -307,9 +287,8 @@ def _schema_document(
_foreach_item_schema( _foreach_item_schema(
workflow, workflow,
foreach, foreach,
scopes_by_node.get(foreach.id, {None}),
foreach_nodes, foreach_nodes,
scopes_by_node, owner_stack_by_node,
), ),
) )
} }
+274
View File
@@ -0,0 +1,274 @@
from __future__ import annotations
from collections import deque
from dataclasses import dataclass
from enum import StrEnum
from wf_core.models.steps import EndNode, ForeachNode
from wf_core.models.workflow import Workflow
from wf_core.tokens import END
type ForeachOwnerStack = tuple[str, ...]
class ControlRegionIssueKind(StrEnum):
UNREACHABLE_NODE = "unreachable_node"
FOREACH_REGION_CONFLICT = "foreach_region_conflict"
INVALID_FOREACH_RETURN = "invalid_foreach_return"
INVALID_FOREACH_TERMINAL = "invalid_foreach_terminal"
EMPTY_FOREACH_BODY = "empty_foreach_body"
FOREACH_BODY_NO_RETURN = "foreach_body_no_return"
@dataclass(frozen=True, slots=True)
class ControlRegionIssue:
kind: ControlRegionIssueKind
path: str
message: str
@dataclass(frozen=True, slots=True)
class ControlRegionAnalysis:
owner_stack_by_node: dict[str, ForeachOwnerStack]
issues: tuple[ControlRegionIssue, ...]
def analyze_control_regions(workflow: Workflow) -> ControlRegionAnalysis:
"""Derive one static foreach-owner stack per reachable node use.
Traversal is over ``(node_id, owner_stack)`` states. A ``loop`` edge from
a foreach pushes that controller; an edge targeting the immediate owner is
an item return that resumes the owner in the popped stack; targeting an
older ancestor is a non-local return; targeting ``END``/``EndNode`` inside
a body is an invalid terminal. Reaching the same node under two stacks is
a region conflict. After traversal every unreached node is unreachable and
every reached body state must have a structural path back to its top owner.
"""
nodes_by_id = {node.id: node for node in workflow.nodes}
if workflow.start not in nodes_by_id:
return ControlRegionAnalysis(owner_stack_by_node={}, issues=())
edges_by_node: dict[str, list[tuple[int, object]]] = {}
for index, edge in enumerate(workflow.edges):
edges_by_node.setdefault(edge.from_, []).append((index, edge))
owner_stack_by_node: dict[str, ForeachOwnerStack] = {}
conflicted: set[str] = set()
issues: list[ControlRegionIssue] = []
# Semantic state adjacency for the structural-return check. Return edges
# also link to the resumed owner state so deeper nested returns are part
# of the path search.
adjacency: dict[
tuple[str, ForeachOwnerStack], list[tuple[str, ForeachOwnerStack]]
] = {}
return_owner_by_source: dict[tuple[str, ForeachOwnerStack], str] = {}
ambiguous_tops: set[str] = set()
def mark_ambiguous(stack: ForeachOwnerStack) -> None:
if stack:
ambiguous_tops.add(stack[-1])
def record_region_conflict(
node_id: str, stacks: tuple[ForeachOwnerStack, ...]
) -> None:
"""Drop one node use reached under two regions and report it once."""
del owner_stack_by_node[node_id]
if node_id not in conflicted:
conflicted.add(node_id)
issues.append(
ControlRegionIssue(
kind=ControlRegionIssueKind.FOREACH_REGION_CONFLICT,
path=f"nodes[{node_id}]",
message=(
f"node {node_id!r} is reachable under two foreach "
"control regions"
),
)
)
for prior_stack in stacks:
mark_ambiguous(prior_stack)
def add_adjacency(
source: tuple[str, ForeachOwnerStack],
target: tuple[str, ForeachOwnerStack],
) -> None:
adjacency.setdefault(source, []).append(target)
pending: deque[tuple[str, ForeachOwnerStack]] = deque([(workflow.start, ())])
visited: set[tuple[str, ForeachOwnerStack]] = set()
visited_nodes: set[str] = set()
while pending:
node_id, stack = pending.popleft()
state = (node_id, stack)
if state in visited:
continue
visited.add(state)
node = nodes_by_id.get(node_id)
if node is None:
continue
visited_nodes.add(node_id)
if node_id in conflicted:
continue
recorded = owner_stack_by_node.get(node_id)
if recorded is None:
owner_stack_by_node[node_id] = stack
elif recorded != stack:
# Same node use reached under two control regions: it has no
# single static owner stack. Drop it so later context analysis
# grants no foreach fields, and stop expanding this ambiguous
# state so the conflict does not cascade.
record_region_conflict(node_id, (recorded, stack))
continue
for edge_index, edge in edges_by_node.get(node_id, []): # type: ignore[attr-defined]
target_id: str = edge.to # type: ignore[attr-defined]
source_is_loop = isinstance(node, ForeachNode) and edge.outcome == "loop" # type: ignore[attr-defined]
if source_is_loop:
if target_id == node_id:
issues.append(
ControlRegionIssue(
kind=ControlRegionIssueKind.EMPTY_FOREACH_BODY,
path=f"edges[{edge_index}]",
message=(
f"foreach {node_id!r} loop targets itself; "
"an iteration body needs a distinct node use"
),
)
)
mark_ambiguous(stack)
continue
target_stack: ForeachOwnerStack = (*stack, node_id)
else:
target_stack = stack
target_node = None if target_id == END else nodes_by_id.get(target_id)
if target_id != END and target_node is None:
# Unknown destinations are owned by ordinary edge validation.
continue
is_terminal = target_id == END or isinstance(target_node, EndNode)
if is_terminal:
# Explicit end nodes are still program locations with one
# static region; record them so they are not also reported as
# unreachable. The `END` token has no node to record.
if isinstance(target_node, EndNode):
visited_nodes.add(target_id)
recorded_target = owner_stack_by_node.get(target_id)
if recorded_target is None:
owner_stack_by_node[target_id] = target_stack
elif recorded_target != target_stack:
record_region_conflict(
target_id, (recorded_target, target_stack)
)
if target_stack:
issues.append(
ControlRegionIssue(
kind=ControlRegionIssueKind.INVALID_FOREACH_TERMINAL,
path=f"edges[{edge_index}]",
message=(
f"foreach item path {node_id!r} -> "
f"{target_id!r} targets a workflow terminal "
"from inside a foreach body"
),
)
)
mark_ambiguous(target_stack)
continue
# At this point target_id is a known non-terminal node id.
if target_stack and target_id == target_stack[-1]:
# Immediate-owner back-edge: the item frame completes at its
# owner without executing the controller again. Resume the
# owner in the popped stack for structural analysis.
resumed: tuple[str, ForeachOwnerStack] = (
target_id,
target_stack[:-1],
)
return_owner_by_source[state] = target_id
add_adjacency(state, resumed)
if resumed not in visited:
pending.append(resumed)
continue
if target_id in target_stack:
issues.append(
ControlRegionIssue(
kind=ControlRegionIssueKind.INVALID_FOREACH_RETURN,
path=f"edges[{edge_index}]",
message=(
f"edge {node_id!r} -> {target_id!r} skips the "
"immediate foreach owner"
),
)
)
mark_ambiguous(target_stack)
continue
successor: tuple[str, ForeachOwnerStack] = (target_id, target_stack)
add_adjacency(state, successor)
pending.append(successor)
for node in workflow.nodes:
if node.id not in visited_nodes:
issues.append(
ControlRegionIssue(
kind=ControlRegionIssueKind.UNREACHABLE_NODE,
path=f"nodes[{node.id}]",
message=f"node {node.id!r} is unreachable from start",
)
)
# Structural returnability: every reached body state needs some graph path
# back to its immediate owner. Data decides whether the exit is taken, so
# one possible path is enough. Skip bodies already made ambiguous by a
# region conflict, invalid return/terminal, or empty body.
tops_with_no_return: set[str] = set()
for node_id, stack in list(visited):
if not stack:
continue
if node_id in conflicted:
continue
top = stack[-1]
if top in ambiguous_tops:
continue
if top in tops_with_no_return:
continue
# Breadth-first search over semantic states for a return to `top`.
seen: set[tuple[str, ForeachOwnerStack]] = set()
queue: deque[tuple[str, ForeachOwnerStack]] = deque([(node_id, stack)])
found = False
while queue:
current = queue.popleft()
if current in seen:
continue
seen.add(current)
if return_owner_by_source.get(current) == top:
found = True
break
for successor in adjacency.get(current, []):
if successor not in seen:
queue.append(successor)
if not found:
tops_with_no_return.add(top)
for top in sorted(tops_with_no_return):
# Only report when the owner itself is unambiguous; a conflicted
# owner has no single region to return to.
if top in conflicted:
continue
if top not in owner_stack_by_node and top not in visited_nodes:
continue
issues.append(
ControlRegionIssue(
kind=ControlRegionIssueKind.FOREACH_BODY_NO_RETURN,
path=f"nodes[{top}]",
message=(
f"foreach {top!r} body has no structural path back to "
"its immediate owner"
),
)
)
return ControlRegionAnalysis(
owner_stack_by_node=dict(owner_stack_by_node),
issues=tuple(issues),
)
+316 -139
View File
@@ -1,16 +1,37 @@
from __future__ import annotations from __future__ import annotations
from dataclasses import dataclass, field from dataclasses import dataclass, field
from typing import Any, Literal from typing import Any, Literal, overload
from wf_core.errors import WorkflowExecutionError from wf_core.errors import WorkflowExecutionError
from wf_core.models.reducers import ReducerRef from wf_core.run_state import ExecutionFrame, RunState
from wf_core.paths import StatePath
from wf_core.run_state import ExecutionFrame, StateWrite
from wf_core.runtime.ops.state import StatePatch
from wf_core.runtime.scheduler import ForeachIterationMetadata from wf_core.runtime.scheduler import ForeachIterationMetadata
_BARRIER_METADATA_KEY = "foreach_barriers" _ACTIVATION_METADATA_KEY = "foreach_activations"
@dataclass(slots=True)
class ForeachActivationState:
"""Persisted state for one dynamic visit to a foreach controller.
A parent frame creates a fresh activation on first entry, reuses it while
admitting items, and closes it before emitting ``done``. The id is opaque:
callers compare it by name and never parse it.
"""
id: str
foreach_node_id: str
barrier: ForeachBarrierState
@dataclass(frozen=True, slots=True)
class ForeachItemOwner:
"""Named ownership record for one foreach item frame."""
parent_frame_id: str
foreach_node_id: str
activation_id: str
item_index: int
@dataclass(slots=True) @dataclass(slots=True)
@@ -68,16 +89,14 @@ class ItemErrorRecord:
class PendingItemResult: class PendingItemResult:
"""Buffered item result waiting for a future foreach barrier commit. """Buffered item result waiting for a future foreach barrier commit.
New concurrent foreach execution stores item writes in `RunState.lineages` Concurrent item writes live in `RunState.lineages`; the barrier keeps
and records `lineage_id` here. `patch` remains for old serialized barrier only the lineage identity per item index.
metadata and direct unit tests that still construct pending patches.
""" """
index: int index: int
frame_id: str frame_id: str
status: Literal["succeeded", "failed"] status: Literal["succeeded", "failed"]
lineage_id: str | None = None lineage_id: str | None = None
patch: StatePatch = field(default_factory=StatePatch)
error: ItemErrorRecord | None = None error: ItemErrorRecord | None = None
@classmethod @classmethod
@@ -92,39 +111,48 @@ class PendingItemResult:
raise WorkflowExecutionError( raise WorkflowExecutionError(
f"malformed pending foreach result missing {exc.args[0]!r}" f"malformed pending foreach result missing {exc.args[0]!r}"
) from exc ) from exc
patch_changes = raw.get("patch_changes", {})
patch_writes = raw.get("patch_writes")
lineage_id = raw.get("lineage_id") lineage_id = raw.get("lineage_id")
raw_error = raw.get("error")
if not isinstance(index, int) or index < 0: if not isinstance(index, int) or index < 0:
raise WorkflowExecutionError("malformed pending foreach result index") raise WorkflowExecutionError("malformed pending foreach result index")
if not isinstance(frame_id, str): if not isinstance(frame_id, str):
raise WorkflowExecutionError("malformed pending foreach result frame id") raise WorkflowExecutionError("malformed pending foreach result frame id")
if lineage_id is not None and not isinstance(lineage_id, str):
raise WorkflowExecutionError("malformed pending foreach result lineage id")
if status not in {"succeeded", "failed"}: if status not in {"succeeded", "failed"}:
raise WorkflowExecutionError("malformed pending foreach result status") raise WorkflowExecutionError("malformed pending foreach result status")
if not isinstance(patch_changes, dict): if status == "succeeded":
raise WorkflowExecutionError("malformed pending foreach result patch") if not isinstance(lineage_id, str):
if patch_writes is not None and not isinstance(patch_writes, list): raise WorkflowExecutionError(
raise WorkflowExecutionError("malformed pending foreach result writes") "malformed pending foreach result lineage id"
raw_error = raw.get("error") )
if raw_error is not None:
raise WorkflowExecutionError(
"malformed pending foreach result: succeeded result must not "
"carry an error"
)
else:
if raw_error is None:
raise WorkflowExecutionError(
"malformed pending foreach result: failed result requires an error"
)
if lineage_id is not None and not isinstance(lineage_id, str):
raise WorkflowExecutionError(
"malformed pending foreach result lineage id"
)
error = (
ItemErrorRecord.from_metadata(raw_error) if raw_error is not None else None
)
if error is not None and (error.index != index or error.frame_id != frame_id):
raise WorkflowExecutionError(
"malformed pending foreach result: error identity "
f"(index {error.index!r}, frame {error.frame_id!r}) does not "
f"match enclosing result (index {index!r}, frame {frame_id!r})"
)
return cls( return cls(
index=index, index=index,
frame_id=frame_id, frame_id=frame_id,
status=status, status=status,
lineage_id=lineage_id, lineage_id=lineage_id,
patch=( error=error,
StatePatch(
writes=[_state_write_from_metadata(item) for item in patch_writes]
)
if patch_writes is not None
else StatePatch(changes=patch_changes)
),
error=(
ItemErrorRecord.from_metadata(raw_error)
if raw_error is not None
else None
),
) )
def to_metadata(self) -> dict[str, Any]: def to_metadata(self) -> dict[str, Any]:
@@ -133,10 +161,6 @@ class PendingItemResult:
"frame_id": self.frame_id, "frame_id": self.frame_id,
"status": self.status, "status": self.status,
"lineage_id": self.lineage_id, "lineage_id": self.lineage_id,
"patch_changes": dict(self.patch.changes),
"patch_writes": [
_state_write_to_metadata(write) for write in self.patch.writes
],
"error": self.error.to_metadata() if self.error is not None else None, "error": self.error.to_metadata() if self.error is not None else None,
} }
@@ -151,33 +175,6 @@ class ForeachBarrierState:
outstanding_frame_ids: tuple[str, ...] = () outstanding_frame_ids: tuple[str, ...] = ()
pending_results: dict[int, PendingItemResult] = field(default_factory=dict) pending_results: dict[int, PendingItemResult] = field(default_factory=dict)
@classmethod
def from_frame(
cls,
frame: ExecutionFrame,
foreach_node_id: str,
) -> ForeachBarrierState | None:
"""Load one foreach barrier state from frame metadata.
Missing metadata means the foreach has not started on this frame yet.
Malformed metadata means runtime state is corrupt and should fail fast.
"""
all_barriers = frame.metadata.get(_BARRIER_METADATA_KEY)
if all_barriers is None:
return None
if not isinstance(all_barriers, dict):
raise WorkflowExecutionError(
f"malformed foreach barrier table for frame {frame.id!r}"
)
raw = all_barriers.get(foreach_node_id)
if raw is None:
return None
if not isinstance(raw, dict):
raise WorkflowExecutionError(
f"malformed foreach barrier state for frame {frame.id!r}"
)
return cls.from_metadata(raw)
@classmethod @classmethod
def from_metadata(cls, raw: object) -> ForeachBarrierState: def from_metadata(cls, raw: object) -> ForeachBarrierState:
if not isinstance(raw, dict): if not isinstance(raw, dict):
@@ -201,7 +198,12 @@ class ForeachBarrierState:
raise WorkflowExecutionError( raise WorkflowExecutionError(
"malformed foreach barrier pending result index" "malformed foreach barrier pending result index"
) from exc ) from exc
parsed_results[index] = PendingItemResult.from_metadata(raw_result) parsed = PendingItemResult.from_metadata(raw_result)
if parsed.index != index:
raise WorkflowExecutionError(
"malformed foreach barrier pending result index mismatch"
)
parsed_results[index] = parsed
return cls( return cls(
next_index=next_index, next_index=next_index,
mode=mode, mode=mode,
@@ -210,20 +212,6 @@ class ForeachBarrierState:
pending_results=parsed_results, pending_results=parsed_results,
) )
def save_to_frame(self, frame: ExecutionFrame, foreach_node_id: str) -> None:
"""Store this barrier state in frame metadata under its foreach node id."""
existing = frame.metadata.get(_BARRIER_METADATA_KEY)
if existing is None:
frame.metadata[_BARRIER_METADATA_KEY] = {
foreach_node_id: self.to_metadata()
}
return
if not isinstance(existing, dict):
raise WorkflowExecutionError(
f"malformed foreach barrier table for frame {frame.id!r}"
)
existing[foreach_node_id] = self.to_metadata()
def to_metadata(self) -> dict[str, Any]: def to_metadata(self) -> dict[str, Any]:
return { return {
"next_index": self.next_index, "next_index": self.next_index,
@@ -266,15 +254,13 @@ class ForeachBarrierState:
*, *,
index: int, index: int,
frame_id: str, frame_id: str,
patch: StatePatch, lineage_id: str,
lineage_id: str | None = None,
) -> None: ) -> None:
"""Buffer or extend successful item patches by item index. """Record one completed concurrent item by lineage identity.
New runtime paths pass an empty patch and use `lineage_id`; legacy Registration is idempotent for the same frame and lineage so the
callers may still accumulate patches here and replay them at the owner back-edge can own it regardless of which operation ran last.
barrier. Do not merge `_prepared_writes`: the barrier replays public Any conflicting identity fails closed.
write records against one staged parent state.
""" """
existing = self.pending_results.get(index) existing = self.pending_results.get(index)
if existing is None: if existing is None:
@@ -283,7 +269,6 @@ class ForeachBarrierState:
frame_id=frame_id, frame_id=frame_id,
status="succeeded", status="succeeded",
lineage_id=lineage_id, lineage_id=lineage_id,
patch=patch,
) )
return return
if existing.frame_id != frame_id: if existing.frame_id != frame_id:
@@ -291,14 +276,11 @@ class ForeachBarrierState:
f"foreach item result for index {index!r} belongs to frame " f"foreach item result for index {index!r} belongs to frame "
f"{existing.frame_id!r}, got {frame_id!r}" f"{existing.frame_id!r}, got {frame_id!r}"
) )
if lineage_id is not None and existing.lineage_id not in {None, lineage_id}: if existing.lineage_id != lineage_id:
raise WorkflowExecutionError( raise WorkflowExecutionError(
f"foreach item result for index {index!r} belongs to lineage " f"foreach item result for index {index!r} belongs to lineage "
f"{existing.lineage_id!r}, got {lineage_id!r}" f"{existing.lineage_id!r}, got {lineage_id!r}"
) )
if existing.lineage_id is None:
existing.lineage_id = lineage_id
existing.patch.extend(patch)
def add_failure(self, *, error: ItemErrorRecord) -> None: def add_failure(self, *, error: ItemErrorRecord) -> None:
"""Buffer one handled item failure for the foreach barrier. """Buffer one handled item failure for the foreach barrier.
@@ -320,14 +302,232 @@ class ForeachBarrierState:
) )
def item_frame_owner(frame: ExecutionFrame) -> tuple[str, str, int] | None: def _activation_entry(
"""Return parent frame id, foreach node id, and item index for item frames.""" frame: ExecutionFrame,
if frame.kind != "foreach_iteration" or frame.parent_frame_id is None: table: dict[str, Any] | None,
foreach_node_id: str,
) -> dict[str, Any] | None:
"""Return the mutable activation entry or fail fast on corrupt state."""
if table is None:
return None return None
entry = table.get(foreach_node_id)
if entry is None:
return None
if not isinstance(entry, dict):
raise WorkflowExecutionError(
f"malformed foreach activation entry for frame {frame.id!r}"
)
return entry
def load_or_begin_foreach_activation(
frame: ExecutionFrame,
foreach_node_id: str,
*,
mode: Literal["serial", "concurrent"],
) -> ForeachActivationState:
"""Load the active activation or begin a fresh visit.
The first entry for one visit allocates an opaque id from the parent frame
id, foreach node id, and a persisted per-frame sequence. Later calls reuse
the active activation; closing it makes the next visit allocate a new id
with fresh barrier state. Mode mismatches and malformed tables fail fast.
"""
table = _activation_table(frame)
entry = _activation_entry(frame, table, foreach_node_id)
if entry is None:
entry = {"next_sequence": 0, "active": None}
table[foreach_node_id] = entry
next_sequence = entry.get("next_sequence", 0)
if not isinstance(next_sequence, int) or next_sequence < 0:
raise WorkflowExecutionError(
f"malformed foreach activation sequence for frame {frame.id!r}"
)
active = entry.get("active")
if active is not None:
activation = _activation_from_metadata(
active, frame_id=frame.id, foreach_node_id=foreach_node_id
)
if activation.barrier.mode != mode:
raise WorkflowExecutionError(
f"foreach {foreach_node_id!r} activation {activation.id!r} "
f"has mode {activation.barrier.mode!r}, got {mode!r}"
)
return activation
activation_id = f"{frame.id}:{foreach_node_id}#{next_sequence}"
activation = ForeachActivationState(
id=activation_id,
foreach_node_id=foreach_node_id,
barrier=ForeachBarrierState(mode=mode),
)
entry["next_sequence"] = next_sequence + 1
entry["active"] = {
"id": activation.id,
"barrier": activation.barrier.to_metadata(),
}
return activation
def save_foreach_activation(
frame: ExecutionFrame, activation: ForeachActivationState
) -> None:
"""Persist barrier progress for the named active activation."""
table = _activation_table(frame, create=False)
entry = _activation_entry(frame, table, activation.foreach_node_id)
if entry is None:
raise WorkflowExecutionError(
f"malformed foreach activation entry for frame {frame.id!r}"
)
active = entry.get("active")
if not isinstance(active, dict) or active.get("id") != activation.id:
raise WorkflowExecutionError(
f"cannot save stale foreach activation {activation.id!r} "
f"for frame {frame.id!r}"
)
active["barrier"] = activation.barrier.to_metadata()
def close_foreach_activation(
frame: ExecutionFrame, activation: ForeachActivationState
) -> None:
"""Close the named active activation, preserving the visit sequence.
The barrier is removed so a later visit starts fresh; the sequence keeps
increasing so child and lineage ids cannot collide across visits.
"""
table = _activation_table(frame, create=False)
entry = _activation_entry(frame, table, activation.foreach_node_id)
if entry is None:
raise WorkflowExecutionError(
f"malformed foreach activation entry for frame {frame.id!r}"
)
active = entry.get("active")
if not isinstance(active, dict) or active.get("id") != activation.id:
raise WorkflowExecutionError(
f"cannot close stale foreach activation {activation.id!r} "
f"for frame {frame.id!r}"
)
entry["active"] = None
def load_foreach_activation(
frame: ExecutionFrame, foreach_node_id: str, activation_id: str
) -> ForeachActivationState | None:
"""Return the active activation only when its id matches the child.
A child result naming a closed or different activation must fail closed in
the caller rather than buffering into the wrong barrier.
This is a read-only lookup: a missing table or entry raises without
mutating frame metadata.
"""
table = _activation_table(frame, create=False)
entry = _activation_entry(frame, table, foreach_node_id)
if entry is None:
raise WorkflowExecutionError(
f"malformed foreach activation entry for frame {frame.id!r}"
)
active = entry.get("active")
if active is None:
return None
activation = _activation_from_metadata(
active, frame_id=frame.id, foreach_node_id=foreach_node_id
)
if activation.id != activation_id:
return None
return activation
def require_foreach_activation(
frame: ExecutionFrame, foreach_node_id: str, activation_id: str
) -> ForeachActivationState:
"""Load the named activation or raise when it is closed or superseded."""
activation = load_foreach_activation(frame, foreach_node_id, activation_id)
if activation is None:
raise WorkflowExecutionError(
f"foreach item activation {activation_id!r} for node "
f"{foreach_node_id!r} is closed or superseded"
)
return activation
def item_frame_owner(frame: ExecutionFrame) -> ForeachItemOwner | None:
"""Return the named foreach ownership record for item frames.
Malformed item metadata fails closed via ``ForeachIterationMetadata``;
only genuinely non-item frames return ``None``. An item frame without
a parent is corrupt state and raises rather than masquerading as an
ordinary frame.
"""
if frame.kind != "foreach_iteration":
return None
if frame.parent_frame_id is None:
raise WorkflowExecutionError(
f"foreach item frame {frame.id!r} is missing its parent frame"
)
metadata = ForeachIterationMetadata.from_frame(frame) metadata = ForeachIterationMetadata.from_frame(frame)
if metadata is None: if metadata is None:
return None return None
return frame.parent_frame_id, metadata.foreach_node_id, metadata.loop_index return ForeachItemOwner(
parent_frame_id=frame.parent_frame_id,
foreach_node_id=metadata.foreach_node_id,
activation_id=metadata.activation_id,
item_index=metadata.loop_index,
)
@overload
def _activation_table(
frame: ExecutionFrame, *, create: Literal[True] = True
) -> dict[str, Any]: ...
@overload
def _activation_table(
frame: ExecutionFrame, *, create: Literal[False]
) -> dict[str, Any] | None: ...
def _activation_table(
frame: ExecutionFrame, *, create: bool = True
) -> dict[str, Any] | None:
"""Return the activation table, optionally creating it.
Read-only lookups pass ``create=False`` so a failed lookup leaves
frame metadata untouched. Only ``load_or_begin`` creates the table.
"""
raw = frame.metadata.get(_ACTIVATION_METADATA_KEY)
if raw is None:
if not create:
return None
table: dict[str, Any] = {}
frame.metadata[_ACTIVATION_METADATA_KEY] = table
return table
if not isinstance(raw, dict):
raise WorkflowExecutionError(
f"malformed foreach activation table for frame {frame.id!r}"
)
return raw
def _activation_from_metadata(
raw: object, *, frame_id: str, foreach_node_id: str
) -> ForeachActivationState:
if not isinstance(raw, dict):
raise WorkflowExecutionError(
f"malformed foreach activation for frame {frame_id!r}"
)
activation_id = raw.get("id")
barrier_raw = raw.get("barrier")
if not isinstance(activation_id, str) or not activation_id:
raise WorkflowExecutionError(
f"malformed foreach activation id for frame {frame_id!r}"
)
return ForeachActivationState(
id=activation_id,
foreach_node_id=foreach_node_id,
barrier=ForeachBarrierState.from_metadata(barrier_raw),
)
def _string_tuple(raw: object) -> tuple[str, ...]: def _string_tuple(raw: object) -> tuple[str, ...]:
@@ -338,53 +538,30 @@ def _string_tuple(raw: object) -> tuple[str, ...]:
raise WorkflowExecutionError("malformed foreach barrier frame id list") raise WorkflowExecutionError("malformed foreach barrier frame id list")
def _state_write_from_metadata(raw: object) -> StateWrite: def register_foreach_item_success(
"""Parse one persisted item-lineage write record. run: RunState, frame: ExecutionFrame, owner: ForeachItemOwner
) -> None:
"""Record one completed concurrent item at its owner back-edge.
Barrier metadata must keep reducer-visible values across interrupt/resume; Registration keys off the returning frame, so it works regardless of
reconstructing from `patch_changes` would downgrade reducer writes to which operation ran last in the item (node, subgraph, or nested
replace-style incoming values. control). Serial items commit through the parent at operation time and
need no barrier entry. A closed or superseded activation fails closed.
""" """
if not isinstance(raw, dict): parent_frame = run.frames.get(owner.parent_frame_id)
raise WorkflowExecutionError("malformed pending foreach write") if parent_frame is None:
try:
path = raw["path"]
incoming_value = raw["incoming_value"]
visible_value = raw["visible_value"]
reducer = raw["reducer"]
except KeyError as exc:
raise WorkflowExecutionError( raise WorkflowExecutionError(
f"malformed pending foreach write missing {exc.args[0]!r}" "foreach lineage state references missing parent frame "
) from exc f"{owner.parent_frame_id!r} for child frame {frame.id!r}"
try:
return StateWrite(
path=_state_path_from_metadata(path),
incoming_value=incoming_value,
visible_value=visible_value,
reducer=ReducerRef.model_validate(reducer),
) )
except WorkflowExecutionError: activation = require_foreach_activation(
raise parent_frame, owner.foreach_node_id, owner.activation_id
except (TypeError, ValueError) as exc: )
raise WorkflowExecutionError(f"malformed pending foreach write: {exc}") from exc if activation.barrier.mode != "concurrent":
return
activation.barrier.add_success_patch(
def _state_write_to_metadata(write: StateWrite) -> dict[str, Any]: index=owner.item_index,
"""Serialize one item-lineage write without relying on dotted display paths.""" frame_id=frame.id,
return { lineage_id=frame.lineage_id,
"path": {"root": "state", "parts": list(write.path.parts)}, )
"incoming_value": write.incoming_value, save_foreach_activation(parent_frame, activation)
"visible_value": write.visible_value,
"reducer": write.reducer.model_dump(mode="json"),
}
def _state_path_from_metadata(raw: object) -> StatePath:
if isinstance(raw, str):
return StatePath.parse(raw)
if not isinstance(raw, dict) or raw.get("root") != "state":
raise WorkflowExecutionError("malformed pending foreach write path")
parts = raw.get("parts")
if not isinstance(parts, list) or not all(isinstance(part, str) for part in parts):
raise WorkflowExecutionError("malformed pending foreach write path")
return StatePath(tuple(parts))
+62 -28
View File
@@ -7,7 +7,6 @@ from typing import Any
from wf_core.errors import WorkflowExecutionError from wf_core.errors import WorkflowExecutionError
from wf_core.run_state import ExecutionFrame, LineageState, RunState, StateWrite from wf_core.run_state import ExecutionFrame, LineageState, RunState, StateWrite
from wf_core.runtime.foreach_state import ForeachBarrierState, item_frame_owner
from wf_core.runtime.ops.state import ( from wf_core.runtime.ops.state import (
StatePatch, StatePatch,
commit_state_patch, commit_state_patch,
@@ -44,41 +43,21 @@ class LineageStateView:
def lineage_writes_for_frame( def lineage_writes_for_frame(
run: RunState, frame: ExecutionFrame run: RunState, frame: ExecutionFrame
) -> Sequence[StateWrite]: ) -> Sequence[StateWrite]:
"""Return writes visible to this frame's current lineage. """Return ancestor and current-lineage writes visible to this frame.
This is still backed by concurrent foreach barrier metadata. Keeping the An empty child lineage still inherits writes buffered by its ancestors, as
lookup here gives future `RunState.lineages` or subgraph scopes one place to happens when an outer concurrent foreach writes before entering an inner
plug in without making node execution understand foreach internals. foreach. Lineage existence and scope therefore control traversal; the
current lineage having its own writes does not.
""" """
lineage = run.lineages.get(frame.lineage_id) lineage = run.lineages.get(frame.lineage_id)
if lineage is not None and lineage.scope_id == frame.scope_id and lineage.writes: if lineage is not None and lineage.scope_id == frame.scope_id:
return tuple( return tuple(
lineage_state_writes( lineage_state_writes(
run, scope_id=frame.scope_id, lineage_id=frame.lineage_id run, scope_id=frame.scope_id, lineage_id=frame.lineage_id
) )
) )
return ()
# Compatibility fallback: concurrent foreach used barrier-local patches
# before `RunState.lineages` became the primary write store. Keep reading
# those patches so old serialized runs and direct barrier tests still work.
owner = item_frame_owner(frame)
if owner is None:
return ()
parent_frame_id, foreach_node_id, item_index = owner
parent_frame = run.frames.get(parent_frame_id)
if parent_frame is None:
raise WorkflowExecutionError(
"foreach lineage compatibility state references missing parent frame "
f"{parent_frame_id!r} for child frame {frame.id!r}"
)
barrier = ForeachBarrierState.from_frame(parent_frame, foreach_node_id)
if barrier is None or barrier.mode != "concurrent":
return ()
pending = barrier.pending_results.get(item_index)
if pending is None:
return ()
return pending.patch.writes
def is_scope_root_lineage_frame(run: RunState, frame: ExecutionFrame) -> bool: def is_scope_root_lineage_frame(run: RunState, frame: ExecutionFrame) -> bool:
@@ -111,6 +90,61 @@ def commit_patch_for_frame(
return {} return {}
def commit_foreach_aware_patch(
run: RunState, frame: ExecutionFrame, patch: StatePatch
) -> dict[str, Any]:
"""Commit one write patch with foreach-aware routing.
Ordinary frames commit (or buffer) through their own lineage. The walk
climbs through every serial item owner until it reaches either the
workflow/subgraph scope root, where it commits, or a concurrent item
boundary, where it buffers in that item lineage for the barrier to
merge. The whole ancestry is validated first: the write lands only
after the chain reaches an acyclic non-item ancestor, so a parent
cycle fails closed even when it passes through a concurrent
boundary. Malformed ownership, missing parents, parent cycles, and
closed or superseded activations fail closed.
"""
from wf_core.runtime.foreach_state import (
item_frame_owner,
require_foreach_activation,
)
current = frame
seen: set[str] = set()
buffer_in: ExecutionFrame | None = None
while True:
owner = item_frame_owner(current)
if owner is None:
break
if current.id in seen:
raise WorkflowExecutionError(
f"cycle detected in foreach parent chain at frame {current.id!r}"
)
seen.add(current.id)
parent_frame = run.frames.get(owner.parent_frame_id)
if parent_frame is None:
raise WorkflowExecutionError(
"foreach item state references missing parent frame "
f"{owner.parent_frame_id!r} for child frame {current.id!r}"
)
activation = require_foreach_activation(
parent_frame, owner.foreach_node_id, owner.activation_id
)
if buffer_in is None and activation.barrier.mode == "concurrent":
buffer_in = current
current = parent_frame
if buffer_in is not None:
append_lineage_writes(
run,
scope_id=buffer_in.scope_id,
lineage_id=buffer_in.lineage_id,
writes=patch.writes,
)
return {}
return commit_patch_for_frame(run, current, patch)
def scope_state_for_frame(run: RunState, frame: ExecutionFrame) -> dict[str, Any]: def scope_state_for_frame(run: RunState, frame: ExecutionFrame) -> dict[str, Any]:
"""Return the committed state root for the frame's runtime scope.""" """Return the committed state root for the frame's runtime scope."""
scope = run.scopes.get(frame.scope_id) scope = run.scopes.get(frame.scope_id)
+65
View File
@@ -2,6 +2,7 @@ from __future__ import annotations
from typing import Any from typing import Any
from wf_core.errors import WorkflowExecutionError
from wf_core.models.workflow import Workflow from wf_core.models.workflow import Workflow
from wf_core.run_state import ( from wf_core.run_state import (
ExecutionFrame, ExecutionFrame,
@@ -76,6 +77,46 @@ def advance_frame(
next_node_id: str, next_node_id: str,
front: bool = False, front: bool = False,
) -> None: ) -> None:
# Foreach back-edge return is an ownership check, not generic cycle
# detection. Only the frame's immediate recorded owner completes the item;
# a root frame targeting the same foreach enters it normally.
from wf_core.runtime.foreach_state import (
item_frame_owner,
register_foreach_item_success,
)
owner = item_frame_owner(frame)
if owner is not None:
if next_node_id == END:
raise WorkflowExecutionError(
f"foreach item frame {frame.id!r} cannot target workflow END; "
f"return to owning foreach {owner.foreach_node_id!r}"
)
if next_node_id == owner.foreach_node_id:
source_node_id = frame.node_id
# Register the completed item with its barrier before completing
# the child, so every final operation (node, subgraph, nested
# control) counts. Closed or superseded activations fail closed.
register_foreach_item_success(run, frame, owner)
frame.prior_outcome = outcome
frame.activated_incoming_edge = source_node_id
frame.node_id = owner.foreach_node_id
frame.status = FrameStatus.COMPLETED
frame.finished_at_node_id = owner.foreach_node_id
# The child does not execute the controller again; the blocked
# parent activation consumes the result and admits the next item
# or emits done. The owner location stays inspectable in trace
# and checkpoint state.
wake_parent_for_child_progress(run, frame.id)
run.sync_from_current_frame()
return
ancestors = _foreach_ancestor_ids(run, frame)
if next_node_id in ancestors[1:]:
raise WorkflowExecutionError(
f"foreach item frame {frame.id!r} targets non-immediate "
f"ancestor {next_node_id!r}; only {owner.foreach_node_id!r} "
"can complete this item"
)
frame.prior_outcome = outcome frame.prior_outcome = outcome
frame.activated_incoming_edge = frame.node_id frame.activated_incoming_edge = frame.node_id
frame.node_id = next_node_id frame.node_id = next_node_id
@@ -93,6 +134,30 @@ def advance_frame(
run.sync_from_current_frame() run.sync_from_current_frame()
def _foreach_ancestor_ids(run: RunState, frame: ExecutionFrame) -> list[str]:
"""Derive active foreach owners from frame ancestry for fail-closed checks.
The first entry is the frame's immediate owner; later entries are older
ancestors. A target naming an older ancestor is a non-local return, while
a target naming an inactive foreach is an ordinary nested entry.
"""
from wf_core.runtime.foreach_state import item_frame_owner
ancestors: list[str] = []
cursor: ExecutionFrame | None = frame
seen: set[str] = set()
while cursor is not None:
owner = item_frame_owner(cursor)
if owner is not None:
if owner.foreach_node_id in seen:
break
seen.add(owner.foreach_node_id)
ancestors.append(owner.foreach_node_id)
parent_id = cursor.parent_frame_id
cursor = run.frames.get(parent_id) if parent_id is not None else None
return ancestors
def finalize_run(workflow: Workflow, run: RunState) -> RunState: def finalize_run(workflow: Workflow, run: RunState) -> RunState:
if run.outcome is None: if run.outcome is None:
run.outcome = "ok" run.outcome = "ok"
+127 -84
View File
@@ -8,13 +8,17 @@ from wf_core.models.steps import ForeachNode
from wf_core.models.workflow import Workflow from wf_core.models.workflow import Workflow
from wf_core.run_state import ExecutionFrame, FrameStatus, RunState, StepExecutionResult from wf_core.run_state import ExecutionFrame, FrameStatus, RunState, StepExecutionResult
from wf_core.runtime.foreach_state import ( from wf_core.runtime.foreach_state import (
ForeachActivationState,
ForeachBarrierState, ForeachBarrierState,
ItemErrorRecord, ItemErrorRecord,
PendingItemResult, PendingItemResult,
close_foreach_activation,
load_or_begin_foreach_activation,
save_foreach_activation,
) )
from wf_core.runtime.lineage import ( from wf_core.runtime.lineage import (
add_lineage, add_lineage,
commit_patch_for_frame, commit_foreach_aware_patch,
lineage_patch, lineage_patch,
scope_input_for_frame, scope_input_for_frame,
) )
@@ -63,7 +67,8 @@ def _step_foreach_serial(
raise WorkflowExecutionError("serial foreach helper received non-serial mode") raise WorkflowExecutionError("serial foreach helper received non-serial mode")
frame = run.current_frame() frame = run.current_frame()
barrier = ForeachBarrierState.from_frame(frame, step.id) or ForeachBarrierState() activation = load_or_begin_foreach_activation(frame, step.id, mode="serial")
barrier = activation.barrier
iterable = _resolve_foreach_iterable(run, frame, step) iterable = _resolve_foreach_iterable(run, frame, step)
loop_index = barrier.next_index loop_index = barrier.next_index
@@ -83,35 +88,24 @@ def _step_foreach_serial(
state_changes={}, state_changes={},
), ),
) )
# Close the visit before following `done` so a self-looping completion
# edge or a later revisit starts a fresh activation.
close_foreach_activation(frame, activation)
advance_frame(run, frame, outcome=outcome, next_node_id=next_node_id) advance_frame(run, frame, outcome=outcome, next_node_id=next_node_id)
return run return run
loop_start = index.next_node_id(frame.node_id, "loop") loop_start = index.next_node_id(frame.node_id, "loop")
item = iterable[loop_index] item = iterable[loop_index]
barrier.next_index = loop_index + 1 loop_start, child_id = _admit_item_frame(
barrier.save_to_frame(frame, step.id) run=run,
child_id = f"{frame.id}:{step.id}:{loop_index}" frame=frame,
child_lineage_id = _child_lineage_id(frame, step, loop_index) step=step,
add_frame( index=index,
run, activation=activation,
ExecutionFrame( loop_index=loop_index,
id=child_id, item=item,
kind="foreach_iteration",
node_id=loop_start,
status=FrameStatus.PENDING,
parent_frame_id=frame.id,
scope_id=frame.scope_id,
lineage_id=child_lineage_id,
parent_lineage_id=frame.lineage_id,
metadata=ForeachIterationMetadata(
foreach_node_id=step.id,
loop_index=loop_index,
loop_item=item,
loop_alias=step.as_,
).to_metadata(),
),
ready=True,
) )
save_foreach_activation(frame, activation)
block_frame_on_children(run, frame.id, (child_id,)) block_frame_on_children(run, frame.id, (child_id,))
append_step_result_trace( append_step_result_trace(
run, run,
@@ -141,11 +135,8 @@ def _step_foreach_concurrent(
if step.concurrent is None: if step.concurrent is None:
raise WorkflowExecutionError("concurrent foreach requires concurrent policy") raise WorkflowExecutionError("concurrent foreach requires concurrent policy")
frame = run.current_frame() frame = run.current_frame()
barrier = ForeachBarrierState.from_frame(frame, step.id) activation = load_or_begin_foreach_activation(frame, step.id, mode="concurrent")
if barrier is None: barrier = activation.barrier
barrier = ForeachBarrierState(mode="concurrent")
elif barrier.mode != "concurrent":
raise WorkflowExecutionError("malformed concurrent foreach barrier mode")
_finish_completed_children(run, step, barrier) _finish_completed_children(run, step, barrier)
iterable = _resolve_foreach_iterable(run, frame, step) iterable = _resolve_foreach_iterable(run, frame, step)
@@ -154,7 +145,7 @@ def _step_foreach_concurrent(
frame=frame, frame=frame,
step=step, step=step,
index=index, index=index,
barrier=barrier, activation=activation,
iterable=iterable, iterable=iterable,
) )
@@ -165,11 +156,11 @@ def _step_foreach_concurrent(
frame=frame, frame=frame,
step=step, step=step,
index=index, index=index,
barrier=barrier, activation=activation,
reducers=reducers, reducers=reducers,
) )
barrier.save_to_frame(frame, step.id) save_foreach_activation(frame, activation)
block_frame_on_children(run, frame.id, barrier.outstanding_frame_ids) block_frame_on_children(run, frame.id, barrier.outstanding_frame_ids)
run.sync_from_current_frame() run.sync_from_current_frame()
return run return run
@@ -236,18 +227,71 @@ def _item_error_record(child: ExecutionFrame) -> ItemErrorRecord:
) )
def _admit_item_frame(
*,
run: RunState,
frame: ExecutionFrame,
step: ForeachNode,
index: WorkflowIndex,
activation: ForeachActivationState,
loop_index: int,
item: object,
) -> tuple[str, str]:
"""Create one activation-qualified child frame and lineage.
Every item owns a lineage so nested subgraph/boundary commits have a
parent lineage to buffer into; top-level serial writes still commit
through the parent scope root. Returns the loop start node and child id;
barrier child bookkeeping stays with the caller. Compare ids by name;
never parse them.
"""
loop_start = index.next_node_id(frame.node_id, "loop")
child_id = _child_frame_id(activation, loop_index)
child_lineage_id = _child_lineage_id(activation, loop_index)
add_lineage(
run,
scope_id=frame.scope_id,
lineage_id=child_lineage_id,
parent_id=frame.lineage_id,
)
activation.barrier.next_index = loop_index + 1
add_frame(
run,
ExecutionFrame(
id=child_id,
kind="foreach_iteration",
node_id=loop_start,
status=FrameStatus.PENDING,
parent_frame_id=frame.id,
scope_id=frame.scope_id,
lineage_id=child_lineage_id,
parent_lineage_id=frame.lineage_id,
metadata=ForeachIterationMetadata(
foreach_node_id=step.id,
activation_id=activation.id,
loop_index=loop_index,
loop_item=item,
loop_alias=step.as_,
).to_metadata(),
),
ready=True,
)
return loop_start, child_id
def _admit_concurrent_children( def _admit_concurrent_children(
*, *,
run: RunState, run: RunState,
frame: ExecutionFrame, frame: ExecutionFrame,
step: ForeachNode, step: ForeachNode,
index: WorkflowIndex, index: WorkflowIndex,
barrier: ForeachBarrierState, activation: ForeachActivationState,
iterable: list[object], iterable: list[object],
) -> None: ) -> None:
if step.concurrent is None: if step.concurrent is None:
raise WorkflowExecutionError("concurrent foreach requires concurrent policy") raise WorkflowExecutionError("concurrent foreach requires concurrent policy")
barrier = activation.barrier
loop_start = index.next_node_id(frame.node_id, "loop") loop_start = index.next_node_id(frame.node_id, "loop")
while ( while (
barrier.next_index < len(iterable) barrier.next_index < len(iterable)
@@ -256,37 +300,17 @@ def _admit_concurrent_children(
): ):
loop_index = barrier.next_index loop_index = barrier.next_index
item = iterable[loop_index] item = iterable[loop_index]
child_id = f"{frame.id}:{step.id}:{loop_index}"
child_lineage_id = _child_lineage_id(frame, step, loop_index)
add_lineage(
run,
scope_id=frame.scope_id,
lineage_id=child_lineage_id,
parent_id=frame.lineage_id,
)
active_count = len(barrier.active_frame_ids) active_count = len(barrier.active_frame_ids)
barrier.next_index = loop_index + 1 loop_start, child_id = _admit_item_frame(
barrier.start_child(child_id) run=run,
add_frame( frame=frame,
run, step=step,
ExecutionFrame( index=index,
id=child_id, activation=activation,
kind="foreach_iteration", loop_index=loop_index,
node_id=loop_start, item=item,
status=FrameStatus.PENDING,
parent_frame_id=frame.id,
scope_id=frame.scope_id,
lineage_id=child_lineage_id,
parent_lineage_id=frame.lineage_id,
metadata=ForeachIterationMetadata(
foreach_node_id=step.id,
loop_index=loop_index,
loop_item=item,
loop_alias=step.as_,
).to_metadata(),
),
ready=True,
) )
barrier.start_child(child_id)
append_step_result_trace( append_step_result_trace(
run, run,
frame_id=frame.id, frame_id=frame.id,
@@ -313,16 +337,22 @@ def _finish_concurrent_foreach(
frame: ExecutionFrame, frame: ExecutionFrame,
step: ForeachNode, step: ForeachNode,
index: WorkflowIndex, index: WorkflowIndex,
barrier: ForeachBarrierState, activation: ForeachActivationState,
reducers: Mapping[str, ReducerDefinition] | None = None, reducers: Mapping[str, ReducerDefinition] | None = None,
) -> RunState: ) -> RunState:
error_records = [ barrier = activation.barrier
result.error.to_metadata() # Coherence is enforced at load, but re-check here: a failed result
for result in sorted( # without an error must never silent-commit as `done`.
barrier.pending_results.values(), key=lambda item: item.index error_records = []
) for result in sorted(barrier.pending_results.values(), key=lambda item: item.index):
if result.status == "failed" and result.error is not None if result.status != "failed":
] continue
if result.error is None:
raise WorkflowExecutionError(
f"foreach item result for index {result.index!r} is failed "
"but carries no error"
)
error_records.append(result.error.to_metadata())
outcome = "completed_with_errors" if error_records else "done" outcome = "completed_with_errors" if error_records else "done"
next_node_id = index.next_node_id(frame.node_id, outcome) next_node_id = index.next_node_id(frame.node_id, outcome)
success_patches = [ success_patches = [
@@ -349,7 +379,7 @@ def _finish_concurrent_foreach(
state_view_for_frame(run, frame), state_view_for_frame(run, frame),
reducers=reducers, reducers=reducers,
) )
state_changes = commit_patch_for_frame(run, frame, combined) state_changes = commit_foreach_aware_patch(run, frame, combined)
append_step_result_trace( append_step_result_trace(
run, run,
frame_id=frame.id, frame_id=frame.id,
@@ -368,17 +398,28 @@ def _finish_concurrent_foreach(
state_changes=state_changes, state_changes=state_changes,
), ),
) )
# Close the visit before following completion so later revisits start fresh.
close_foreach_activation(frame, activation)
advance_frame(run, frame, outcome=outcome, next_node_id=next_node_id) advance_frame(run, frame, outcome=outcome, next_node_id=next_node_id)
return run return run
def _child_lineage_id(frame: ExecutionFrame, step: ForeachNode, loop_index: int) -> str: def _child_frame_id(activation: ForeachActivationState, loop_index: int) -> str:
"""Return a deterministic opaque lineage id for one foreach child frame. """Return a deterministic opaque child frame id for one activation item.
The id embeds the activation so a later visit at item zero cannot collide
with the first visit. Compare full ids; never parse them.
"""
return f"{activation.id}:{loop_index}"
def _child_lineage_id(activation: ForeachActivationState, loop_index: int) -> str:
"""Return a deterministic opaque lineage id for one activation item.
The readable shape is only for diagnostics. Runtime code should compare the The readable shape is only for diagnostics. Runtime code should compare the
full id, not parse it; future structured lineage refs can replace this. full id, not parse it; future structured lineage refs can replace this.
""" """
return f"{frame.lineage_id}/{step.id}[{loop_index}]" return f"{activation.id}[{loop_index}]"
def _patch_for_successful_item( def _patch_for_successful_item(
@@ -388,14 +429,16 @@ def _patch_for_successful_item(
) -> StatePatch: ) -> StatePatch:
"""Return the replayable patch for a completed foreach item. """Return the replayable patch for a completed foreach item.
New concurrent foreach results store writes in `RunState.lineages` and keep Item writes live in `RunState.lineages`; a success without a known
only lineage metadata in the barrier. Old serialized barrier metadata may lineage is corrupt state and fails closed.
still carry `result.patch`, so keep that as the compatibility fallback.
""" """
if result.lineage_id is not None and result.lineage_id in run.lineages: if result.lineage_id is None or result.lineage_id not in run.lineages:
return lineage_patch( raise WorkflowExecutionError(
run, f"foreach item result for index {result.index!r} references "
scope_id=frame.scope_id, f"unknown lineage {result.lineage_id!r}"
lineage_id=result.lineage_id,
) )
return result.patch return lineage_patch(
run,
scope_id=frame.scope_id,
lineage_id=result.lineage_id,
)
+4 -2
View File
@@ -14,7 +14,7 @@ from wf_core.run_state import (
StepExecutionResult, StepExecutionResult,
) )
from wf_core.runtime.input_bindings import resolve_step_input_bindings from wf_core.runtime.input_bindings import resolve_step_input_bindings
from wf_core.runtime.lineage import commit_patch_for_frame from wf_core.runtime.lineage import commit_foreach_aware_patch
from wf_core.runtime.ops.flow import advance_frame, append_step_result_trace from wf_core.runtime.ops.flow import advance_frame, append_step_result_trace
from wf_core.runtime.ops.index import WorkflowIndex from wf_core.runtime.ops.index import WorkflowIndex
from wf_core.runtime.ops.merges import ReducerDefinition from wf_core.runtime.ops.merges import ReducerDefinition
@@ -115,7 +115,9 @@ def resume_interrupt(
reducers=reducers, reducers=reducers,
missing_field_message="interrupt resume payload is missing required field {field}", missing_field_message="interrupt resume payload is missing required field {field}",
) )
state_changes = commit_patch_for_frame(run, frame, patch) # Foreach-aware routing: a serial item resume commits through the parent
# scope, a concurrent one buffers in the item lineage for barrier merge.
state_changes = commit_foreach_aware_patch(run, frame, patch)
next_node_id = index.next_node_id(frame.node_id, resume_outcome) next_node_id = index.next_node_id(frame.node_id, resume_outcome)
append_step_result_trace( append_step_result_trace(
run, run,
+6 -30
View File
@@ -15,18 +15,16 @@ from wf_core.run_state import (
RuntimeContext, RuntimeContext,
StepExecutionResult, StepExecutionResult,
) )
from wf_core.runtime.foreach_state import ForeachBarrierState, item_frame_owner
from wf_core.runtime.input_bindings import resolve_step_input_bindings from wf_core.runtime.input_bindings import resolve_step_input_bindings
from wf_core.runtime.lineage import ( from wf_core.runtime.lineage import (
append_lineage_writes, commit_foreach_aware_patch,
commit_patch_for_frame,
scope_input_for_frame, scope_input_for_frame,
) )
from wf_core.runtime.ops.frames import frame_context_values from wf_core.runtime.ops.frames import frame_context_values
from wf_core.runtime.ops.merges import ReducerDefinition from wf_core.runtime.ops.merges import ReducerDefinition
from wf_core.runtime.ops.overlays import state_view_for_frame from wf_core.runtime.ops.overlays import state_view_for_frame
from wf_core.runtime.ops.schemas import validate_payload_against_schema from wf_core.runtime.ops.schemas import validate_payload_against_schema
from wf_core.runtime.ops.state import StatePatch, build_output_patch from wf_core.runtime.ops.state import build_output_patch
NodeHandler = Callable[[dict[str, Any], RuntimeContext], NodeResult | dict[str, Any]] NodeHandler = Callable[[dict[str, Any], RuntimeContext], NodeResult | dict[str, Any]]
AsyncNodeHandler = Callable[ AsyncNodeHandler = Callable[
@@ -112,32 +110,10 @@ def _finalize_node_execution(
state_view, state_view,
reducers=reducers, reducers=reducers,
) )
owner = item_frame_owner(frame) # Foreach-aware routing (root, serial parent, concurrent lineage) is
if owner is None: # owned by the shared helper so every operation commits the same way.
state_changes = commit_patch_for_frame(run, frame, patch) # Closed or superseded activations fail closed inside.
else: state_changes = commit_foreach_aware_patch(run, frame, patch)
parent_frame_id, foreach_node_id, item_index = owner
parent_frame = run.frames[parent_frame_id]
barrier = ForeachBarrierState.from_frame(parent_frame, foreach_node_id)
if barrier is not None and barrier.mode == "concurrent":
# New concurrent foreach stores writes in the child lineage; the
# barrier keeps only result metadata plus old patch fallback.
append_lineage_writes(
run,
scope_id=frame.scope_id,
lineage_id=frame.lineage_id,
writes=patch.writes,
)
barrier.add_success_patch(
index=item_index,
frame_id=frame.id,
patch=StatePatch(),
lineage_id=frame.lineage_id,
)
barrier.save_to_frame(parent_frame, foreach_node_id)
state_changes = {}
else:
state_changes = commit_patch_for_frame(run, parent_frame, patch)
return StepExecutionResult( return StepExecutionResult(
outcome=result.outcome, outcome=result.outcome,
resolved_input=resolved_input, resolved_input=resolved_input,
+25 -7
View File
@@ -249,6 +249,15 @@ def build_barrier_patch(
committed aggregate values. A barrier trace is the single visible state committed aggregate values. A barrier trace is the single visible state
commit for all buffered item patches, so showing raw per-item incoming commit for all buffered item patches, so showing raw per-item incoming
values would hide what actually landed in `RunState.state`. values would hide what actually landed in `RunState.state`.
The emitted `writes` log keeps every constituent item write in order
instead of one merged write per path. A combined patch buffered in a
lineage can itself be re-merged by an outer barrier, and replaying merged
cumulative values would duplicate whatever was already committed when the
constituents were built. Replaying the original per-item deltas stays
correct at any nesting depth. Each kept write still carries the merged
aggregate as its `visible_value`, so overlay reads and `visible_values`
keep showing the final value.
""" """
state_fields = workflow.state_schema.field_index() state_fields = workflow.state_schema.field_index()
validate_barrier_writes(item_patches, state_fields, reducers=reducers) validate_barrier_writes(item_patches, state_fields, reducers=reducers)
@@ -269,22 +278,31 @@ def build_barrier_patch(
safe_set_nested_value(staged_state, key_path, merged_value) safe_set_nested_value(staged_state, key_path, merged_value)
prepared_patch[destination_path] = (key_path, merged_value) prepared_patch[destination_path] = (key_path, merged_value)
committed_changes[str(destination_path)] = merged_value committed_changes[str(destination_path)] = merged_value
merged_visible = {
destination_path: merged_value
for destination_path, (_key_path, merged_value) in prepared_patch.items()
}
writes = [ writes = [
StateWrite( StateWrite(
path=destination_path, path=write.path,
incoming_value=merged_value, incoming_value=write.incoming_value,
visible_value=merged_value, visible_value=merged_visible[write.path],
reducer=reducer_for_state_path(destination_path, state_fields), reducer=write.reducer,
) )
for destination_path, (_key_path, merged_value) in prepared_patch.items() for item_patch in item_patches
for write in item_patch.writes
] ]
validate_staged_state_patch(staged_state, prepared_patch, state_fields) validate_staged_state_patch(staged_state, prepared_patch, state_fields)
return StatePatch( combined = StatePatch(
changes=committed_changes,
writes=writes, writes=writes,
_prepared_writes=prepared_patch, _prepared_writes=prepared_patch,
_staged_state=staged_state, _staged_state=staged_state,
) )
# The trace-facing view reports the aggregate, while the replay log above
# intentionally carries per-item deltas (see docstring). Assign it after
# construction: passing both to the constructor requires them to agree.
combined.changes = committed_changes
return combined
def validate_barrier_writes( def validate_barrier_writes(
+39 -2
View File
@@ -38,9 +38,15 @@ class BlockedOnChildren:
@dataclass(slots=True, frozen=True) @dataclass(slots=True, frozen=True)
class ForeachIterationMetadata: class ForeachIterationMetadata:
"""Typed metadata for a foreach iteration frame.""" """Typed metadata for a foreach iteration frame.
``activation_id`` names the dynamic foreach visit that owns this item.
It separates fresh barrier state from earlier visits to the same node use
and must survive checkpoint serialization.
"""
foreach_node_id: str foreach_node_id: str
activation_id: str
loop_index: int loop_index: int
loop_item: Any loop_item: Any
loop_alias: str loop_alias: str
@@ -51,12 +57,17 @@ class ForeachIterationMetadata:
return None return None
metadata = frame.metadata metadata = frame.metadata
foreach_node_id = metadata.get("foreach_node_id") foreach_node_id = metadata.get("foreach_node_id")
activation_id = metadata.get("activation_id")
loop_index = metadata.get("loop_index") loop_index = metadata.get("loop_index")
loop_alias = metadata.get("loop_alias") loop_alias = metadata.get("loop_alias")
if not isinstance(foreach_node_id, str) or not foreach_node_id: if not isinstance(foreach_node_id, str) or not foreach_node_id:
raise WorkflowExecutionError( raise WorkflowExecutionError(
f"malformed foreach node id for frame {frame.id!r}" f"malformed foreach node id for frame {frame.id!r}"
) )
if not isinstance(activation_id, str) or not activation_id:
raise WorkflowExecutionError(
f"malformed foreach activation id for frame {frame.id!r}"
)
if not isinstance(loop_index, int): if not isinstance(loop_index, int):
raise WorkflowExecutionError( raise WorkflowExecutionError(
f"malformed foreach loop index for frame {frame.id!r}" f"malformed foreach loop index for frame {frame.id!r}"
@@ -71,6 +82,7 @@ class ForeachIterationMetadata:
) )
return cls( return cls(
foreach_node_id=foreach_node_id, foreach_node_id=foreach_node_id,
activation_id=activation_id,
loop_index=loop_index, loop_index=loop_index,
loop_item=metadata["loop_item"], loop_item=metadata["loop_item"],
loop_alias=loop_alias, loop_alias=loop_alias,
@@ -79,6 +91,7 @@ class ForeachIterationMetadata:
def to_metadata(self) -> dict[str, object]: def to_metadata(self) -> dict[str, object]:
return { return {
"foreach_node_id": self.foreach_node_id, "foreach_node_id": self.foreach_node_id,
"activation_id": self.activation_id,
"loop_index": self.loop_index, "loop_index": self.loop_index,
"loop_item": self.loop_item, "loop_item": self.loop_item,
"loop_alias": self.loop_alias, "loop_alias": self.loop_alias,
@@ -178,7 +191,11 @@ def wake_parent_if_children_complete(run: RunState, child_frame_id: str) -> None
def wake_parent_for_child_progress(run: RunState, child_frame_id: str) -> None: def wake_parent_for_child_progress(run: RunState, child_frame_id: str) -> None:
"""Wake a blocked parent after one child finishes so it can refill slots.""" """Wake a blocked parent after one child finishes so it can refill slots.
The wake-up includes the foreach activation: a child naming a closed or
superseded activation cannot wake a parent waiting on a later visit.
"""
child = _frame(run, child_frame_id) child = _frame(run, child_frame_id)
parent_id = child.parent_frame_id parent_id = child.parent_frame_id
if parent_id is None: if parent_id is None:
@@ -189,6 +206,26 @@ def wake_parent_for_child_progress(run: RunState, child_frame_id: str) -> None:
block = BlockedOnChildren.from_frame(parent) block = BlockedOnChildren.from_frame(parent)
if block is None or child_frame_id not in block.child_frame_ids: if block is None or child_frame_id not in block.child_frame_ids:
return return
# Lazy import avoids a cycle: foreach_state owns activation persistence on
# top of this scheduler's frame metadata types.
from wf_core.runtime.foreach_state import (
item_frame_owner,
load_foreach_activation,
)
try:
owner = item_frame_owner(child)
except WorkflowExecutionError:
raise
if owner is not None:
activation = load_foreach_activation(
parent, owner.foreach_node_id, owner.activation_id
)
if activation is None:
raise WorkflowExecutionError(
f"foreach item frame {child_frame_id!r} names closed activation "
f"{owner.activation_id!r} and cannot wake parent {parent_id!r}"
)
wake_frame(run, parent_id) wake_frame(run, parent_id)
+21 -8
View File
@@ -16,7 +16,7 @@ from wf_core.models.steps import (
) )
from wf_core.models.workflow import Workflow from wf_core.models.workflow import Workflow
from wf_core.run_state import ExecutionFrame, FrameStatus, RunState, StepExecutionResult from wf_core.run_state import ExecutionFrame, FrameStatus, RunState, StepExecutionResult
from wf_core.runtime.foreach_state import ForeachBarrierState, item_frame_owner from wf_core.runtime.foreach_state import item_frame_owner, load_foreach_activation
from wf_core.runtime.ops.flow import advance_frame, append_step_result_trace from wf_core.runtime.ops.flow import advance_frame, append_step_result_trace
from wf_core.runtime.ops.foreach import step_foreach from wf_core.runtime.ops.foreach import step_foreach
from wf_core.runtime.ops.handlers import ( from wf_core.runtime.ops.handlers import (
@@ -87,6 +87,11 @@ def complete_end_step(
"""Record an explicit workflow terminal and complete the active frame.""" """Record an explicit workflow terminal and complete the active frame."""
result = StepExecutionResult(outcome=outcome) result = StepExecutionResult(outcome=outcome)
frame = run.frames[frame_id] frame = run.frames[frame_id]
if item_frame_owner(frame) is not None:
raise WorkflowExecutionError(
f"foreach item frame {frame.id!r} cannot target explicit end node "
f"{node_id!r}; return to its owning foreach"
)
frame.metadata["workflow_outcome"] = outcome frame.metadata["workflow_outcome"] = outcome
if frame.parent_frame_id is None: if frame.parent_frame_id is None:
run.outcome = outcome run.outcome = outcome
@@ -361,10 +366,15 @@ def _claim_matching_async_item_frames(
index: WorkflowIndex, index: WorkflowIndex,
first_frame: ExecutionFrame, first_frame: ExecutionFrame,
) -> list[ExecutionFrame]: ) -> list[ExecutionFrame]:
"""Claim sibling item frames from the same activation for async batching.
Batching never mixes activations: only frames naming the same parent,
foreach, and activation id run together, preserving deterministic barrier
commits across revisits.
"""
owner = item_frame_owner(first_frame) owner = item_frame_owner(first_frame)
if owner is None: if owner is None:
return [] return []
parent_frame_id, foreach_node_id, _item_index = owner
claimed: list[ExecutionFrame] = [] claimed: list[ExecutionFrame] = []
remaining_ready: list[str] = [] remaining_ready: list[str] = []
for frame_id in run.ready_frame_ids: for frame_id in run.ready_frame_ids:
@@ -373,7 +383,9 @@ def _claim_matching_async_item_frames(
if ( if (
frame.status == FrameStatus.PENDING frame.status == FrameStatus.PENDING
and frame_owner is not None and frame_owner is not None
and frame_owner[:2] == (parent_frame_id, foreach_node_id) and frame_owner.parent_frame_id == owner.parent_frame_id
and frame_owner.foreach_node_id == owner.foreach_node_id
and frame_owner.activation_id == owner.activation_id
and isinstance(index.nodes_by_id.get(frame.node_id), NodeUse) and isinstance(index.nodes_by_id.get(frame.node_id), NodeUse)
): ):
frame.status = FrameStatus.RUNNING frame.status = FrameStatus.RUNNING
@@ -392,14 +404,15 @@ def _can_batch_async_foreach_item(
owner = item_frame_owner(frame) owner = item_frame_owner(frame)
if owner is None: if owner is None:
return False return False
parent_frame_id, foreach_node_id, _item_index = owner parent_frame = run.frames.get(owner.parent_frame_id)
parent_frame = run.frames.get(parent_frame_id)
if parent_frame is None: if parent_frame is None:
return False return False
barrier = ForeachBarrierState.from_frame(parent_frame, foreach_node_id) activation = load_foreach_activation(
parent_frame, owner.foreach_node_id, owner.activation_id
)
return ( return (
barrier is not None activation is not None
and barrier.mode == "concurrent" and activation.barrier.mode == "concurrent"
and isinstance(index.nodes_by_id.get(frame.node_id), NodeUse) and isinstance(index.nodes_by_id.get(frame.node_id), NodeUse)
) )
+5 -2
View File
@@ -17,7 +17,7 @@ from wf_core.run_state import (
StepExecutionResult, StepExecutionResult,
) )
from wf_core.runtime.input_bindings import resolve_step_input_bindings from wf_core.runtime.input_bindings import resolve_step_input_bindings
from wf_core.runtime.lineage import commit_patch_for_frame from wf_core.runtime.lineage import commit_foreach_aware_patch
from wf_core.runtime.ops.frames import frame_context_values from wf_core.runtime.ops.frames import frame_context_values
from wf_core.runtime.ops.merges import ReducerDefinition from wf_core.runtime.ops.merges import ReducerDefinition
from wf_core.runtime.ops.overlays import state_view_for_frame from wf_core.runtime.ops.overlays import state_view_for_frame
@@ -236,7 +236,10 @@ def _finish_subgraph(
reducers=reducers, reducers=reducers,
missing_field_message="subgraph output did not include required field {field}", missing_field_message="subgraph output did not include required field {field}",
) )
state_changes = commit_patch_for_frame(run, frame, patch) # Foreach-aware routing (root, serial parent, concurrent lineage) is
# owned by the shared helper so subgraph output commits exactly like
# node output. Closed or superseded activations fail closed inside.
state_changes = commit_foreach_aware_patch(run, frame, patch)
return StepExecutionResult( return StepExecutionResult(
outcome=child_outcome, outcome=child_outcome,
resolved_input=activation.child_input, resolved_input=activation.child_input,
+14
View File
@@ -25,6 +25,14 @@ from wf_core.validation.steps import (
def validate_workflow(workflow: Workflow) -> ValidationReport: def validate_workflow(workflow: Workflow) -> ValidationReport:
"""Coordinate structural validation including foreach control regions.
Ordinary node/edge checks run first; the pure control-region analysis runs
once afterwards and its diagnostics are translated verbatim. No second
graph traversal lives inside validation.
"""
from wf_core.analysis.control_regions import analyze_control_regions
report = ValidationReport() report = ValidationReport()
node_defs = _collect_node_defs(workflow, report) node_defs = _collect_node_defs(workflow, report)
@@ -33,6 +41,12 @@ def validate_workflow(workflow: Workflow) -> ValidationReport:
_validate_start(workflow, nodes_by_id, report) _validate_start(workflow, nodes_by_id, report)
outgoing = _validate_edges(workflow, nodes_by_id, node_defs, report) outgoing = _validate_edges(workflow, nodes_by_id, node_defs, report)
_validate_reachable_outcomes(workflow, nodes_by_id, node_defs, outgoing, report) _validate_reachable_outcomes(workflow, nodes_by_id, node_defs, outgoing, report)
for issue in analyze_control_regions(workflow).issues:
report.add(
ValidationIssueCode(issue.kind.value),
issue.path,
issue.message,
)
return report return report
+6
View File
@@ -26,6 +26,12 @@ class ValidationIssueCode(StrEnum):
INVALID_FOREACH_COLLECT_DESTINATION = "invalid_foreach_collect_destination" INVALID_FOREACH_COLLECT_DESTINATION = "invalid_foreach_collect_destination"
INVALID_INTERRUPT_SOURCE = "invalid_interrupt_source" INVALID_INTERRUPT_SOURCE = "invalid_interrupt_source"
INVALID_INTERRUPT_DESTINATION = "invalid_interrupt_destination" INVALID_INTERRUPT_DESTINATION = "invalid_interrupt_destination"
UNREACHABLE_NODE = "unreachable_node"
FOREACH_REGION_CONFLICT = "foreach_region_conflict"
INVALID_FOREACH_RETURN = "invalid_foreach_return"
INVALID_FOREACH_TERMINAL = "invalid_foreach_terminal"
EMPTY_FOREACH_BODY = "empty_foreach_body"
FOREACH_BODY_NO_RETURN = "foreach_body_no_return"
@dataclass(slots=True) @dataclass(slots=True)
+5 -3
View File
@@ -505,14 +505,16 @@ def test_adapter_lowers_foreach_policy_through_builder() -> None:
"collect_to": "state.item_errors", "collect_to": "state.item_errors",
}, },
} }
} },
"echo": {"use": "demo.echo"},
}, },
"routes": { "routes": {
"each_item": { "each_item": {
"loop": "__end__", "loop": "echo",
"done": "__end__", "done": "__end__",
"completed_with_errors": "__end__", "completed_with_errors": "__end__",
} },
"echo": {"ok": "each_item"},
}, },
} }
) )
+13 -5
View File
@@ -322,7 +322,7 @@ def test_workflow_draft_foreach_over_dumps_structural_path() -> None:
}, },
"routes": { "routes": {
"each_item": {"loop": "echo", "done": "__end__"}, "each_item": {"loop": "echo", "done": "__end__"},
"echo": {"ok": "__end__"}, "echo": {"ok": "each_item"},
}, },
} }
) )
@@ -338,6 +338,7 @@ def test_workflow_draft_foreach_accepts_canonical_item_error_policy() -> None:
**_keyed_echo_draft(), **_keyed_echo_draft(),
"start": "each_item", "start": "each_item",
"steps": { "steps": {
**_keyed_echo_draft()["steps"],
"each_item": { "each_item": {
"foreach": { "foreach": {
"over": "state.items", "over": "state.items",
@@ -349,9 +350,12 @@ def test_workflow_draft_foreach_accepts_canonical_item_error_policy() -> None:
"collect_to": "state.item_errors", "collect_to": "state.item_errors",
}, },
} }
} },
},
"routes": {
"each_item": {"loop": "echo", "done": "__end__"},
"echo": {"ok": "each_item"},
}, },
"routes": {"each_item": {"loop": "__end__", "done": "__end__"}},
} }
) )
@@ -372,15 +376,19 @@ def test_workflow_draft_foreach_accepts_item_error_action_string() -> None:
**_keyed_echo_draft(), **_keyed_echo_draft(),
"start": "each_item", "start": "each_item",
"steps": { "steps": {
**_keyed_echo_draft()["steps"],
"each_item": { "each_item": {
"foreach": { "foreach": {
"over": "state.items", "over": "state.items",
"as": "item", "as": "item",
"item_error": "skip", "item_error": "skip",
} }
} },
},
"routes": {
"each_item": {"loop": "echo", "done": "__end__"},
"echo": {"ok": "each_item"},
}, },
"routes": {"each_item": {"loop": "__end__", "done": "__end__"}},
} }
) )
+1 -1
View File
@@ -210,7 +210,7 @@ def build_authoring_demo_workflow():
builder.connect(list_files, "ok", summarize_each) builder.connect(list_files, "ok", summarize_each)
builder.connect(summarize_each, "loop", summarize_one) builder.connect(summarize_each, "loop", summarize_one)
builder.connect(summarize_each, "done", combine_summaries) builder.connect(summarize_each, "done", combine_summaries)
builder.connect(summarize_one, "ok", END) builder.connect(summarize_one, "ok", summarize_each)
builder.connect(combine_summaries, "ok", should_email) builder.connect(combine_summaries, "ok", should_email)
builder.connect(should_email, "true", approve_email) builder.connect(should_email, "true", approve_email)
builder.connect(should_email, "false", skip_email) builder.connect(should_email, "false", skip_email)
+40
View File
@@ -284,6 +284,46 @@ def test_barrier_replays_incoming_values_not_lineage_visible_values() -> None:
assert patch.visible_values["state.number"] == 6 assert patch.visible_values["state.number"] == 6
def test_barrier_combined_patch_remerges_without_duplicating_prefix() -> None:
"""A combined patch re-merged by an outer barrier must not duplicate.
The second barrier is computed after the first aggregate was committed,
so its constituents were built against that prefix. Re-merging must
replay the original per-item deltas, not the cumulative aggregates.
"""
workflow = _workflow(
fields={
"seen": StateField(
type="array",
reducer=ReducerRef(name="wf.std.append"),
)
}
)
first = build_barrier_patch(
workflow,
[
StatePatch(changes={"state.seen": "a"}),
StatePatch(changes={"state.seen": "b"}),
],
{},
)
second = build_barrier_patch(
workflow,
[
StatePatch(changes={"state.seen": "c"}),
StatePatch(changes={"state.seen": "d"}),
],
{"seen": ["a", "b"]},
)
assert second.changes["state.seen"] == ["a", "b", "c", "d"]
remerged = build_barrier_patch(workflow, [first, second], {})
assert remerged.changes["state.seen"] == ["a", "b", "c", "d"]
assert remerged.visible_values["state.seen"] == ["a", "b", "c", "d"]
def test_build_and_commit_patch_matches_apply_output_bindings() -> None: def test_build_and_commit_patch_matches_apply_output_bindings() -> None:
workflow = _workflow(fields={"person.name": StateField(type="string")}) workflow = _workflow(fields={"person.name": StateField(type="string")})
state_from_apply = {"person": {"name": "old"}} state_from_apply = {"person": {"name": "old"}}
+63 -21
View File
@@ -19,7 +19,11 @@ from wf_core import (
execute_workflow, execute_workflow,
) )
from wf_core.run_state import ExecutionFrame, RunState, RuntimeContext from wf_core.run_state import ExecutionFrame, RunState, RuntimeContext
from wf_core.runtime.foreach_state import ForeachBarrierState from wf_core.runtime.foreach_state import (
ForeachItemOwner,
item_frame_owner,
load_or_begin_foreach_activation,
)
from wf_core.runtime.scheduler import ForeachIterationMetadata from wf_core.runtime.scheduler import ForeachIterationMetadata
@@ -143,7 +147,11 @@ def test_concurrent_foreach_item_frames_use_distinct_lineages() -> None:
assert run.frames["root"].lineage_id == "root" assert run.frames["root"].lineage_id == "root"
assert run.frames["root"].parent_lineage_id is None assert run.frames["root"].parent_lineage_id is None
assert len(item_frames) == 2 assert len(item_frames) == 2
assert item_lineage_ids == {"root/each[0]", "root/each[1]"} assert item_lineage_ids == {"root:each#0[0]", "root:each#0[1]"}
for frame in item_frames:
owner = item_frame_owner(frame)
assert isinstance(owner, ForeachItemOwner)
assert owner.activation_id == "root:each#0"
assert set(context_lineage_ids) == item_lineage_ids assert set(context_lineage_ids) == item_lineage_ids
assert all(frame.scope_id == "root" for frame in item_frames) assert all(frame.scope_id == "root" for frame in item_frames)
assert all(frame.parent_lineage_id == "root" for frame in item_frames) assert all(frame.parent_lineage_id == "root" for frame in item_frames)
@@ -162,15 +170,27 @@ def test_nested_concurrent_foreach_records_parent_child_lineages() -> None:
inner_frames = _foreach_frames(run, "inner_each") inner_frames = _foreach_frames(run, "inner_each")
assert {frame.lineage_id for frame in outer_frames} == { assert {frame.lineage_id for frame in outer_frames} == {
"root/outer_each[0]", "root:outer_each#0[0]",
"root/outer_each[1]", "root:outer_each#0[1]",
} }
assert all(frame.parent_lineage_id == "root" for frame in outer_frames) assert all(frame.parent_lineage_id == "root" for frame in outer_frames)
assert {(frame.parent_lineage_id, frame.lineage_id) for frame in inner_frames} == { assert {(frame.parent_lineage_id, frame.lineage_id) for frame in inner_frames} == {
("root/outer_each[0]", "root/outer_each[0]/inner_each[0]"), (
("root/outer_each[0]", "root/outer_each[0]/inner_each[1]"), "root:outer_each#0[0]",
("root/outer_each[1]", "root/outer_each[1]/inner_each[0]"), "root:outer_each#0:0:inner_each#0[0]",
("root/outer_each[1]", "root/outer_each[1]/inner_each[1]"), ),
(
"root:outer_each#0[0]",
"root:outer_each#0:0:inner_each#0[1]",
),
(
"root:outer_each#0[1]",
"root:outer_each#0:1:inner_each#0[0]",
),
(
"root:outer_each#0[1]",
"root:outer_each#0:1:inner_each#0[1]",
),
} }
@@ -264,12 +284,16 @@ def test_sync_concurrent_foreach_barrier_replays_add_reducer_inputs() -> None:
assert run.state["number"] == 6 assert run.state["number"] == 6
assert run.output["number"] == 6 assert run.output["number"] == 6
assert run.lineages["root/each[0]"].writes[0].incoming_value == 3 assert run.lineages["root:each#0[0]"].writes[0].incoming_value == 3
assert run.lineages["root/each[1]"].writes[0].incoming_value == 1 assert run.lineages["root:each#0[1]"].writes[0].incoming_value == 1
barrier = ForeachBarrierState.from_frame(run.frames["root"], "each") # The visit closed before `done`; lineage history remains while a new
assert barrier is not None # load starts fresh barrier state with a new activation id.
assert barrier.pending_results[0].lineage_id == "root/each[0]" fresh = load_or_begin_foreach_activation(
assert barrier.pending_results[0].patch.writes == [] run.frames["root"], "each", mode="concurrent"
)
assert fresh.id == "root:each#1"
assert fresh.barrier.next_index == 0
assert fresh.barrier.pending_results == {}
foreach_entries = [entry for entry in run.trace if entry.step_type == "foreach"] foreach_entries = [entry for entry in run.trace if entry.step_type == "foreach"]
assert foreach_entries[-1].state_changes["state.number"] == 6 assert foreach_entries[-1].state_changes["state.number"] == 6
@@ -385,7 +409,7 @@ def _sum_items_workflow() -> Workflow:
], ],
edges=[ edges=[
Edge.model_validate({"from": "each", "outcome": "loop", "to": "add_item"}), Edge.model_validate({"from": "each", "outcome": "loop", "to": "add_item"}),
Edge.model_validate({"from": "add_item", "outcome": "ok", "to": END}), Edge.model_validate({"from": "add_item", "outcome": "ok", "to": "each"}),
Edge.model_validate({"from": "each", "outcome": "done", "to": END}), Edge.model_validate({"from": "each", "outcome": "done", "to": END}),
], ],
) )
@@ -495,7 +519,7 @@ def _same_item_reducer_visibility_workflow() -> Workflow:
"to": "read_number", "to": "read_number",
} }
), ),
Edge.model_validate({"from": "read_number", "outcome": "ok", "to": END}), Edge.model_validate({"from": "read_number", "outcome": "ok", "to": "each"}),
Edge.model_validate({"from": "each", "outcome": "done", "to": END}), Edge.model_validate({"from": "each", "outcome": "done", "to": END}),
], ],
) )
@@ -571,6 +595,15 @@ def _nested_foreach_lineage_workflow() -> Workflow:
"output": [{"source": "seen", "target": "state.seen"}], "output": [{"source": "seen", "target": "state.seen"}],
} }
), ),
NodeUse.model_validate(
{
"id": "tail",
"type": "node",
"node": "record",
"input": [{"target": "seen", "path": "context.outer"}],
"output": [{"source": "seen", "target": "state.seen"}],
}
),
], ],
edges=[ edges=[
Edge.model_validate( Edge.model_validate(
@@ -587,8 +620,13 @@ def _nested_foreach_lineage_workflow() -> Workflow:
"to": "record", "to": "record",
} }
), ),
Edge.model_validate({"from": "record", "outcome": "ok", "to": END}), Edge.model_validate(
Edge.model_validate({"from": "inner_each", "outcome": "done", "to": END}), {"from": "record", "outcome": "ok", "to": "inner_each"}
),
Edge.model_validate(
{"from": "inner_each", "outcome": "done", "to": "tail"}
),
Edge.model_validate({"from": "tail", "outcome": "ok", "to": "outer_each"}),
Edge.model_validate({"from": "outer_each", "outcome": "done", "to": END}), Edge.model_validate({"from": "outer_each", "outcome": "done", "to": END}),
], ],
) )
@@ -602,7 +640,7 @@ def _workflow(
) -> Workflow: ) -> Workflow:
edges = [ edges = [
Edge.model_validate({"from": "each", "outcome": "loop", "to": "record"}), Edge.model_validate({"from": "each", "outcome": "loop", "to": "record"}),
Edge.model_validate({"from": "record", "outcome": "ok", "to": END}), Edge.model_validate({"from": "record", "outcome": "ok", "to": "each"}),
Edge.model_validate({"from": "each", "outcome": "done", "to": END}), Edge.model_validate({"from": "each", "outcome": "done", "to": END}),
] ]
if include_completed_with_errors: if include_completed_with_errors:
@@ -772,7 +810,9 @@ def _multi_step_overlay_workflow() -> Workflow:
"to": "read_scratch", "to": "read_scratch",
} }
), ),
Edge.model_validate({"from": "read_scratch", "outcome": "ok", "to": END}), Edge.model_validate(
{"from": "read_scratch", "outcome": "ok", "to": "each"}
),
Edge.model_validate({"from": "each", "outcome": "done", "to": END}), Edge.model_validate({"from": "each", "outcome": "done", "to": END}),
], ],
) )
@@ -839,7 +879,9 @@ def _same_path_replace_workflow() -> Workflow:
"to": "write_winner", "to": "write_winner",
} }
), ),
Edge.model_validate({"from": "write_winner", "outcome": "ok", "to": END}), Edge.model_validate(
{"from": "write_winner", "outcome": "ok", "to": "each"}
),
Edge.model_validate({"from": "each", "outcome": "done", "to": END}), Edge.model_validate({"from": "each", "outcome": "done", "to": END}),
], ],
) )
+1 -1
View File
@@ -123,7 +123,7 @@ def _workflow(*, max_active: int) -> Workflow:
], ],
edges=[ edges=[
Edge.model_validate({"from": "each", "outcome": "loop", "to": "record"}), Edge.model_validate({"from": "each", "outcome": "loop", "to": "record"}),
Edge.model_validate({"from": "record", "outcome": "ok", "to": END}), Edge.model_validate({"from": "record", "outcome": "ok", "to": "each"}),
Edge.model_validate({"from": "each", "outcome": "done", "to": END}), Edge.model_validate({"from": "each", "outcome": "done", "to": END}),
], ],
) )
+3 -3
View File
@@ -27,7 +27,7 @@ def test_concurrent_foreach_skip_emits_completed_with_errors() -> None:
) )
assert run.state["seen"] == ["a", "c"] assert run.state["seen"] == ["a", "c"]
assert run.frames["root:each:1"].status == "failed" assert run.frames["root:each#0:1"].status == "failed"
foreach_entries = [entry for entry in run.trace if entry.step_type == "foreach"] foreach_entries = [entry for entry in run.trace if entry.step_type == "foreach"]
assert foreach_entries[-1].outcome == "completed_with_errors" assert foreach_entries[-1].outcome == "completed_with_errors"
assert foreach_entries[-1].resolved_input["failed_items"] == 1 assert foreach_entries[-1].resolved_input["failed_items"] == 1
@@ -47,7 +47,7 @@ def test_concurrent_foreach_collect_writes_ordered_error_records() -> None:
assert len(run.state["errors"]) == 1 assert len(run.state["errors"]) == 1
error = run.state["errors"][0] error = run.state["errors"][0]
assert error["index"] == 1 assert error["index"] == 1
assert error["frame_id"] == "root:each:1" assert error["frame_id"] == "root:each#0:1"
assert error["node_id"] == "record" assert error["node_id"] == "record"
assert error["error_type"] == "ValueError" assert error["error_type"] == "ValueError"
assert error["message"] == "bad item" assert error["message"] == "bad item"
@@ -144,7 +144,7 @@ def _workflow(*, item_error: dict[str, object]) -> Workflow:
], ],
edges=[ edges=[
Edge.model_validate({"from": "each", "outcome": "loop", "to": "record"}), Edge.model_validate({"from": "each", "outcome": "loop", "to": "record"}),
Edge.model_validate({"from": "record", "outcome": "ok", "to": END}), Edge.model_validate({"from": "record", "outcome": "ok", "to": "each"}),
Edge.model_validate({"from": "each", "outcome": "done", "to": END}), Edge.model_validate({"from": "each", "outcome": "done", "to": END}),
Edge.model_validate( Edge.model_validate(
{ {
@@ -31,8 +31,8 @@ async def test_concurrent_foreach_interrupt_returns_before_refill() -> None:
assert run.status is RunStatus.INTERRUPTED assert run.status is RunStatus.INTERRUPTED
assert run.interrupt is not None assert run.interrupt is not None
assert run.interrupt.payload["item"] == "b" assert run.interrupt.payload["item"] == "b"
assert run.frames["root:each:1"].status == "interrupted" assert run.frames["root:each#0:1"].status == "interrupted"
assert "root:each:2" not in run.frames assert "root:each#0:2" not in run.frames
assert "seen" not in run.state assert "seen" not in run.state
@@ -52,9 +52,16 @@ async def test_resume_prioritizes_interrupted_item_before_siblings() -> None:
resume_payload={}, resume_payload={},
) )
from wf_core.runtime.foreach_state import item_frame_owner
interrupted_owner = item_frame_owner(run.frames["root:each#0:1"])
assert interrupted_owner is not None
assert resumed.status is RunStatus.COMPLETED assert resumed.status is RunStatus.COMPLETED
assert resumed.state["seen"] == ["a", "b", "c"] assert resumed.state["seen"] == ["a", "b", "c"]
assert resumed.trace[interrupted_trace_len].frame_id == "root:each:1" resumed_owner = item_frame_owner(resumed.frames["root:each#0:1"])
assert resumed_owner is not None
assert resumed_owner.activation_id == interrupted_owner.activation_id
assert resumed.trace[interrupted_trace_len].frame_id == "root:each#0:1"
assert resumed.trace[interrupted_trace_len].step_type == "interrupt" assert resumed.trace[interrupted_trace_len].step_type == "interrupt"
assert resumed.trace[interrupted_trace_len].outcome == "submitted" assert resumed.trace[interrupted_trace_len].outcome == "submitted"
foreach_entries = [entry for entry in resumed.trace if entry.step_type == "foreach"] foreach_entries = [entry for entry in resumed.trace if entry.step_type == "foreach"]
@@ -132,7 +139,7 @@ def _workflow() -> Workflow:
], ],
edges=[ edges=[
Edge.model_validate({"from": "each", "outcome": "loop", "to": "route"}), Edge.model_validate({"from": "each", "outcome": "loop", "to": "route"}),
Edge.model_validate({"from": "route", "outcome": "ok", "to": END}), Edge.model_validate({"from": "route", "outcome": "ok", "to": "each"}),
Edge.model_validate( Edge.model_validate(
{ {
"from": "route", "from": "route",
@@ -140,7 +147,7 @@ def _workflow() -> Workflow:
"to": "ask", "to": "ask",
} }
), ),
Edge.model_validate({"from": "ask", "outcome": "submitted", "to": END}), Edge.model_validate({"from": "ask", "outcome": "submitted", "to": "each"}),
Edge.model_validate({"from": "each", "outcome": "done", "to": END}), Edge.model_validate({"from": "each", "outcome": "done", "to": END}),
], ],
) )
+22 -12
View File
@@ -146,7 +146,7 @@ def test_serial_and_concurrent_foreach_expose_the_same_scoped_context() -> None:
edges=[ edges=[
{"from": "each", "outcome": "loop", "to": "body"}, {"from": "each", "outcome": "loop", "to": "body"},
{"from": "each", "outcome": "done", "to": "tail"}, {"from": "each", "outcome": "done", "to": "tail"},
{"from": "body", "outcome": "ok", "to": END}, {"from": "body", "outcome": "ok", "to": "each"},
{"from": "tail", "outcome": "ok", "to": END}, {"from": "tail", "outcome": "ok", "to": END},
], ],
) )
@@ -164,7 +164,7 @@ def test_foreach_item_schema_and_configured_alias_are_reported() -> None:
nodes=[_foreach("each", alias="record"), _node("body")], nodes=[_foreach("each", alias="record"), _node("body")],
edges=[ edges=[
{"from": "each", "outcome": "loop", "to": "body"}, {"from": "each", "outcome": "loop", "to": "body"},
{"from": "body", "outcome": "ok", "to": END}, {"from": "body", "outcome": "ok", "to": "each"},
{"from": "each", "outcome": "done", "to": END}, {"from": "each", "outcome": "done", "to": END},
], ],
) )
@@ -181,7 +181,7 @@ def test_foreach_item_schema_resolves_bounded_local_array_reference() -> None:
nodes=[_foreach("each", alias="record"), _node("body")], nodes=[_foreach("each", alias="record"), _node("body")],
edges=[ edges=[
{"from": "each", "outcome": "loop", "to": "body"}, {"from": "each", "outcome": "loop", "to": "body"},
{"from": "body", "outcome": "ok", "to": END}, {"from": "body", "outcome": "ok", "to": "each"},
{"from": "each", "outcome": "done", "to": END}, {"from": "each", "outcome": "done", "to": END},
], ],
state_schema={ state_schema={
@@ -209,7 +209,7 @@ def test_foreach_item_schema_resolves_bounded_local_array_reference() -> None:
assert fields["record"].contract.schema["properties"] == {"id": {"type": "string"}} assert fields["record"].contract.schema["properties"] == {"id": {"type": "string"}}
def test_only_foreach_reachable_node_has_available_context() -> None: def test_region_conflicted_node_receives_no_guaranteed_foreach_fields() -> None:
workflow = _workflow( workflow = _workflow(
start="start", start="start",
nodes=[_node("start"), _foreach("each", alias="item"), _node("body")], nodes=[_node("start"), _foreach("each", alias="item"), _node("body")],
@@ -218,12 +218,18 @@ def test_only_foreach_reachable_node_has_available_context() -> None:
{"from": "start", "outcome": "loop", "to": "each"}, {"from": "start", "outcome": "loop", "to": "each"},
{"from": "each", "outcome": "loop", "to": "body"}, {"from": "each", "outcome": "loop", "to": "body"},
{"from": "each", "outcome": "done", "to": END}, {"from": "each", "outcome": "done", "to": END},
{"from": "body", "outcome": "ok", "to": END}, {"from": "body", "outcome": "ok", "to": "each"},
], ],
) )
assert _field_map(workflow, "body")["item"].availability == "conditional" fields = context_fields_by_node(workflow)
assert _field_map(workflow, "body")["item"].reason assert "body" not in fields or "item" not in {
field.contract.name for field in fields.get("body", ())
}
warnings = context_analysis_warnings(workflow)
assert any(
"control region" in warning or "conflict" in warning for warning in warnings
)
def test_nested_foreach_replaces_inner_scope_and_restores_outer_scope() -> None: def test_nested_foreach_replaces_inner_scope_and_restores_outer_scope() -> None:
@@ -239,8 +245,8 @@ def test_nested_foreach_replaces_inner_scope_and_restores_outer_scope() -> None:
{"from": "outer", "outcome": "loop", "to": "inner"}, {"from": "outer", "outcome": "loop", "to": "inner"},
{"from": "inner", "outcome": "loop", "to": "inner_body"}, {"from": "inner", "outcome": "loop", "to": "inner_body"},
{"from": "inner", "outcome": "done", "to": "after_inner"}, {"from": "inner", "outcome": "done", "to": "after_inner"},
{"from": "inner_body", "outcome": "ok", "to": END}, {"from": "inner_body", "outcome": "ok", "to": "inner"},
{"from": "after_inner", "outcome": "ok", "to": END}, {"from": "after_inner", "outcome": "ok", "to": "outer"},
{"from": "outer", "outcome": "done", "to": END}, {"from": "outer", "outcome": "done", "to": END},
], ],
state_schema={ state_schema={
@@ -267,12 +273,14 @@ def test_nested_foreach_preserves_context_backed_item_schema() -> None:
_foreach("outer", alias="outer_item"), _foreach("outer", alias="outer_item"),
_foreach("inner", alias="inner_item", over="context.outer_item"), _foreach("inner", alias="inner_item", over="context.outer_item"),
_node("inner_body"), _node("inner_body"),
_node("after_inner"),
], ],
edges=[ edges=[
{"from": "outer", "outcome": "loop", "to": "inner"}, {"from": "outer", "outcome": "loop", "to": "inner"},
{"from": "inner", "outcome": "loop", "to": "inner_body"}, {"from": "inner", "outcome": "loop", "to": "inner_body"},
{"from": "inner", "outcome": "done", "to": END}, {"from": "inner", "outcome": "done", "to": "after_inner"},
{"from": "inner_body", "outcome": "ok", "to": END}, {"from": "inner_body", "outcome": "ok", "to": "inner"},
{"from": "after_inner", "outcome": "ok", "to": "outer"},
{"from": "outer", "outcome": "done", "to": END}, {"from": "outer", "outcome": "done", "to": END},
], ],
state_schema={ state_schema={
@@ -350,4 +358,6 @@ def test_scoped_cycle_terminates_and_preserves_scoped_field_availability() -> No
fields = context_fields_by_node(workflow) fields = context_fields_by_node(workflow)
assert fields["body"] assert fields["body"]
assert _field_map(workflow, "body")["item"].availability == "available" assert _field_map(workflow, "body")["item"].availability == "available"
assert _field_map(workflow, "each")["item"].availability == "conditional" # A canonical back-edge pops the item stack, so the controller itself
# stays in the outer region and exposes no item alias.
assert "item" not in _field_map(workflow, "each")
+130
View File
@@ -0,0 +1,130 @@
from __future__ import annotations
import pytest
from wf_core.errors import WorkflowExecutionError
from wf_core.run_state import ExecutionFrame
from wf_core.runtime.foreach_state import (
ForeachActivationState,
ForeachBarrierState,
close_foreach_activation,
item_frame_owner,
load_foreach_activation,
load_or_begin_foreach_activation,
save_foreach_activation,
)
from wf_core.runtime.scheduler import ForeachIterationMetadata
def _frame() -> ExecutionFrame:
return ExecutionFrame(id="root", kind="workflow", node_id="each")
def test_activation_lifecycle_reuses_active_then_fresh_after_close() -> None:
frame = _frame()
first = load_or_begin_foreach_activation(frame, "each", mode="serial")
save_foreach_activation(frame, first)
restored = load_or_begin_foreach_activation(frame, "each", mode="serial")
assert restored.id == first.id
close_foreach_activation(frame, restored)
second = load_or_begin_foreach_activation(frame, "each", mode="serial")
assert second.id != first.id
assert second.barrier.next_index == 0
def test_activation_rejects_malformed_metadata() -> None:
frame = ExecutionFrame(
id="root",
kind="workflow",
node_id="each",
metadata={"foreach_activations": "corrupt"},
)
with pytest.raises(WorkflowExecutionError, match="activation"):
load_or_begin_foreach_activation(frame, "each", mode="serial")
def test_activation_rejects_mode_mismatch() -> None:
frame = _frame()
activation = load_or_begin_foreach_activation(frame, "each", mode="serial")
save_foreach_activation(frame, activation)
with pytest.raises(WorkflowExecutionError, match="mode"):
load_or_begin_foreach_activation(frame, "each", mode="concurrent")
def test_closing_stale_activation_fails_closed() -> None:
frame = _frame()
first = load_or_begin_foreach_activation(frame, "each", mode="serial")
save_foreach_activation(frame, first)
close_foreach_activation(frame, first)
second = load_or_begin_foreach_activation(frame, "each", mode="serial")
save_foreach_activation(frame, second)
with pytest.raises(WorkflowExecutionError, match="stale|closed|active"):
close_foreach_activation(frame, first)
def test_activation_json_round_trip_through_frame_metadata() -> None:
frame = _frame()
activation = load_or_begin_foreach_activation(frame, "each", mode="serial")
activation.barrier.next_index = 2
save_foreach_activation(frame, activation)
dumped = dict(frame.metadata)
restored_frame = ExecutionFrame(
id="root", kind="workflow", node_id="each", metadata=dumped
)
restored = load_or_begin_foreach_activation(restored_frame, "each", mode="serial")
assert restored.id == activation.id
assert restored.barrier.next_index == 2
def test_item_metadata_requires_activation_identity() -> None:
frame = ExecutionFrame(
id="root:each#0:0",
kind="foreach_iteration",
node_id="work",
parent_frame_id="root",
metadata={
"foreach_node_id": "each",
"loop_index": 0,
"loop_item": "a",
"loop_alias": "item",
},
)
with pytest.raises(WorkflowExecutionError, match="activation"):
ForeachIterationMetadata.from_frame(frame)
with pytest.raises(WorkflowExecutionError, match="activation"):
item_frame_owner(frame)
def test_failed_activation_lookup_leaves_metadata_untouched() -> None:
"""Read-only lookups must not create the activation table on failure."""
frame = _frame()
stale = ForeachActivationState(
id="root:each#0",
foreach_node_id="each",
barrier=ForeachBarrierState(mode="serial"),
)
with pytest.raises(WorkflowExecutionError, match="activation"):
load_foreach_activation(frame, "each", "root:each#0")
with pytest.raises(WorkflowExecutionError, match="activation"):
save_foreach_activation(frame, stale)
with pytest.raises(WorkflowExecutionError, match="activation"):
close_foreach_activation(frame, stale)
assert frame.metadata == {}
# The write path still creates the table exactly once.
activation = load_or_begin_foreach_activation(frame, "each", mode="serial")
assert frame.metadata["foreach_activations"]["each"]["active"]["id"] == (
activation.id
)
File diff suppressed because it is too large Load Diff
+225 -175
View File
@@ -1,107 +1,83 @@
from __future__ import annotations from __future__ import annotations
from copy import deepcopy
import pytest import pytest
from wf_core.errors import WorkflowExecutionError from wf_core.errors import WorkflowExecutionError
from wf_core.models.reducers import ReducerRef from wf_core.models.reducers import ReducerRef
from wf_core.paths import StatePath from wf_core.paths import StatePath
from wf_core.run_state import ExecutionFrame, RunState, RunStatus, StateWrite from wf_core.run_state import (
ExecutionFrame,
LineageState,
RunState,
RunStatus,
RuntimeScope,
StateWrite,
)
from wf_core.runtime.foreach_state import ( from wf_core.runtime.foreach_state import (
ForeachBarrierState, ForeachBarrierState,
ForeachItemOwner,
ItemErrorRecord, ItemErrorRecord,
PendingItemResult, PendingItemResult,
_state_write_from_metadata, item_frame_owner,
load_foreach_activation,
load_or_begin_foreach_activation,
save_foreach_activation,
)
from wf_core.runtime.lineage import (
LineageStateView,
add_lineage,
append_lineage_writes,
lineage_writes_for_frame,
) )
from wf_core.runtime.lineage import LineageStateView, lineage_writes_for_frame
from wf_core.runtime.ops.state import StatePatch
def test_foreach_barrier_state_round_trips_through_frame_metadata() -> None: def test_foreach_barrier_state_round_trips_through_activation_metadata() -> None:
frame = ExecutionFrame(id="root", kind="root", node_id="each") frame = ExecutionFrame(id="root", kind="root", node_id="each")
barrier = ForeachBarrierState( activation = load_or_begin_foreach_activation(frame, "each", mode="serial")
next_index=2, activation.barrier.next_index = 2
active_frame_ids=("child-1",), activation.barrier.start_child("child-1")
outstanding_frame_ids=("child-1", "child-2"), activation.barrier.start_child("child-2")
pending_results={ activation.barrier.finish_child("child-2")
1: PendingItemResult( activation.barrier.add_failure(
index=1, error=ItemErrorRecord(
frame_id="child-1", index=1,
status="failed", frame_id="child-1",
patch=StatePatch(changes={"state.count": 1}), node_id="work",
error=ItemErrorRecord( error_type="ValueError",
index=1, message="bad item",
frame_id="child-1", item={"id": "a"},
node_id="work",
error_type="ValueError",
message="bad item",
item={"id": "a"},
),
)
},
)
barrier.save_to_frame(frame, "each")
loaded = ForeachBarrierState.from_frame(frame, "each")
assert loaded is not None
assert loaded.next_index == 2
assert loaded.active_frame_ids == ("child-1",)
assert loaded.outstanding_frame_ids == ("child-1", "child-2")
assert loaded.pending_results[1].patch.changes["state.count"] == 1
assert loaded.pending_results[1].error is not None
assert loaded.pending_results[1].error.message == "bad item"
def test_foreach_barrier_state_round_trips_reducer_write_records() -> None:
frame = ExecutionFrame(id="root", kind="root", node_id="each")
barrier = ForeachBarrierState(
next_index=1,
mode="concurrent",
pending_results={
0: PendingItemResult(
index=0,
frame_id="child-0",
status="succeeded",
lineage_id="root/each[0]",
patch=StatePatch(
writes=[
StateWrite(
path=StatePath(("count",)),
incoming_value=3,
visible_value=5,
reducer=ReducerRef(name="wf.std.add"),
)
]
),
)
},
)
barrier.save_to_frame(frame, "each")
loaded = ForeachBarrierState.from_frame(frame, "each")
assert loaded is not None
write = loaded.pending_results[0].patch.writes[0]
assert loaded.pending_results[0].lineage_id == "root/each[0]"
assert write.path == StatePath(("count",))
assert write.incoming_value == 3
assert write.visible_value == 5
assert write.reducer.name == "wf.std.add"
def test_pending_write_metadata_preserves_invalid_reducer_detail() -> None:
with pytest.raises(WorkflowExecutionError, match="mutually exclusive"):
_state_write_from_metadata(
{
"path": {"root": "state", "parts": ["count"]},
"incoming_value": 1,
"visible_value": 1,
"reducer": {
"name": "wf.std.add",
"ref": {"source": "wf.std", "capability_key": "add"},
},
}
) )
)
save_foreach_activation(frame, activation)
loaded = load_foreach_activation(frame, "each", activation.id)
assert loaded is not None
assert loaded.barrier.next_index == 2
assert loaded.barrier.active_frame_ids == ("child-1",)
assert loaded.barrier.outstanding_frame_ids == ("child-1",)
assert loaded.barrier.pending_results[1].status == "failed"
assert loaded.barrier.pending_results[1].error is not None
assert loaded.barrier.pending_results[1].error.message == "bad item"
def test_concurrent_success_round_trips_lineage_identity() -> None:
frame = ExecutionFrame(id="root", kind="root", node_id="each")
activation = load_or_begin_foreach_activation(frame, "each", mode="concurrent")
activation.barrier.add_success_patch(
index=0,
frame_id="child-0",
lineage_id="root:each#0[0]",
)
save_foreach_activation(frame, activation)
loaded = load_foreach_activation(frame, "each", activation.id)
assert loaded is not None
assert loaded.barrier.pending_results[0].lineage_id == "root:each#0[0]"
assert loaded.barrier.pending_results[0].status == "succeeded"
def test_lineage_state_view_materializes_visible_values_without_mutating_base() -> None: def test_lineage_state_view_materializes_visible_values_without_mutating_base() -> None:
@@ -132,23 +108,50 @@ def test_lineage_state_view_materializes_visible_values_without_mutating_base()
assert base_state["nested"]["value"] == "old" assert base_state["nested"]["value"] == "old"
def test_lineage_writes_for_frame_reads_current_foreach_pending_result() -> None: def test_lineage_writes_for_frame_reads_item_lineage_store() -> None:
parent = ExecutionFrame(id="root", kind="workflow", node_id="each") parent = ExecutionFrame(id="root", kind="workflow", node_id="each")
activation = load_or_begin_foreach_activation(parent, "each", mode="concurrent")
child_lineage_id = f"{activation.id}[0]"
child = ExecutionFrame( child = ExecutionFrame(
id="root:each:0", id=f"{activation.id}:0",
kind="foreach_iteration", kind="foreach_iteration",
node_id="work", node_id="work",
parent_frame_id="root", parent_frame_id="root",
lineage_id="root/each[0]", lineage_id=child_lineage_id,
parent_lineage_id="root", parent_lineage_id="root",
metadata={ metadata={
"foreach_node_id": "each", "foreach_node_id": "each",
"activation_id": activation.id,
"loop_index": 0, "loop_index": 0,
"loop_item": "a", "loop_item": "a",
"loop_alias": "item", "loop_alias": "item",
}, },
) )
patch = StatePatch( # Ownership is named, not positional.
owner = item_frame_owner(child)
assert isinstance(owner, ForeachItemOwner)
assert owner.activation_id == activation.id
assert owner.item_index == 0
save_foreach_activation(parent, activation)
run = RunState(
workflow_name="lineage",
status=RunStatus.PENDING,
workflow_input={},
state={"count": 2},
frames={parent.id: parent, child.id: child},
)
run.scopes["root"] = RuntimeScope(
id="root",
workflow_name="lineage",
workflow_input={},
committed_state=run.state,
)
run.lineages["root"] = LineageState(id="root", scope_id="root")
add_lineage(run, scope_id="root", lineage_id=child_lineage_id, parent_id="root")
append_lineage_writes(
run,
scope_id="root",
lineage_id=child_lineage_id,
writes=[ writes=[
StateWrite( StateWrite(
path=StatePath(("count",)), path=StatePath(("count",)),
@@ -156,27 +159,7 @@ def test_lineage_writes_for_frame_reads_current_foreach_pending_result() -> None
visible_value=5, visible_value=5,
reducer=ReducerRef(name="wf.std.add"), reducer=ReducerRef(name="wf.std.add"),
) )
] ],
)
barrier = ForeachBarrierState(
mode="concurrent",
pending_results={
0: PendingItemResult(
index=0,
frame_id=child.id,
status="succeeded",
lineage_id=child.lineage_id,
patch=patch,
)
},
)
barrier.save_to_frame(parent, "each")
run = RunState(
workflow_name="lineage",
status=RunStatus.PENDING,
workflow_input={},
state={"count": 2},
frames={parent.id: parent, child.id: child},
) )
writes = lineage_writes_for_frame(run, child) writes = lineage_writes_for_frame(run, child)
@@ -186,47 +169,16 @@ def test_lineage_writes_for_frame_reads_current_foreach_pending_result() -> None
assert writes[0].visible_value == 5 assert writes[0].visible_value == 5
def test_lineage_writes_for_frame_rejects_missing_compatibility_parent_frame() -> None: def test_load_activation_returns_none_when_missing() -> None:
child = ExecutionFrame( from wf_core.runtime.foreach_state import close_foreach_activation
id="missing:each:0",
kind="foreach_iteration",
node_id="work",
parent_frame_id="missing",
metadata={
"foreach_node_id": "each",
"loop_index": 0,
"loop_item": "a",
"loop_alias": "item",
},
)
run = RunState(
workflow_name="lineage",
status=RunStatus.PENDING,
workflow_input={},
state={},
frames={child.id: child},
)
with pytest.raises(WorkflowExecutionError, match="missing parent frame"):
lineage_writes_for_frame(run, child)
def test_foreach_barrier_state_returns_none_when_missing() -> None:
frame = ExecutionFrame(id="root", kind="root", node_id="each") frame = ExecutionFrame(id="root", kind="root", node_id="each")
activation = load_or_begin_foreach_activation(frame, "each", mode="serial")
save_foreach_activation(frame, activation)
close_foreach_activation(frame, activation)
assert ForeachBarrierState.from_frame(frame, "each") is None assert load_foreach_activation(frame, "each", activation.id) is None
assert load_foreach_activation(frame, "each", "root:each#99") is None
def test_foreach_barrier_state_rejects_malformed_metadata() -> None:
frame = ExecutionFrame(
id="root",
kind="root",
node_id="each",
metadata={"foreach_barriers": {"each": {"next_index": "bad"}}},
)
with pytest.raises(WorkflowExecutionError, match="next_index"):
ForeachBarrierState.from_frame(frame, "each")
def test_foreach_barrier_tracks_active_and_outstanding_children() -> None: def test_foreach_barrier_tracks_active_and_outstanding_children() -> None:
@@ -261,39 +213,44 @@ def test_foreach_barrier_rejects_finishing_unknown_child() -> None:
barrier.finish_child("child-0") barrier.finish_child("child-0")
def test_foreach_barrier_accumulates_multiple_patches_for_one_item() -> None: def test_foreach_barrier_success_registration_is_idempotent_for_same_lineage() -> None:
barrier = ForeachBarrierState(mode="concurrent") barrier = ForeachBarrierState(mode="concurrent")
patch = StatePatch(changes={"state.count": 1})
second_patch = StatePatch(changes={"state.name": "a"})
barrier.add_success_patch( barrier.add_success_patch(
index=0, index=0,
frame_id="child-0", frame_id="child-0",
patch=patch, lineage_id="root:each#0[0]",
lineage_id="root/each[0]",
) )
barrier.add_success_patch( barrier.add_success_patch(
index=0, index=0,
frame_id="child-0", frame_id="child-0",
patch=second_patch, lineage_id="root:each#0[0]",
lineage_id="root/each[0]",
) )
result = barrier.pending_results[0] result = barrier.pending_results[0]
assert result.lineage_id == "root/each[0]" assert result.status == "succeeded"
assert result.patch.changes["state.count"] == 1 assert result.lineage_id == "root:each#0[0]"
assert result.patch.changes["state.name"] == "a"
assert len(result.patch.writes) == 2
def test_foreach_barrier_rejects_success_lineage_mismatch() -> None:
barrier = ForeachBarrierState(mode="concurrent")
barrier.add_success_patch(index=0, frame_id="child-0", lineage_id="root:each#0[0]")
with pytest.raises(WorkflowExecutionError, match="belongs to lineage"):
barrier.add_success_patch(
index=0, frame_id="child-0", lineage_id="root:each#0[1]"
)
def test_foreach_barrier_rejects_item_result_frame_mismatch() -> None: def test_foreach_barrier_rejects_item_result_frame_mismatch() -> None:
barrier = ForeachBarrierState(mode="concurrent") barrier = ForeachBarrierState(mode="concurrent")
patch = StatePatch(changes={"state.count": 1})
barrier.add_success_patch(index=0, frame_id="child-0", patch=patch) barrier.add_success_patch(index=0, frame_id="child-0", lineage_id="root:each#0[0]")
with pytest.raises(WorkflowExecutionError, match="belongs to frame"): with pytest.raises(WorkflowExecutionError, match="belongs to frame"):
barrier.add_success_patch(index=0, frame_id="child-1", patch=patch) barrier.add_success_patch(
index=0, frame_id="child-1", lineage_id="root:each#0[0]"
)
def test_item_error_record_rejects_negative_index() -> None: def test_item_error_record_rejects_negative_index() -> None:
@@ -325,16 +282,109 @@ def test_pending_item_result_rejects_negative_index() -> None:
) )
def test_save_to_frame_rejects_corrupt_table_without_mutating() -> None: def test_pending_item_result_requires_lineage_for_success() -> None:
frame = ExecutionFrame( with pytest.raises(WorkflowExecutionError, match="lineage"):
id="root", PendingItemResult.from_metadata(
kind="root", {
node_id="each", "index": 0,
metadata={"foreach_barriers": "corrupt"}, "frame_id": "child",
"status": "succeeded",
"lineage_id": None,
}
)
def test_pending_item_result_rejects_error_on_success() -> None:
with pytest.raises(WorkflowExecutionError, match="must not carry an error"):
PendingItemResult.from_metadata(
{
"index": 0,
"frame_id": "child",
"status": "succeeded",
"lineage_id": "root:each#0[0]",
"error": {
"index": 0,
"frame_id": "child",
"node_id": "work",
"error_type": "ValueError",
"message": "bad",
},
}
)
def test_pending_item_result_requires_error_for_failure() -> None:
with pytest.raises(WorkflowExecutionError, match="requires an error"):
PendingItemResult.from_metadata(
{
"index": 0,
"frame_id": "child",
"status": "failed",
"lineage_id": None,
"error": None,
}
)
def test_pending_item_result_rejects_index_key_mismatch() -> None:
frame = ExecutionFrame(id="root", kind="root", node_id="each")
activation = load_or_begin_foreach_activation(frame, "each", mode="concurrent")
activation.barrier.add_success_patch(
index=0, frame_id="child-0", lineage_id="root:each#0[0]"
) )
barrier = ForeachBarrierState(next_index=1) save_foreach_activation(frame, activation)
raw = deepcopy(frame.metadata["foreach_activations"])
raw["each"]["active"]["barrier"]["pending_results"] = {
"7": raw["each"]["active"]["barrier"]["pending_results"]["0"]
}
with pytest.raises(WorkflowExecutionError, match="barrier table"): with pytest.raises(WorkflowExecutionError, match="index mismatch"):
barrier.save_to_frame(frame, "each") ForeachBarrierState.from_metadata(raw["each"]["active"]["barrier"])
assert frame.metadata["foreach_barriers"] == "corrupt"
def _failed_result(
*, index: int, frame_id: str, error_index: int, error_frame: str
) -> dict:
return {
"index": index,
"frame_id": frame_id,
"status": "failed",
"lineage_id": None,
"error": {
"index": error_index,
"frame_id": error_frame,
"node_id": "work",
"error_type": "ValueError",
"message": "bad",
},
}
def test_pending_item_result_rejects_error_index_mismatch() -> None:
with pytest.raises(WorkflowExecutionError, match="error.*index|index.*error"):
PendingItemResult.from_metadata(
_failed_result(
index=0, frame_id="child-0", error_index=7, error_frame="child-0"
)
)
def test_pending_item_result_rejects_error_frame_mismatch() -> None:
with pytest.raises(WorkflowExecutionError, match="error.*frame|frame.*error"):
PendingItemResult.from_metadata(
_failed_result(
index=0, frame_id="child-0", error_index=0, error_frame="other"
)
)
def test_pending_item_result_accepts_matching_error_identity() -> None:
result = PendingItemResult.from_metadata(
_failed_result(
index=0, frame_id="child-0", error_index=0, error_frame="child-0"
)
)
assert result.error is not None
assert result.error.index == 0
assert result.error.frame_id == "child-0"
+446
View File
@@ -0,0 +1,446 @@
from __future__ import annotations
from wf_core import END, Workflow
from wf_core.analysis.control_regions import (
ControlRegionAnalysis,
ControlRegionIssueKind,
analyze_control_regions,
)
from wf_core.validation.issues import ValidationIssueCode
def _workflow(
*,
start: str,
nodes: list[dict[str, object]],
edges: list[dict[str, str]],
) -> Workflow:
return Workflow.model_validate(
{
"name": "control-regions",
"input_schema": {"type": "object", "properties": {}},
"state_schema": {
"type": "object",
"properties": {
"items": {"type": "array", "items": {"type": "string"}},
"inner_items": {"type": "array", "items": {"type": "integer"}},
},
},
"output_schema": {"type": "object", "properties": {}},
"start": start,
"nodes": nodes,
"edges": edges,
"node_defs": [],
}
)
def _node(node_id: str) -> dict[str, object]:
return {"id": node_id, "type": "node", "node": "noop"}
def _foreach(node_id: str, *, alias: str = "item") -> dict[str, object]:
return {
"id": node_id,
"type": "foreach",
"over": "state.items",
"as": alias,
"mode": "serial",
}
def _condition(node_id: str) -> dict[str, object]:
return {
"id": node_id,
"type": "condition",
"check": {"op": "exists", "path": "state.items"},
}
_CONTROL_REGION_CODES = {code.value for code in ControlRegionIssueKind}
def _public_control_errors(workflow: Workflow) -> list[tuple[str, str]]:
return [
(issue.code.value, issue.path)
for issue in workflow.validate_structure().errors
if issue.code.value in _CONTROL_REGION_CODES
]
def _assert_no_public_control_errors(workflow: Workflow) -> None:
assert _public_control_errors(workflow) == []
def test_closed_root_cycle_has_one_empty_control_region() -> None:
workflow = _workflow(
start="a",
nodes=[_node("a"), _node("b")],
edges=[
{"from": "a", "outcome": "ok", "to": "b"},
{"from": "b", "outcome": "ok", "to": "a"},
],
)
analysis = analyze_control_regions(workflow)
assert analysis.issues == ()
assert analysis.owner_stack_by_node == {"a": (), "b": ()}
_assert_no_public_control_errors(workflow)
def test_foreach_cycle_with_possible_return_is_valid() -> None:
workflow = _workflow(
start="f",
nodes=[_foreach("f"), _node("a")],
edges=[
{"from": "f", "outcome": "loop", "to": "a"},
{"from": "a", "outcome": "again", "to": "a"},
{"from": "a", "outcome": "done", "to": "f"},
{"from": "f", "outcome": "done", "to": END},
],
)
analysis = analyze_control_regions(workflow)
assert analysis.issues == ()
assert analysis.owner_stack_by_node["a"] == ("f",)
assert analysis.owner_stack_by_node["f"] == ()
_assert_no_public_control_errors(workflow)
def test_conditional_foreach_paths_can_both_return() -> None:
workflow = _workflow(
start="f",
nodes=[_foreach("f"), _condition("condition"), _node("work")],
edges=[
{"from": "f", "outcome": "loop", "to": "condition"},
{"from": "condition", "outcome": "true", "to": "work"},
{"from": "condition", "outcome": "false", "to": "f"},
{"from": "work", "outcome": "ok", "to": "f"},
{"from": "f", "outcome": "done", "to": END},
],
)
analysis = analyze_control_regions(workflow)
assert analysis.issues == ()
assert analysis.owner_stack_by_node["condition"] == ("f",)
assert analysis.owner_stack_by_node["work"] == ("f",)
_assert_no_public_control_errors(workflow)
def test_nested_foreach_assigns_static_owner_stacks() -> None:
workflow = _workflow(
start="f1",
nodes=[
_foreach("f1"),
_foreach("f2"),
_node("work"),
_node("tail"),
_node("after"),
],
edges=[
{"from": "f1", "outcome": "loop", "to": "f2"},
{"from": "f2", "outcome": "loop", "to": "work"},
{"from": "work", "outcome": "ok", "to": "f2"},
{"from": "f2", "outcome": "done", "to": "tail"},
{"from": "tail", "outcome": "ok", "to": "f1"},
{"from": "f1", "outcome": "done", "to": "after"},
{"from": "after", "outcome": "ok", "to": END},
],
)
analysis: ControlRegionAnalysis = analyze_control_regions(workflow)
assert analysis.owner_stack_by_node == {
"f1": (),
"f2": ("f1",),
"work": ("f1", "f2"),
"tail": ("f1",),
"after": (),
}
assert analysis.issues == ()
_assert_no_public_control_errors(workflow)
def test_reentering_completed_foreach_keeps_one_static_region() -> None:
workflow = _workflow(
start="again",
nodes=[_condition("again"), _foreach("f"), _node("work")],
edges=[
{"from": "again", "outcome": "true", "to": "f"},
{"from": "f", "outcome": "loop", "to": "work"},
{"from": "work", "outcome": "ok", "to": "f"},
{"from": "f", "outcome": "done", "to": "again"},
{"from": "again", "outcome": "false", "to": END},
],
)
analysis = analyze_control_regions(workflow)
assert analysis.issues == ()
assert analysis.owner_stack_by_node["f"] == ()
assert analysis.owner_stack_by_node["work"] == ("f",)
assert analysis.owner_stack_by_node["again"] == ()
_assert_no_public_control_errors(workflow)
def test_external_entry_into_foreach_body_is_region_conflict() -> None:
workflow = _workflow(
start="start",
nodes=[_condition("start"), _foreach("f"), _node("b")],
edges=[
{"from": "start", "outcome": "true", "to": "f"},
{"from": "start", "outcome": "false", "to": "b"},
{"from": "f", "outcome": "loop", "to": "b"},
{"from": "b", "outcome": "ok", "to": "f"},
{"from": "f", "outcome": "done", "to": END},
],
)
analysis = analyze_control_regions(workflow)
assert (ControlRegionIssueKind.FOREACH_REGION_CONFLICT, "nodes[b]") in [
(issue.kind, issue.path) for issue in analysis.issues
]
assert "b" not in analysis.owner_stack_by_node
matching = [
issue
for issue in workflow.validate_structure().errors
if issue.code == ValidationIssueCode.FOREACH_REGION_CONFLICT
]
assert matching[0].path == "nodes[b]"
def test_foreach_body_escape_is_region_conflict() -> None:
workflow = _workflow(
start="f",
nodes=[_foreach("f"), _node("b"), _node("after")],
edges=[
{"from": "f", "outcome": "loop", "to": "b"},
{"from": "b", "outcome": "ok", "to": "after"},
{"from": "f", "outcome": "done", "to": "after"},
{"from": "after", "outcome": "ok", "to": END},
],
)
analysis = analyze_control_regions(workflow)
assert (ControlRegionIssueKind.FOREACH_REGION_CONFLICT, "nodes[after]") in [
(issue.kind, issue.path) for issue in analysis.issues
]
assert "after" not in analysis.owner_stack_by_node
matching = [
issue
for issue in workflow.validate_structure().errors
if issue.code == ValidationIssueCode.FOREACH_REGION_CONFLICT
]
assert matching[0].path == "nodes[after]"
def test_skipping_inner_foreach_owner_is_invalid_return() -> None:
workflow = _workflow(
start="f1",
nodes=[_foreach("f1"), _foreach("f2"), _node("work")],
edges=[
{"from": "f1", "outcome": "loop", "to": "f2"},
{"from": "f2", "outcome": "loop", "to": "work"},
{"from": "work", "outcome": "ok", "to": "f1"},
{"from": "f1", "outcome": "done", "to": END},
{"from": "f2", "outcome": "done", "to": END},
],
)
analysis = analyze_control_regions(workflow)
assert (ControlRegionIssueKind.INVALID_FOREACH_RETURN, "edges[2]") in [
(issue.kind, issue.path) for issue in analysis.issues
]
matching = [
issue
for issue in workflow.validate_structure().errors
if issue.code == ValidationIssueCode.INVALID_FOREACH_RETURN
]
assert matching[0].path == "edges[2]"
def test_reentering_active_ancestor_foreach_as_nested_controller_is_invalid() -> None:
workflow = _workflow(
start="f1",
nodes=[_foreach("f1"), _foreach("f2")],
edges=[
{"from": "f1", "outcome": "loop", "to": "f2"},
{"from": "f2", "outcome": "loop", "to": "f1"},
{"from": "f2", "outcome": "done", "to": "f1"},
{"from": "f1", "outcome": "done", "to": END},
],
)
analysis = analyze_control_regions(workflow)
assert (ControlRegionIssueKind.INVALID_FOREACH_RETURN, "edges[1]") in [
(issue.kind, issue.path) for issue in analysis.issues
]
matching = [
issue
for issue in workflow.validate_structure().errors
if issue.code == ValidationIssueCode.INVALID_FOREACH_RETURN
]
assert matching[0].path == "edges[1]"
def test_entering_sibling_foreach_body_is_region_conflict() -> None:
workflow = _workflow(
start="f1",
nodes=[_foreach("f1"), _foreach("f2"), _node("b1"), _node("b2")],
edges=[
{"from": "f1", "outcome": "loop", "to": "b1"},
{"from": "b1", "outcome": "ok", "to": "b2"},
{"from": "f2", "outcome": "loop", "to": "b2"},
{"from": "b2", "outcome": "ok", "to": "f1"},
{"from": "f1", "outcome": "done", "to": "f2"},
{"from": "f2", "outcome": "done", "to": END},
],
)
analysis = analyze_control_regions(workflow)
assert (ControlRegionIssueKind.FOREACH_REGION_CONFLICT, "nodes[b2]") in [
(issue.kind, issue.path) for issue in analysis.issues
]
matching = [
issue
for issue in workflow.validate_structure().errors
if issue.code == ValidationIssueCode.FOREACH_REGION_CONFLICT
]
assert matching[0].path == "nodes[b2]"
def test_empty_foreach_body_is_rejected() -> None:
workflow = _workflow(
start="f",
nodes=[_foreach("f")],
edges=[
{"from": "f", "outcome": "loop", "to": "f"},
{"from": "f", "outcome": "done", "to": END},
],
)
analysis = analyze_control_regions(workflow)
assert (ControlRegionIssueKind.EMPTY_FOREACH_BODY, "edges[0]") in [
(issue.kind, issue.path) for issue in analysis.issues
]
matching = [
issue
for issue in workflow.validate_structure().errors
if issue.code == ValidationIssueCode.EMPTY_FOREACH_BODY
]
assert matching[0].path == "edges[0]"
def test_closed_foreach_body_cycle_has_no_return() -> None:
workflow = _workflow(
start="f",
nodes=[_foreach("f"), _node("a"), _node("b")],
edges=[
{"from": "f", "outcome": "loop", "to": "a"},
{"from": "a", "outcome": "ok", "to": "b"},
{"from": "b", "outcome": "ok", "to": "a"},
{"from": "f", "outcome": "done", "to": END},
],
)
analysis = analyze_control_regions(workflow)
assert (ControlRegionIssueKind.FOREACH_BODY_NO_RETURN, "nodes[f]") in [
(issue.kind, issue.path) for issue in analysis.issues
]
matching = [
issue
for issue in workflow.validate_structure().errors
if issue.code == ValidationIssueCode.FOREACH_BODY_NO_RETURN
]
assert matching[0].path == "nodes[f]"
def test_foreach_body_cannot_target_end_token() -> None:
workflow = _workflow(
start="f",
nodes=[_foreach("f"), _node("body")],
edges=[
{"from": "f", "outcome": "loop", "to": "body"},
{"from": "body", "outcome": "ok", "to": END},
{"from": "f", "outcome": "done", "to": END},
],
)
analysis = analyze_control_regions(workflow)
assert (ControlRegionIssueKind.INVALID_FOREACH_TERMINAL, "edges[1]") in [
(issue.kind, issue.path) for issue in analysis.issues
]
matching = [
issue
for issue in workflow.validate_structure().errors
if issue.code == ValidationIssueCode.INVALID_FOREACH_TERMINAL
]
assert matching[0].path == "edges[1]"
def test_foreach_body_cannot_target_explicit_end_node() -> None:
workflow = _workflow(
start="f",
nodes=[
_foreach("f"),
_node("body"),
{"id": "stop", "type": "end", "outcome": "ok"},
],
edges=[
{"from": "f", "outcome": "loop", "to": "body"},
{"from": "body", "outcome": "ok", "to": "stop"},
{"from": "f", "outcome": "done", "to": END},
],
)
analysis = analyze_control_regions(workflow)
assert (ControlRegionIssueKind.INVALID_FOREACH_TERMINAL, "edges[1]") in [
(issue.kind, issue.path) for issue in analysis.issues
]
matching = [
issue
for issue in workflow.validate_structure().errors
if issue.code == ValidationIssueCode.INVALID_FOREACH_TERMINAL
]
assert matching[0].path == "edges[1]"
def test_every_unreachable_node_is_reported() -> None:
workflow = _workflow(
start="work",
nodes=[_node("work"), _node("detached_a"), _node("detached_b")],
edges=[
{"from": "work", "outcome": "ok", "to": END},
{"from": "detached_a", "outcome": "ok", "to": "detached_b"},
{"from": "detached_b", "outcome": "ok", "to": "detached_a"},
],
)
analysis = analyze_control_regions(workflow)
by_kind_path = [(issue.kind, issue.path) for issue in analysis.issues]
assert (
ControlRegionIssueKind.UNREACHABLE_NODE,
"nodes[detached_a]",
) in by_kind_path
assert (
ControlRegionIssueKind.UNREACHABLE_NODE,
"nodes[detached_b]",
) in by_kind_path
public_by_code_path = [
(issue.code.value, issue.path) for issue in workflow.validate_structure().errors
]
assert ("unreachable_node", "nodes[detached_a]") in public_by_code_path
assert ("unreachable_node", "nodes[detached_b]") in public_by_code_path
+11 -6
View File
@@ -137,16 +137,19 @@ def test_collect_policy_requires_completed_with_errors_edge() -> None:
workflow = _workflow( workflow = _workflow(
item_error={"action": "collect", "collect_to": "state.item_errors"}, item_error={"action": "collect", "collect_to": "state.item_errors"},
edges=[ edges=[
{"from": "each", "outcome": "loop", "to": END}, {"from": "each", "outcome": "loop", "to": "body"},
{"from": "body", "outcome": "ok", "to": "each"},
{"from": "each", "outcome": "done", "to": END}, {"from": "each", "outcome": "done", "to": END},
], ],
) )
report = validate_workflow(workflow) report = validate_workflow(workflow)
assert report.errors matching = [
assert report.errors[0].code == "missing_outcome_edge" issue for issue in report.errors if issue.code == "missing_outcome_edge"
assert "completed_with_errors" in report.errors[0].message ]
assert matching
assert "completed_with_errors" in matching[0].message
def test_collect_policy_destination_must_be_declared_array_field() -> None: def test_collect_policy_destination_must_be_declared_array_field() -> None:
@@ -199,11 +202,13 @@ def _workflow(
"over": "state.items", "over": "state.items",
"as": "item", "as": "item",
"item_error": item_error or {"action": "fail"}, "item_error": item_error or {"action": "fail"},
} },
{"id": "body", "type": "node", "node": "noop"},
], ],
"edges": edges "edges": edges
or [ or [
{"from": "each", "outcome": "loop", "to": END}, {"from": "each", "outcome": "loop", "to": "body"},
{"from": "body", "outcome": "ok", "to": "each"},
{"from": "each", "outcome": "done", "to": END}, {"from": "each", "outcome": "done", "to": END},
], ],
"node_defs": [], "node_defs": [],
+22
View File
@@ -163,8 +163,19 @@ def test_child_completion_wakes_blocked_parent() -> None:
def test_wake_parent_when_child_finishes_for_refill() -> None: def test_wake_parent_when_child_finishes_for_refill() -> None:
from wf_core.runtime.foreach_state import (
ForeachItemOwner,
item_frame_owner,
load_or_begin_foreach_activation,
save_foreach_activation,
)
run = _run() run = _run()
add_frame(run, ExecutionFrame(id="parent", kind="root", node_id="foreach")) add_frame(run, ExecutionFrame(id="parent", kind="root", node_id="foreach"))
activation = load_or_begin_foreach_activation(
run.frames["parent"], "foreach", mode="serial"
)
save_foreach_activation(run.frames["parent"], activation)
add_frame( add_frame(
run, run,
ExecutionFrame( ExecutionFrame(
@@ -172,11 +183,22 @@ def test_wake_parent_when_child_finishes_for_refill() -> None:
kind="foreach_iteration", kind="foreach_iteration",
node_id="__end__", node_id="__end__",
parent_frame_id="parent", parent_frame_id="parent",
metadata={
"foreach_node_id": "foreach",
"activation_id": activation.id,
"loop_index": 0,
"loop_item": "a",
"loop_alias": "item",
},
), ),
) )
block_frame_on_children(run, "parent", ("child", "other")) block_frame_on_children(run, "parent", ("child", "other"))
run.frames["child"].status = FrameStatus.COMPLETED run.frames["child"].status = FrameStatus.COMPLETED
owner = item_frame_owner(run.frames["child"])
assert isinstance(owner, ForeachItemOwner)
assert owner.activation_id == activation.id
wake_parent_for_child_progress(run, "child") wake_parent_for_child_progress(run, "child")
assert run.frames["parent"].status == FrameStatus.PENDING assert run.frames["parent"].status == FrameStatus.PENDING
+7 -1
View File
@@ -272,7 +272,13 @@ async def test_snapshotless_remote_node_upgrades_to_real_capability_contract() -
replacement = graph.use(capability, id="replacement", input=[], output=[]) replacement = graph.use(capability, id="replacement", input=[], output=[])
graph.connect(replacement, "ok", "done") graph.connect(replacement, "ok", "done")
assert graph.validate_local().ok is True # A disconnected replacement has no derivable control region; new
# validation reports it as unreachable rather than silently accepting it.
report = graph.validate_local()
assert any(
issue.code == "unreachable_node" and issue.path == "nodes[replacement]"
for issue in report.errors
)
assert graph.seeded_node_defs["app.default.remote"].input_schema.properties == { assert graph.seeded_node_defs["app.default.remote"].input_schema.properties == {
"query": {"type": "string"} "query": {"type": "string"}
} }