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

33 KiB

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:

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:

uv run pytest tests/core/test_concurrent_foreach.py -q

Expected:

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:

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:

uv run pytest tests/core/test_foreach_barrier_state.py -q

Expected:

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:

    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:

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:

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:

uv run pytest tests/core/test_scheduler.py::test_wake_parent_when_child_finishes_for_refill -q

Expected:

ImportError or NameError for wake_parent_for_child_progress
  • Step 3: Implement wake helper

In src/wf_core/runtime/scheduler.py, add:

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:

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:

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:

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:

from wf_core.runtime.scheduler import ForeachIterationMetadata
  • Step 2: Add pending result upsert helper

In ForeachBarrierState, add:

    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:

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:

    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:

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:

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:

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:

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:

elif isinstance(step, ForeachNode):
    return step_foreach(workflow, run, step, index, reducers=reducers)
  • Step 3: Add concurrent policy guard

Add:

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:

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:

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:

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:

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:

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:

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:

    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:

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:

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:

    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:

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:

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:

        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:

frame.metadata["error"] = str(exc)

Then use:

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:

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:

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:

uv run pytest tests/authoring/test_demo_workflow.py tests/authoring/test_builder.py -q

Expected: pass.

  • Step 3: Run full suite

Run:

uv run pytest -q

Expected: pass, except known environment-only skips.

  • Step 4: Run lint and type checks

Run:

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.