Files
lda-wf/docs/historical/superpowers/plans/2026-05-22-concurrent-foreach-v1-sync-fail-only.md
T

1121 lines
33 KiB
Markdown

# Concurrent Foreach V1 Sync Fail-Only Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Enable `foreach(mode="concurrent")` in the sync runtime for fail-only item policy, using deterministic frame interleaving and barrier-buffered state commits.
**Architecture:** The foreach parent owns admission and refill. Item frames execute through the existing scheduler one step at a time, but their node output writes are buffered as item-local `StatePatch` records instead of mutating `RunState.state`. When all items succeed, the parent barrier commits item patches in item index order and emits `done`.
**Important V1 limitation:** this slice intentionally supports only `loop -> one node -> END` concurrent item bodies. A no-op item-state overlay seam exists at `wf_core.runtime.ops.overlays.state_view_for_frame`, but it still returns parent state. Full multi-step concurrent item bodies require the next slice to make that overlay non-noop so later nodes in one item can read earlier item-local writes.
**Tech Stack:** Python 3.14, dataclasses, Pydantic v2, pytest, `wf_core.runtime.scheduler`, `wf_core.runtime.foreach_state`, `wf_core.runtime.ops.state`.
---
## Files
- Modify: `src/wf_core/runtime/foreach_state.py`
- Add active/outstanding helpers and pending result mutation helpers.
- Modify: `src/wf_core/runtime/ops/foreach.py`
- Split serial and concurrent foreach execution.
- Admit multiple child frames for concurrent mode.
- Finish parent when all items succeed.
- Modify: `src/wf_core/runtime/step.py`
- Pass reducer definitions through to foreach barrier commit.
- Modify: `src/wf_core/runtime/ops/state.py`
- Add a barrier patch helper that reapplies reducers in deterministic item order.
- Modify: `src/wf_core/runtime/ops/nodes.py`
- Buffer output patches for concurrent foreach item frames.
- Add: `src/wf_core/runtime/ops/overlays.py`
- Add the no-op state overlay seam for the next item-local overlay slice.
- Modify: `src/wf_core/runtime/scheduler.py`
- Add a parent wake helper that can wake a foreach parent when any child finishes, not only when all child frames in one block reason are complete.
- Test: `tests/core/test_concurrent_foreach.py`
- New focused runtime tests.
---
### Task 1: Add Failing Sync Concurrent Foreach Tests
**Files:**
- Create: `tests/core/test_concurrent_foreach.py`
- [ ] **Step 1: Add the test file**
Create `tests/core/test_concurrent_foreach.py`:
```python
from __future__ import annotations
from typing import Any
import pytest
from wf_core import (
END,
Edge,
ForeachNode,
NodeDef,
NodeUse,
SchemaRef,
StateSchema,
Workflow,
WorkflowExecutionError,
execute_workflow,
)
from wf_core.models.reducers import ReducerRef
from wf_core.models.schemas import StateField
def test_sync_concurrent_foreach_interleaves_items_and_commits_at_barrier() -> None:
workflow = _workflow(
state_schema=StateSchema.from_field_map(
{
"items": StateField(type="array"),
"seen": StateField(
type="array",
reducer=ReducerRef(name="wf.std.append"),
),
}
),
foreach=ForeachNode.model_validate(
{
"id": "each",
"type": "foreach",
"over": "state.items",
"as": "item",
"mode": "concurrent",
"concurrent": {"max_active": 2, "max_outstanding": 2},
}
),
)
run = execute_workflow(
workflow,
{"items": ["a", "b", "c"]},
{"record": lambda payload, _ctx: {"outcome": "ok", "output": payload}},
)
assert run.output["seen"] == ["a", "b", "c"]
assert run.state["seen"] == ["a", "b", "c"]
foreach_entries = [entry for entry in run.trace if entry.step_type == "foreach"]
assert foreach_entries[-1].outcome == "done"
assert foreach_entries[-1].state_changes["state.seen"] == ["a", "b", "c"]
def test_sync_concurrent_foreach_respects_max_active_by_refill_trace() -> None:
workflow = _workflow(
state_schema=StateSchema.from_field_map(
{
"items": StateField(type="array"),
"seen": StateField(type="array", reducer=ReducerRef(name="wf.std.append")),
}
),
foreach=ForeachNode.model_validate(
{
"id": "each",
"type": "foreach",
"over": "state.items",
"as": "item",
"mode": "concurrent",
"concurrent": {"max_active": 2, "max_outstanding": 2},
}
),
)
run = execute_workflow(
workflow,
{"items": ["a", "b", "c", "d"]},
{"record": lambda payload, _ctx: {"outcome": "ok", "output": payload}},
)
loop_entries = [
entry for entry in run.trace if entry.step_type == "foreach" and entry.outcome == "loop"
]
assert loop_entries[0].resolved_input["active_count"] == 0
assert loop_entries[1].resolved_input["active_count"] == 1
assert all(entry.resolved_input["active_count"] < 2 for entry in loop_entries)
def test_sync_concurrent_foreach_rejects_non_fail_item_policy_for_now() -> None:
workflow = _workflow(
state_schema=StateSchema.from_field_map(
{
"items": StateField(type="array"),
"seen": StateField(type="array", reducer=ReducerRef(name="wf.std.append")),
"errors": StateField(type="array"),
}
),
foreach=ForeachNode.model_validate(
{
"id": "each",
"type": "foreach",
"over": "state.items",
"as": "item",
"mode": "concurrent",
"concurrent": {"max_active": 2, "max_outstanding": 2},
"item_error": {"action": "collect", "collect_to": "state.errors"},
}
),
)
with pytest.raises(WorkflowExecutionError, match="only supports item_error.action='fail'"):
execute_workflow(
workflow,
{"items": ["a"]},
{"record": lambda payload, _ctx: {"outcome": "ok", "output": payload}},
)
def test_sync_concurrent_foreach_fails_run_on_item_runtime_error() -> None:
workflow = _workflow(
state_schema=StateSchema.from_field_map(
{
"items": StateField(type="array"),
"seen": StateField(type="array", reducer=ReducerRef(name="wf.std.append")),
}
),
foreach=ForeachNode.model_validate(
{
"id": "each",
"type": "foreach",
"over": "state.items",
"as": "item",
"mode": "concurrent",
"concurrent": {"max_active": 2, "max_outstanding": 2},
}
),
)
def fail_on_b(payload: dict[str, Any], _ctx: object) -> dict[str, Any]:
if payload["value"] == "b":
raise ValueError("bad item")
return {"outcome": "ok", "output": payload}
with pytest.raises(WorkflowExecutionError, match="bad item"):
execute_workflow(workflow, {"items": ["a", "b", "c"]}, {"record": fail_on_b})
def _workflow(*, state_schema: StateSchema, foreach: ForeachNode) -> Workflow:
return Workflow(
name="concurrent_foreach_v1",
input_schema=SchemaRef(
type="object",
properties={"items": {"type": "array"}},
),
state_schema=state_schema,
output_schema=SchemaRef(
type="object",
properties={"seen": {"type": "array"}},
),
node_defs=[
NodeDef(
name="record",
input_schema=SchemaRef(
type="object",
properties={"value": {}, "seen": {}},
required=["value", "seen"],
),
output_schema=SchemaRef(
type="object",
properties={"value": {}, "seen": {}},
required=["seen"],
),
outcomes=["ok"],
)
],
start="each",
nodes=[
foreach,
NodeUse.model_validate(
{
"id": "record",
"type": "node",
"node": "record",
"input": [
{"target": "value", "path": "context.item"},
{"target": "seen", "path": "context.item"},
],
"output": [{"source": "seen", "target": "state.seen"}],
}
),
],
edges=[
Edge.model_validate({"from": "each", "outcome": "loop", "to": "record"}),
Edge.model_validate({"from": "record", "outcome": "ok", "to": END}),
Edge.model_validate({"from": "each", "outcome": "done", "to": END}),
],
)
```
- [ ] **Step 2: Run the new tests and verify failure**
Run:
```bash
uv run pytest tests/core/test_concurrent_foreach.py -q
```
Expected:
```text
FAILED with message containing "concurrent foreach execution is not implemented yet"
```
---
### Task 2: Add Barrier Admission Helpers
**Files:**
- Modify: `src/wf_core/runtime/foreach_state.py`
- Test: `tests/core/test_foreach_barrier_state.py`
- [ ] **Step 1: Add barrier helper tests**
Append to `tests/core/test_foreach_barrier_state.py`:
```python
def test_foreach_barrier_tracks_active_and_outstanding_children() -> None:
barrier = ForeachBarrierState()
barrier.start_child("child-0")
barrier.start_child("child-1")
barrier.finish_child("child-0")
assert barrier.active_frame_ids == ("child-1",)
assert barrier.outstanding_frame_ids == ("child-1",)
def test_foreach_barrier_rejects_duplicate_child_start() -> None:
barrier = ForeachBarrierState()
barrier.start_child("child-0")
with pytest.raises(WorkflowExecutionError, match="already active"):
barrier.start_child("child-0")
```
- [ ] **Step 2: Run helper tests and verify failure**
Run:
```bash
uv run pytest tests/core/test_foreach_barrier_state.py -q
```
Expected:
```text
FAILED with AttributeError: 'ForeachBarrierState' object has no attribute 'start_child'
```
- [ ] **Step 3: Implement helper methods**
In `src/wf_core/runtime/foreach_state.py`, add methods to `ForeachBarrierState`:
```python
def start_child(self, frame_id: str) -> None:
"""Record one admitted child frame as active and outstanding."""
if frame_id in self.active_frame_ids or frame_id in self.outstanding_frame_ids:
raise WorkflowExecutionError(f"foreach child frame {frame_id!r} already active")
self.active_frame_ids = (*self.active_frame_ids, frame_id)
self.outstanding_frame_ids = (*self.outstanding_frame_ids, frame_id)
def finish_child(self, frame_id: str) -> None:
"""Record one child frame as no longer active or outstanding."""
self.active_frame_ids = tuple(
item for item in self.active_frame_ids if item != frame_id
)
self.outstanding_frame_ids = tuple(
item for item in self.outstanding_frame_ids if item != frame_id
)
```
- [ ] **Step 4: Verify helper tests**
Run:
```bash
uv run pytest tests/core/test_foreach_barrier_state.py -q
```
Expected: pass.
---
### Task 3: Wake Foreach Parent After Each Child Completion
**Files:**
- Modify: `src/wf_core/runtime/scheduler.py`
- Test: `tests/core/test_scheduler.py`
- [ ] **Step 1: Add a parent-wake test for any completed child**
Append to `tests/core/test_scheduler.py`:
```python
def test_wake_parent_when_child_finishes_for_refill() -> None:
run = _run()
add_frame(run, ExecutionFrame(id="parent", kind="root", node_id="foreach"))
add_frame(
run,
ExecutionFrame(
id="child",
kind="foreach_iteration",
node_id="__end__",
parent_frame_id="parent",
),
)
block_frame_on_children(run, "parent", ("child", "other"))
run.frames["child"].status = FrameStatus.COMPLETED
wake_parent_for_child_progress(run, "child")
assert run.frames["parent"].status == FrameStatus.PENDING
assert run.ready_frame_ids == ["parent"]
```
- [ ] **Step 2: Run scheduler test and verify failure**
Run:
```bash
uv run pytest tests/core/test_scheduler.py::test_wake_parent_when_child_finishes_for_refill -q
```
Expected:
```text
ImportError or NameError for wake_parent_for_child_progress
```
- [ ] **Step 3: Implement wake helper**
In `src/wf_core/runtime/scheduler.py`, add:
```python
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."""
child = _frame(run, child_frame_id)
parent_id = child.parent_frame_id
if parent_id is None:
return
parent = _frame(run, parent_id)
if parent.status != FrameStatus.BLOCKED:
return
block = BlockedOnChildren.from_frame(parent)
if block is None or child_frame_id not in block.child_frame_ids:
return
wake_frame(run, parent_id)
```
Keep `wake_parent_if_children_complete(...)` for serial foreach compatibility until all callers are migrated.
- [ ] **Step 4: Import helper in test**
Update `tests/core/test_scheduler.py` import list:
```python
from wf_core.runtime.scheduler import (
add_frame,
block_frame_on_children,
enqueue_frame,
resolve_no_ready_frames,
select_next_frame,
wake_frame,
wake_parent_for_child_progress,
wake_parent_if_children_complete,
)
```
- [ ] **Step 5: Verify scheduler tests**
Run:
```bash
uv run pytest tests/core/test_scheduler.py -q
```
Expected: pass.
---
### Task 4: Buffer Node Writes Inside Concurrent Foreach Item Frames
**Files:**
- Modify: `src/wf_core/runtime/ops/nodes.py`
- Modify: `src/wf_core/runtime/foreach_state.py`
- Test: `tests/core/test_concurrent_foreach.py`
- [ ] **Step 1: Add item frame patch helper**
In `src/wf_core/runtime/foreach_state.py`, add:
```python
def item_frame_owner(frame: ExecutionFrame) -> tuple[str, str, int] | None:
"""Return parent frame id, foreach node id, and item index for item frames."""
if frame.kind != "foreach_iteration" or frame.parent_frame_id is None:
return None
metadata = ForeachIterationMetadata.from_frame(frame)
if metadata is None:
return None
return frame.parent_frame_id, metadata.foreach_node_id, metadata.loop_index
```
Add the import at the top:
```python
from wf_core.runtime.scheduler import ForeachIterationMetadata
```
- [ ] **Step 2: Add pending result upsert helper**
In `ForeachBarrierState`, add:
```python
def add_success_patch(self, *, index: int, frame_id: str, patch: StatePatch) -> None:
"""Buffer one successful item patch by item index."""
self.pending_results[index] = PendingItemResult(
index=index,
frame_id=frame_id,
status="succeeded",
patch=patch,
)
```
- [ ] **Step 3: Change node finalization to buffer patches for concurrent item frames**
In `src/wf_core/runtime/ops/nodes.py`, import:
```python
from wf_core.runtime.foreach_state import ForeachBarrierState, item_frame_owner
from wf_core.runtime.ops.state import build_output_patch, commit_state_patch
```
Replace the direct `apply_output_bindings(...)` call in `_finalize_node_execution(...)` with:
```python
patch = build_output_patch(
workflow,
node.output,
result.output,
run.state,
reducers=reducers,
)
owner = item_frame_owner(run.current_frame())
if owner is None:
state_changes = commit_state_patch(run.state, patch)
else:
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)
or ForeachBarrierState()
)
barrier.add_success_patch(
index=item_index,
frame_id=run.current_frame().id,
patch=patch,
)
barrier.save_to_frame(parent_frame, foreach_node_id)
state_changes = {}
```
Rationale: child traces should not claim committed state changes; the barrier trace will report committed changes later.
- [ ] **Step 4: Run focused tests and verify expected failures remain**
Run:
```bash
uv run pytest tests/core/test_concurrent_foreach.py tests/core/test_foreach_barrier_state.py -q
```
Expected:
- Barrier helper tests pass.
- Concurrent foreach runtime tests may still fail because parent admission/refill is not implemented yet.
---
### Task 5: Split Serial and Concurrent Foreach Runtime
**Files:**
- Modify: `src/wf_core/runtime/ops/foreach.py`
- Modify: `src/wf_core/runtime/step.py`
- Test: `tests/core/test_concurrent_foreach.py`
- [ ] **Step 1: Split the existing serial implementation**
In `src/wf_core/runtime/ops/foreach.py`, replace `step_foreach(...)` with this
dispatcher:
```python
def step_foreach(
workflow: Workflow,
run: RunState,
step: ForeachNode,
index: WorkflowIndex,
*,
reducers: Mapping[str, ReducerDefinition] | None = None,
) -> RunState:
if step.mode == "serial":
return _step_foreach_serial(workflow, run, step, index)
return _step_foreach_concurrent(
workflow,
run,
step,
index,
reducers=reducers,
)
```
Move the current body into:
```python
def _step_foreach_serial(
workflow: Workflow,
run: RunState,
step: ForeachNode,
index: WorkflowIndex,
) -> RunState:
if step.mode != "serial":
raise WorkflowExecutionError("serial foreach helper received non-serial mode")
frame = run.current_frame()
barrier = ForeachBarrierState.from_frame(frame, step.id) or ForeachBarrierState()
iterable = _resolve_foreach_iterable(run, frame, step)
loop_index = barrier.next_index
if loop_index >= len(iterable):
outcome = "done"
next_node_id = index.next_node_id(frame.node_id, outcome)
append_step_result_trace(
run,
frame_id=frame.id,
node_id=frame.node_id,
step_type=step.type,
next_node_id=next_node_id,
result=StepExecutionResult(
outcome=outcome,
resolved_input={"count": len(iterable), "index": loop_index},
output={},
state_changes={},
),
)
advance_frame(run, frame, outcome=outcome, next_node_id=next_node_id)
return run
loop_start = index.next_node_id(frame.node_id, "loop")
item = iterable[loop_index]
barrier.next_index = loop_index + 1
barrier.save_to_frame(frame, step.id)
child_id = f"{frame.id}:{step.id}:{loop_index}"
add_frame(
run,
ExecutionFrame(
id=child_id,
kind="foreach_iteration",
node_id=loop_start,
status=FrameStatus.PENDING,
parent_frame_id=frame.id,
metadata=ForeachIterationMetadata(
foreach_node_id=step.id,
loop_index=loop_index,
loop_item=item,
loop_alias=step.as_,
).to_metadata(),
),
ready=True,
)
block_frame_on_children(run, frame.id, (child_id,))
append_step_result_trace(
run,
frame_id=frame.id,
node_id=frame.node_id,
step_type=step.type,
next_node_id=loop_start,
result=StepExecutionResult(
outcome="loop",
resolved_input={"item": item, "index": loop_index},
output={},
state_changes={},
),
)
run.sync_from_current_frame()
return run
```
Import the reducer types:
```python
from collections.abc import Mapping
from wf_core.runtime.ops.merges import ReducerDefinition
```
- [ ] **Step 2: Pass reducers from runtime step dispatch**
In `src/wf_core/runtime/step.py`, update both sync and async foreach dispatch:
```python
elif isinstance(step, ForeachNode):
return step_foreach(workflow, run, step, index, reducers=reducers)
```
- [ ] **Step 3: Add concurrent policy guard**
Add:
```python
def _step_foreach_concurrent(
workflow: Workflow,
run: RunState,
step: ForeachNode,
index: WorkflowIndex,
*,
reducers: Mapping[str, ReducerDefinition] | None = None,
) -> RunState:
if step.item_error.action != "fail":
raise WorkflowExecutionError(
"concurrent foreach v1 only supports item_error.action='fail'"
)
raise WorkflowExecutionError("concurrent foreach execution is not implemented yet")
```
- [ ] **Step 4: Verify the non-fail policy test passes**
Run:
```bash
uv run pytest tests/core/test_concurrent_foreach.py::test_sync_concurrent_foreach_rejects_non_fail_item_policy_for_now -q
```
Expected: pass.
---
### Task 6: Admit Concurrent Child Frames
**Files:**
- Modify: `src/wf_core/runtime/ops/foreach.py`
- Test: `tests/core/test_concurrent_foreach.py`
- [ ] **Step 1: Add admission helpers**
In `src/wf_core/runtime/ops/foreach.py`, add:
```python
def _resolve_foreach_iterable(run: RunState, frame: ExecutionFrame, step: ForeachNode) -> list[object]:
iterable = safe_resolve_path(
str(step.over),
state=run.state,
workflow_input=run.workflow_input,
context=frame_context_values(frame),
)
if not isinstance(iterable, list):
raise WorkflowExecutionError(
f"foreach source {str(step.over)!r} must resolve to a list"
)
return iterable
```
Use this helper in both serial and concurrent code.
- [ ] **Step 2: Implement `_admit_concurrent_children`**
Add:
```python
def _admit_concurrent_children(
*,
run: RunState,
frame: ExecutionFrame,
step: ForeachNode,
index: WorkflowIndex,
barrier: ForeachBarrierState,
iterable: list[object],
) -> int:
assert step.concurrent is not None
admitted = 0
loop_start = index.next_node_id(frame.node_id, "loop")
while (
barrier.next_index < len(iterable)
and len(barrier.active_frame_ids) < step.concurrent.max_active
and len(barrier.outstanding_frame_ids) < step.concurrent.max_outstanding
):
loop_index = barrier.next_index
item = iterable[loop_index]
child_id = f"{frame.id}:{step.id}:{loop_index}"
barrier.next_index = loop_index + 1
barrier.start_child(child_id)
add_frame(
run,
ExecutionFrame(
id=child_id,
kind="foreach_iteration",
node_id=loop_start,
status=FrameStatus.PENDING,
parent_frame_id=frame.id,
metadata=ForeachIterationMetadata(
foreach_node_id=step.id,
loop_index=loop_index,
loop_item=item,
loop_alias=step.as_,
).to_metadata(),
),
ready=True,
)
append_step_result_trace(
run,
frame_id=frame.id,
node_id=frame.node_id,
step_type=step.type,
next_node_id=loop_start,
result=StepExecutionResult(
outcome="loop",
resolved_input={
"item": item,
"index": loop_index,
"active_count": len(barrier.active_frame_ids) - 1,
},
output={},
state_changes={},
),
)
admitted += 1
return admitted
```
- [ ] **Step 3: Implement initial concurrent step body**
Replace `_step_foreach_concurrent` with:
```python
def _step_foreach_concurrent(
workflow: Workflow,
run: RunState,
step: ForeachNode,
index: WorkflowIndex,
) -> RunState:
if step.item_error.action != "fail":
raise WorkflowExecutionError(
"concurrent foreach v1 only supports item_error.action='fail'"
)
if step.concurrent is None:
raise WorkflowExecutionError("concurrent foreach requires concurrent policy")
frame = run.current_frame()
barrier = ForeachBarrierState.from_frame(frame, step.id) or ForeachBarrierState()
iterable = _resolve_foreach_iterable(run, frame, step)
_admit_concurrent_children(
run=run,
frame=frame,
step=step,
index=index,
barrier=barrier,
iterable=iterable,
)
barrier.save_to_frame(frame, step.id)
block_frame_on_children(run, frame.id, barrier.outstanding_frame_ids)
run.sync_from_current_frame()
return run
```
This still does not finish; the next task handles child completion/refill/finish.
- [ ] **Step 4: Run tests and inspect failure**
Run:
```bash
uv run pytest tests/core/test_concurrent_foreach.py::test_sync_concurrent_foreach_interleaves_items_and_commits_at_barrier -q
```
Expected: failure after first admitted children complete, likely deadlock or no barrier commit.
---
### Task 7: Refill and Finish Concurrent Foreach
**Files:**
- Modify: `src/wf_core/runtime/ops/state.py`
- Modify: `src/wf_core/runtime/ops/foreach.py`
- Modify: `src/wf_core/runtime/ops/flow.py` or current child-completion caller if needed
- Test: `tests/core/test_concurrent_foreach.py`
- [ ] **Step 1: Ensure child completion wakes parent for refill**
In `src/wf_core/runtime/ops/flow.py`, `advance_frame(...)` currently calls
`wake_parent_if_children_complete(run, frame.id)` after a frame reaches `END`.
Replace or augment it with:
```python
wake_parent_for_child_progress(run, frame.id)
```
Import from `wf_core.runtime.scheduler`.
Expected behavior:
- Serial foreach still works because waking the parent after its only child completes is equivalent.
- Concurrent foreach parent wakes after each child completion so it can mark that child finished and refill one slot.
- [ ] **Step 2: Add child-finish cleanup in concurrent step**
At the start of `_step_foreach_concurrent`, after loading `barrier`, add:
```python
for child_id in tuple(barrier.outstanding_frame_ids):
child = run.frames[child_id]
if child.status == FrameStatus.COMPLETED:
barrier.finish_child(child_id)
elif child.status == FrameStatus.FAILED:
raise WorkflowExecutionError(
f"concurrent foreach item frame {child_id!r} failed"
)
```
- [ ] **Step 3: Add deterministic barrier patch helper**
In `src/wf_core/runtime/ops/state.py`, add:
```python
def build_barrier_patch(
workflow: Workflow,
item_patches: Sequence[StatePatch],
state: dict[str, Any],
*,
reducers: Mapping[str, ReducerDefinition] | None = None,
) -> StatePatch:
"""Build one committed barrier patch by replaying item writes in order.
Item patches were built against parent-visible state, so reducer-prepared
writes inside each item patch cannot be blindly merged together. The barrier
must replay the trace-facing incoming changes against one staged state in
deterministic item order.
"""
state_fields = workflow.state_schema.field_index()
staged_state = deepcopy(state)
prepared_patch: dict[StatePath, tuple[list[str], Any]] = {}
committed_changes: dict[str, Any] = {}
for item_patch in item_patches:
for destination, incoming_value in item_patch.changes.items():
destination_path = StatePath.parse(destination)
key_path, merged_value = prepare_state_value(
workflow,
staged_state,
destination_path,
incoming_value,
reducers=reducers,
state_fields=state_fields,
)
safe_set_nested_value(staged_state, key_path, merged_value)
prepared_patch[destination_path] = (key_path, merged_value)
committed_changes[destination] = merged_value
validate_staged_state_patch(staged_state, prepared_patch, state_fields)
return StatePatch(
changes=committed_changes,
_prepared_writes=prepared_patch,
_staged_state=staged_state,
)
```
- [ ] **Step 4: Add finish helper**
Add:
```python
def _finish_concurrent_foreach(
*,
workflow: Workflow,
run: RunState,
frame: ExecutionFrame,
step: ForeachNode,
index: WorkflowIndex,
barrier: ForeachBarrierState,
reducers: Mapping[str, ReducerDefinition] | None = None,
) -> RunState:
next_node_id = index.next_node_id(frame.node_id, "done")
combined = build_barrier_patch(
workflow,
[
barrier.pending_results[item_index].patch
for item_index in sorted(barrier.pending_results)
],
run.state,
reducers=reducers,
)
state_changes = commit_state_patch(run.state, combined)
append_step_result_trace(
run,
frame_id=frame.id,
node_id=frame.node_id,
step_type=step.type,
next_node_id=next_node_id,
result=StepExecutionResult(
outcome="done",
resolved_input={
"count": barrier.next_index,
"index": barrier.next_index,
"committed_items": len(barrier.pending_results),
},
output={},
state_changes=state_changes,
),
)
advance_frame(run, frame, outcome="done", next_node_id=next_node_id)
return run
```
Also import `build_barrier_patch`, `commit_state_patch`, `ReducerDefinition`, and
`Mapping` where needed.
- [ ] **Step 5: Finish when no more work remains**
In `_step_foreach_concurrent`, after cleanup and admission:
```python
if barrier.next_index >= len(iterable) and not barrier.outstanding_frame_ids:
return _finish_concurrent_foreach(
workflow=workflow,
run=run,
frame=frame,
step=step,
index=index,
barrier=barrier,
reducers=reducers,
)
```
If not finished, save barrier and block parent on outstanding children.
- [ ] **Step 6: Run V1 tests**
Run:
```bash
uv run pytest tests/core/test_concurrent_foreach.py -q
```
Expected: the first two tests pass; runtime-error test may still need failure propagation adjustment.
---
### Task 8: Preserve Runtime Failure Semantics
**Files:**
- Modify: `src/wf_core/runtime/engine.py`
- Modify: `src/wf_core/runtime/ops/foreach.py`
- Test: `tests/core/test_concurrent_foreach.py`
- [ ] **Step 1: Verify existing engine behavior**
Run:
```bash
uv run pytest tests/core/test_concurrent_foreach.py::test_sync_concurrent_foreach_fails_run_on_item_runtime_error -q
```
Expected before this task: either pass with the original exception or fail with deadlock. If it passes, skip to Task 9.
- [ ] **Step 2: If child failure becomes deadlock, fail parent on failed child**
In `_step_foreach_concurrent`, keep the failed child check:
```python
elif child.status == FrameStatus.FAILED:
raise WorkflowExecutionError(
f"concurrent foreach item frame {child_id!r} failed"
)
```
If the original exception text is lost, update engine frame-failure metadata in the existing exception handler so the parent error includes child error text:
```python
frame.metadata["error"] = str(exc)
```
Then use:
```python
message = child.metadata.get("error", "unknown item failure")
raise WorkflowExecutionError(
f"concurrent foreach item frame {child_id!r} failed: {message}"
)
```
- [ ] **Step 3: Verify failure test**
Run:
```bash
uv run pytest tests/core/test_concurrent_foreach.py::test_sync_concurrent_foreach_fails_run_on_item_runtime_error -q
```
Expected: pass.
---
### Task 9: Regression and Verification
**Files:**
- Modify: docs only if implementation differs from plan.
- [ ] **Step 1: Run focused core tests**
Run:
```bash
uv run pytest tests/core/test_concurrent_foreach.py tests/core/test_foreach_barrier_state.py tests/core/test_scheduler.py tests/core/test_atomic_state_patches.py -q
```
Expected: pass.
- [ ] **Step 2: Run authoring regression tests**
Run:
```bash
uv run pytest tests/authoring/test_demo_workflow.py tests/authoring/test_builder.py -q
```
Expected: pass.
- [ ] **Step 3: Run full suite**
Run:
```bash
uv run pytest -q
```
Expected: pass, except known environment-only skips.
- [ ] **Step 4: Run lint and type checks**
Run:
```bash
uvx ruff check src tests
uv run basedpyright --level error
```
Expected: ruff passes and basedpyright reports 0 errors.
---
## Self-Review
- Spec coverage: this plan implements only sync `mode="concurrent"` with `item_error.action="fail"`, frame admission/refill, buffered item patches, barrier commit, and fail-fast runtime behavior.
- Placeholder scan: async execution, `skip`, `collect`, reducer conflict policy, and interrupt quiescence are explicitly out of scope and covered by the Phase 4 roadmap.
- Type consistency: the plan uses existing `ForeachBarrierState`, `PendingItemResult`, `StatePatch`, `ForeachConcurrentPolicy`, scheduler frame helpers, and canonical `mode="concurrent"` vocabulary.