From f28e1cc6b4865de54ca936d2dfac8a74977d4206 Mon Sep 17 00:00:00 2001 From: lda Date: Fri, 4 Sep 2026 18:32:36 +0700 Subject: [PATCH] fix: inherit ancestor writes in nested foreach --- src/wf_core/runtime/lineage.py | 11 +-- tests/core/test_foreach_back_edges.py | 115 ++++++++++++++++++++++++++ 2 files changed, 121 insertions(+), 5 deletions(-) diff --git a/src/wf_core/runtime/lineage.py b/src/wf_core/runtime/lineage.py index dcbe077a..e5657af3 100644 --- a/src/wf_core/runtime/lineage.py +++ b/src/wf_core/runtime/lineage.py @@ -43,14 +43,15 @@ class LineageStateView: def lineage_writes_for_frame( run: RunState, frame: ExecutionFrame ) -> 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 - lookup here gives future `RunState.lineages` or subgraph scopes one place to - plug in without making node execution understand foreach internals. + An empty child lineage still inherits writes buffered by its ancestors, as + happens when an outer concurrent foreach writes before entering an inner + foreach. Lineage existence and scope therefore control traversal; the + current lineage having its own writes does not. """ 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( lineage_state_writes( run, scope_id=frame.scope_id, lineage_id=frame.lineage_id diff --git a/tests/core/test_foreach_back_edges.py b/tests/core/test_foreach_back_edges.py index bdc368f5..ca19e327 100644 --- a/tests/core/test_foreach_back_edges.py +++ b/tests/core/test_foreach_back_edges.py @@ -539,6 +539,121 @@ def test_nested_foreach_preserves_inner_writes_in_all_modes( assert sorted(seen, key=repr) == sorted([1, 2, 1, 2], key=repr) +@pytest.mark.parametrize("inner_mode", ["serial", "concurrent"]) +def test_nested_item_reads_buffered_ancestor_state(inner_mode: str) -> None: + """An inner item inherits the enclosing concurrent item's state view.""" + inner_payload: dict[str, Any] = { + "id": "inner", + "type": "foreach", + "over": "state.inner_items", + "as": "inner_item", + "mode": inner_mode, + } + if inner_mode == "concurrent": + inner_payload["concurrent"] = {"max_active": 1, "max_outstanding": 1} + workflow = Workflow( + name="nested_foreach_reads_ancestor_state", + input_schema=SchemaRef(type="object", properties={}), + state_schema=StateSchema.from_field_map( + { + "items": StateField(type="array"), + "inner_items": StateField(type="array"), + "marker": StateField(type="string", default="root"), + "seen": StateField( + type="array", reducer=ReducerRef(name="wf.std.append") + ), + } + ), + output_schema=SchemaRef(type="object", properties={"seen": {"type": "array"}}), + node_defs=[ + NodeDef( + name="write_marker", + input_schema=SchemaRef( + type="object", properties={"marker": {}}, required=["marker"] + ), + output_schema=SchemaRef( + type="object", properties={"marker": {}}, required=["marker"] + ), + outcomes=["ok"], + ), + NodeDef( + name="observe_marker", + input_schema=SchemaRef( + type="object", properties={"marker": {}}, required=["marker"] + ), + output_schema=SchemaRef( + type="object", properties={"seen": {}}, required=["seen"] + ), + outcomes=["ok"], + ), + ], + start="outer", + nodes=[ + ForeachNode.model_validate( + { + "id": "outer", + "type": "foreach", + "over": "state.items", + "as": "outer_item", + "mode": "concurrent", + "concurrent": {"max_active": 1, "max_outstanding": 1}, + } + ), + NodeUse.model_validate( + { + "id": "write_outer", + "type": "node", + "node": "write_marker", + "input": [{"target": "marker", "path": "context.outer_item"}], + "output": [{"source": "marker", "target": "state.marker"}], + } + ), + ForeachNode.model_validate(inner_payload), + NodeUse.model_validate( + { + "id": "read_inner", + "type": "node", + "node": "observe_marker", + "input": [{"target": "marker", "path": "state.marker"}], + "output": [{"source": "seen", "target": "state.seen"}], + } + ), + ], + edges=[ + Edge.model_validate( + {"from": "outer", "outcome": "loop", "to": "write_outer"} + ), + Edge.model_validate( + {"from": "write_outer", "outcome": "ok", "to": "inner"} + ), + Edge.model_validate( + {"from": "inner", "outcome": "loop", "to": "read_inner"} + ), + Edge.model_validate({"from": "read_inner", "outcome": "ok", "to": "inner"}), + Edge.model_validate({"from": "inner", "outcome": "done", "to": "outer"}), + Edge.model_validate({"from": "outer", "outcome": "done", "to": END}), + ], + ) + + run = execute_workflow( + workflow, + {"items": ["outer"], "inner_items": [1]}, + { + "write_marker": lambda payload, _ctx: { + "outcome": "ok", + "output": {"marker": payload["marker"]}, + }, + "observe_marker": lambda payload, _ctx: { + "outcome": "ok", + "output": {"seen": payload["marker"]}, + }, + }, + ) + + assert run.status == RunStatus.COMPLETED + assert run.state["seen"] == ["outer"] + + def _three_level_workflow( *, outer_mode: str, middle_mode: str, inner_mode: str ) -> Workflow: