Files
lda-wf/docs/historical/superpowers/plans/2026-05-22-concurrent-foreach-item-overlays.md
T

19 KiB

Concurrent Foreach Item Overlays 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: Make concurrent foreach item frames read their own buffered state writes so multi-step item bodies are safe.

Architecture: Keep RunState.state as committed parent state. Store item-local overlay patches in the foreach parent barrier, keyed by item index/frame id. state_view_for_frame(...) should return parent state plus that item's staged overlay; sibling overlays remain invisible. This plan does not implement sibling write conflict policy; Slice 3 owns write semantics across different item lineages.

Tech Stack: Python 3.14, dataclasses, pytest, existing StatePatch, ForeachBarrierState, safe_resolve_path, and runtime scheduler modules.


Boundary With Slice 3

This slice answers:

  • Can node B in the same concurrent item read node A's buffered state write?
  • Can multi-step item bodies run without reading stale parent state?
  • Are sibling item overlays isolated from each other?

This slice does not answer:

  • Should two sibling items be allowed to write the same state path without a reducer?
  • Should ancestor/descendant sibling writes conflict?
  • Should barrier trace state_changes show raw per-item inputs or final aggregate values?

Those are Slice 3 write semantics. Do not add broad write-conflict policy here except what is already enforced by build_output_patch(...) for a single node output.


Files

  • Modify: src/wf_core/runtime/foreach_state.py
    • Accumulate successful item patches per item instead of storing only one patch.
    • Expose helpers to get item overlay patches by frame/index.
  • Modify: src/wf_core/runtime/ops/overlays.py
    • Replace the current no-op seam with parent-state plus item-local staged writes.
  • Modify: src/wf_core/runtime/ops/nodes.py
    • Build output patches against the frame-visible state view, not always run.state.
    • Append item-local patches for concurrent item frames.
  • Modify: src/wf_core/runtime/ops/foreach.py
    • Remove the single-node item-body guard.
    • Keep item_error.action != "fail" unsupported.
  • Test: tests/core/test_concurrent_foreach.py
    • Add multi-step item-body tests.
  • Test: tests/core/test_foreach_barrier_state.py
    • Add item patch accumulation tests.

Task 1: Add Failing Multi-Step Overlay Tests

Files:

  • Modify: tests/core/test_concurrent_foreach.py

  • Step 1: Add a two-node item body test

Append this test:

def test_sync_concurrent_foreach_item_reads_own_buffered_write() -> None:
    workflow = _workflow(
        state_schema=StateSchema.from_field_map(
            {
                "items": StateField(type="array"),
                "scratch": StateField(type="string"),
                "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},
            }
        ),
    )
    workflow.node_defs.extend(
        [
            NodeDef(
                name="read_scratch",
                input_schema=SchemaRef(
                    type="object",
                    properties={"scratch": {}},
                    required=["scratch"],
                ),
                output_schema=SchemaRef(
                    type="object",
                    properties={"seen": {}},
                    required=["seen"],
                ),
                outcomes=["ok"],
            )
        ]
    )
    workflow.nodes.append(
        NodeUse.model_validate(
            {
                "id": "read_scratch",
                "type": "node",
                "node": "read_scratch",
                "input": [{"target": "scratch", "path": "state.scratch"}],
                "output": [{"source": "seen", "target": "state.seen"}],
            }
        )
    )
    workflow.edges = [
        Edge.model_validate({"from": "each", "outcome": "loop", "to": "record"}),
        Edge.model_validate({"from": "record", "outcome": "ok", "to": "read_scratch"}),
        Edge.model_validate({"from": "read_scratch", "outcome": "ok", "to": END}),
        Edge.model_validate({"from": "each", "outcome": "done", "to": END}),
    ]

    run = execute_workflow(
        workflow,
        {"items": ["a", "b", "c"]},
        {
            "record": lambda payload, _ctx: {
                "outcome": "ok",
                "output": {"scratch": f"scratch:{payload['value']}"},
            },
            "read_scratch": lambda payload, _ctx: {
                "outcome": "ok",
                "output": {"seen": payload["scratch"]},
            },
        },
    )

    assert run.state["seen"] == ["scratch:a", "scratch:b", "scratch:c"]

Important detail: do not mutate the generic _workflow(...) helper for this test. Create a dedicated helper such as _multi_step_overlay_workflow() whose record NodeUse writes scratch to state.scratch. The test is specifically about an item-local write followed by an item-local read, so the workflow shape should be self-contained and obvious.

  • Step 2: Add a sibling isolation test

Append this test:

def test_sync_concurrent_foreach_sibling_overlays_do_not_leak() -> None:
    workflow = _workflow(
        state_schema=StateSchema.from_field_map(
            {
                "items": StateField(type="array"),
                "scratch": StateField(type="string"),
                "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},
            }
        ),
    )
    workflow.node_defs.extend(
        [
            NodeDef(
                name="read_scratch",
                input_schema=SchemaRef(
                    type="object",
                    properties={"scratch": {}},
                    required=["scratch"],
                ),
                output_schema=SchemaRef(
                    type="object",
                    properties={"seen": {}},
                    required=["seen"],
                ),
                outcomes=["ok"],
            )
        ]
    )
    workflow.nodes.append(
        NodeUse.model_validate(
            {
                "id": "read_scratch",
                "type": "node",
                "node": "read_scratch",
                "input": [{"target": "scratch", "path": "state.scratch"}],
                "output": [{"source": "seen", "target": "state.seen"}],
            }
        )
    )
    workflow.edges = [
        Edge.model_validate({"from": "each", "outcome": "loop", "to": "record"}),
        Edge.model_validate({"from": "record", "outcome": "ok", "to": "read_scratch"}),
        Edge.model_validate({"from": "read_scratch", "outcome": "ok", "to": END}),
        Edge.model_validate({"from": "each", "outcome": "done", "to": END}),
    ]

    run = execute_workflow(
        workflow,
        {"items": ["a", "b"]},
        {
            "record": lambda payload, _ctx: {
                "outcome": "ok",
                "output": {"scratch": payload["value"]},
            },
            "read_scratch": lambda payload, _ctx: {
                "outcome": "ok",
                "output": {"seen": payload["scratch"]},
            },
        },
    )

    assert run.state["seen"] == ["a", "b"]

This catches the bad implementation where item b sees item a's staged write or vice versa.

  • Step 3: Run tests and verify failure

Run:

uv run pytest tests/core/test_concurrent_foreach.py::test_sync_concurrent_foreach_item_reads_own_buffered_write tests/core/test_concurrent_foreach.py::test_sync_concurrent_foreach_sibling_overlays_do_not_leak -q

Expected before implementation:

FAILED with "concurrent foreach v1 only supports loop bodies with one node"

or, if the guard was already removed by another worker:

FAILED because state.scratch is missing/stale

Task 2: Accumulate Per-Item Patches

Files:

  • Modify: src/wf_core/runtime/foreach_state.py

  • Modify: tests/core/test_foreach_barrier_state.py

  • Step 1: Add patch accumulation tests

Append to tests/core/test_foreach_barrier_state.py:

def test_foreach_barrier_accumulates_multiple_patches_for_one_item() -> None:
    barrier = ForeachBarrierState(mode="concurrent")

    barrier.add_success_patch(
        index=0,
        frame_id="child-0",
        patch=StatePatch(changes={"state.scratch": "a"}),
    )
    barrier.add_success_patch(
        index=0,
        frame_id="child-0",
        patch=StatePatch(changes={"state.seen": "a"}),
    )

    result = barrier.pending_results[0]
    assert result.patch.changes["state.scratch"] == "a"
    assert result.patch.changes["state.seen"] == "a"
  • Step 2: Run test and verify failure

Run:

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

Expected before implementation:

FAILED with "already recorded"

After this plan is implemented, both tests should pass.

  • Step 3: Replace duplicate rejection with patch merge

In src/wf_core/runtime/foreach_state.py, change add_success_patch(...) to merge changes for the same item:

    def add_success_patch(
        self, *, index: int, frame_id: str, patch: StatePatch
    ) -> None:
        """Buffer or extend successful item patches by item index.

        A multi-step item body may produce multiple node patches. They are
        accumulated for the same item lineage and replayed by the barrier in
        item index order. Overlap inside one item remains governed by the normal
        node output patch rules for each node; Slice 3 owns sibling conflict
        policy at the barrier.
        """
        existing = self.pending_results.get(index)
        if existing is None:
            self.pending_results[index] = PendingItemResult(
                index=index,
                frame_id=frame_id,
                status="succeeded",
                patch=patch,
            )
            return
        if existing.frame_id != frame_id:
            raise WorkflowExecutionError(
                f"foreach item result for index {index!r} belongs to frame "
                f"{existing.frame_id!r}, got {frame_id!r}"
            )
        existing.patch.changes.update(patch.changes)

This intentionally updates only changes; the barrier replays changes into a fresh staged state later. Do not try to merge _prepared_writes here.

  • Step 4: Update duplicate test

Replace the prior duplicate-item-result test with a frame-mismatch test:

def test_foreach_barrier_rejects_item_result_frame_mismatch() -> None:
    barrier = ForeachBarrierState(mode="concurrent")
    patch = StatePatch(changes={"state.count": 1})

    barrier.add_success_patch(index=0, frame_id="child-0", patch=patch)

    with pytest.raises(WorkflowExecutionError, match="belongs to frame"):
        barrier.add_success_patch(index=0, frame_id="child-1", patch=patch)
  • Step 5: Verify barrier tests

Run:

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

Expected: pass.


Task 3: Build Item-Local State Views

Files:

  • Modify: src/wf_core/runtime/ops/overlays.py

  • Test: tests/core/test_concurrent_foreach.py

  • Step 1: Implement overlay state view

Replace state_view_for_frame(...) in src/wf_core/runtime/ops/overlays.py:

from __future__ import annotations

from copy import deepcopy
from typing import Any

from wf_core.run_state import ExecutionFrame, RunState
from wf_core.runtime.foreach_state import ForeachBarrierState, item_frame_owner
from wf_core.runtime.ops.state import safe_set_nested_value
from wf_core.paths import StatePath


def state_view_for_frame(run: RunState, frame: ExecutionFrame) -> dict[str, Any]:
    """Return committed parent state plus this frame's item-local overlay.

    Concurrent foreach item frames buffer writes in the parent barrier until the
    foreach barrier commits. Later nodes in the same item must still read those
    earlier writes, while sibling item frames must not see them.
    """
    owner = item_frame_owner(frame)
    if owner is None:
        return run.state

    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 None or barrier.mode != "concurrent":
        return run.state

    pending = barrier.pending_results.get(item_index)
    if pending is None:
        return run.state

    state_view = deepcopy(run.state)
    for destination, value in pending.patch.changes.items():
        path = StatePath.parse(destination)
        safe_set_nested_value(state_view, list(path.parts), value)
    return state_view

Do not apply reducers here. The overlay view is an item-local read model, not the final parent commit. Reducers are applied at patch build time for each node and again at the barrier for aggregate commit.

  • Step 2: Verify overlay tests still fail on guard

Run:

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

Expected: if the single-node guard is still present, failure remains the guard. If guard was removed by another worker, this may already pass.


Task 4: Build Output Patches Against Frame State View

Files:

  • Modify: src/wf_core/runtime/ops/nodes.py

  • Test: tests/core/test_concurrent_foreach.py

  • Step 1: Reuse the resolved state view for output patching

Currently _resolve_node_execution(...) computes state_view but returns only input/context. Change it to return the state view too:

) -> tuple[dict[str, Any], RuntimeContext, dict[str, Any]]:

Return:

    return resolved_input, context, state_view

Update both callers:

resolved_input, context, state_view = _resolve_node_execution(...)

Then pass state_view into _finalize_node_execution(...):

        state=state_view,

Add a parameter to _finalize_node_execution(...):

    state_view: dict[str, Any],

And change build_output_patch(...) call from:

        run.state,

to:

        state_view,

This is required for node B in one item to build a patch using node A's staged value.

  • Step 2: Run focused overlay test

Run:

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

Expected: still fails until the single-node guard is removed.


Task 5: Lift The Single-Node Concurrent Body Restriction

Files:

  • Modify: src/wf_core/runtime/ops/foreach.py

  • Modify: tests/core/test_concurrent_foreach.py

  • Step 1: Remove the old rejection test

Delete or rewrite test_sync_concurrent_foreach_rejects_multi_step_item_body_for_now.

If preserving regression coverage is preferred, replace it with:

def test_sync_concurrent_foreach_allows_multi_step_item_body_with_overlay() -> None:
    # Use the same workflow shape as
    # test_sync_concurrent_foreach_item_reads_own_buffered_write.
    # Assert the workflow completes and output contains all expected values.

Prefer not duplicating the full workflow; reuse the dedicated helper introduced for the overlay read/write tests:

def _multi_step_overlay_workflow() -> Workflow:
    ...
  • Step 2: Remove validation call and helper

In src/wf_core/runtime/ops/foreach.py, remove:

    _validate_single_node_loop_body(index, step)

Delete _validate_single_node_loop_body(...).

Remove unused imports:

from wf_core.models.steps import NodeUse
from wf_core.tokens import END

Keep graph traversal validation out of this slice. Normal workflow validation and runtime edge lookup still define whether graph topology is routable.

  • Step 3: Verify multi-step overlay tests

Run:

uv run pytest tests/core/test_concurrent_foreach.py::test_sync_concurrent_foreach_item_reads_own_buffered_write tests/core/test_concurrent_foreach.py::test_sync_concurrent_foreach_sibling_overlays_do_not_leak -q

Expected: pass.


Task 6: Document Overlay Semantics

Files:

  • Modify: docs/adr/0002-concurrent-foreach-policy-and-barrier-commits.md

  • Modify: docs/superpowers/plans/2026-05-22-concurrent-foreach-phase4-roadmap.md

  • Step 1: Update ADR current-state note

In docs/adr/0002-concurrent-foreach-policy-and-barrier-commits.md, verify the barrier commit section contains:

Current sync execution supports item-local read overlays for concurrent foreach
item frames. `RunState.state` remains committed parent state, while
`state_view_for_frame` overlays the current item's buffered writes for reads by
later nodes in the same item lineage. Sibling overlays remain invisible until
the foreach barrier commits.
  • Step 2: Verify roadmap slice statuses

In docs/superpowers/plans/2026-05-22-concurrent-foreach-phase4-roadmap.md, verify Slice 1 is marked implemented and this plan is linked under Slice 2.

Use:

Plan:

- See [`2026-05-22-concurrent-foreach-item-overlays.md`](2026-05-22-concurrent-foreach-item-overlays.md).
  • Step 3: Verify docs reference no stale limitation

Run:

rg -n "one node|no-op overlay|multi-step concurrent item bodies are rejected" docs src tests

Expected: no stale claims except historical plan text in the already-completed V1 plan.


Task 7: Verification

Files:

  • No new files unless tests require helper extraction.

  • 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 smoke tests

Run:

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

Expected: pass.

  • Step 3: Run full suite

Run:

uv run pytest -q

Expected: pass, allowing known intentional environment-only skips.

  • Step 4: Run lint/type/format checks

Run:

uvx ruff check src tests
uvx ruff format --check src tests docs
uv run basedpyright --level error src tests

Expected: all pass with 0 type errors.


Self-Review

  • Spec coverage: the plan makes item-local overlays real, supports multi-step concurrent item bodies, keeps sibling overlays isolated, and explicitly defers sibling write conflict policy.
  • Placeholder scan: no task uses “TBD” or “add tests” without concrete test content.
  • Type consistency: the plan uses current names: ForeachBarrierState, PendingItemResult, StatePatch, state_view_for_frame, item_frame_owner, and build_output_patch.