feat: return foreach items through owner back-edges

This commit is contained in:
lda
2026-09-04 07:40:49 +07:00 Verified
parent f69c4502cf
commit f23e760b5f
13 changed files with 902 additions and 21 deletions
+1 -1
View File
@@ -210,7 +210,7 @@ def build_authoring_demo_workflow():
builder.connect(list_files, "ok", summarize_each)
builder.connect(summarize_each, "loop", summarize_one)
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(should_email, "true", approve_email)
builder.connect(should_email, "false", skip_email)
+31 -11
View File
@@ -286,12 +286,14 @@ def test_sync_concurrent_foreach_barrier_replays_add_reducer_inputs() -> None:
assert run.output["number"] == 6
assert run.lineages["root:each#0[0]"].writes[0].incoming_value == 3
assert run.lineages["root:each#0[1]"].writes[0].incoming_value == 1
active = load_or_begin_foreach_activation(
# The visit closed before `done`; lineage history remains while a new
# load starts fresh barrier state with a new activation id.
fresh = load_or_begin_foreach_activation(
run.frames["root"], "each", mode="concurrent"
)
assert active.id == "root:each#0"
assert active.barrier.pending_results[0].lineage_id == "root:each#0[0]"
assert active.barrier.pending_results[0].patch.writes == []
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"]
assert foreach_entries[-1].state_changes["state.number"] == 6
@@ -407,7 +409,7 @@ def _sum_items_workflow() -> Workflow:
],
edges=[
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}),
],
)
@@ -517,7 +519,7 @@ def _same_item_reducer_visibility_workflow() -> Workflow:
"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}),
],
)
@@ -593,6 +595,15 @@ def _nested_foreach_lineage_workflow() -> Workflow:
"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=[
Edge.model_validate(
@@ -609,8 +620,13 @@ def _nested_foreach_lineage_workflow() -> Workflow:
"to": "record",
}
),
Edge.model_validate({"from": "record", "outcome": "ok", "to": END}),
Edge.model_validate({"from": "inner_each", "outcome": "done", "to": END}),
Edge.model_validate(
{"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}),
],
)
@@ -624,7 +640,7 @@ def _workflow(
) -> Workflow:
edges = [
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}),
]
if include_completed_with_errors:
@@ -794,7 +810,9 @@ def _multi_step_overlay_workflow() -> Workflow:
"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}),
],
)
@@ -861,7 +879,9 @@ def _same_path_replace_workflow() -> Workflow:
"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}),
],
)
+1 -1
View File
@@ -123,7 +123,7 @@ def _workflow(*, max_active: int) -> Workflow:
],
edges=[
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}),
],
)
+1 -1
View File
@@ -144,7 +144,7 @@ def _workflow(*, item_error: dict[str, object]) -> Workflow:
],
edges=[
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(
{
@@ -52,8 +52,15 @@ async def test_resume_prioritizes_interrupted_item_before_siblings() -> None:
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.state["seen"] == ["a", "b", "c"]
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].outcome == "submitted"
@@ -132,7 +139,7 @@ def _workflow() -> Workflow:
],
edges=[
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(
{
"from": "route",
@@ -140,7 +147,7 @@ def _workflow() -> Workflow:
"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}),
],
)
+750
View File
@@ -0,0 +1,750 @@
from __future__ import annotations
from typing import Any
import pytest
from wf_core import (
END,
ConditionNode,
Edge,
ForeachNode,
NodeDef,
NodeUse,
ReducerRef,
SchemaRef,
StateField,
StateSchema,
SubgraphNode,
Workflow,
WorkflowExecutionError,
execute_workflow,
)
from wf_core.run_state import ExecutionFrame, FrameStatus, RunState, RunStatus
from wf_core.runtime.foreach_state import item_frame_owner
from wf_core.runtime.scheduler import add_frame
def _node_use(node_id: str, *, node: str = "record") -> NodeUse:
return NodeUse.model_validate(
{
"id": node_id,
"type": "node",
"node": node,
"input": [{"target": "value", "path": "context.item"}],
"output": [{"source": "seen", "target": "state.seen"}],
}
)
def _serial_workflow() -> Workflow:
foreach = ForeachNode.model_validate(
{
"id": "each",
"type": "foreach",
"over": "state.items",
"as": "item",
"mode": "serial",
}
)
return Workflow(
name="foreach_back_edge",
input_schema=SchemaRef(type="object", properties={"items": {"type": "array"}}),
state_schema=StateSchema.from_field_map(
{
"items": StateField(type="array"),
"seen": StateField(
type="array", reducer=ReducerRef(name="wf.std.append")
),
}
),
output_schema=SchemaRef(type="object", properties={"seen": {"type": "array"}}),
node_defs=[
NodeDef(
name="record",
input_schema=SchemaRef(
type="object", properties={"value": {}}, required=["value"]
),
output_schema=SchemaRef(
type="object", properties={"seen": {}}, required=["seen"]
),
outcomes=["ok"],
)
],
start="each",
nodes=[
foreach,
NodeUse.model_validate(
{
"id": "work",
"type": "node",
"node": "record",
"input": [{"target": "value", "path": "context.item"}],
"output": [{"source": "seen", "target": "state.seen"}],
}
),
],
edges=[
Edge.model_validate({"from": "each", "outcome": "loop", "to": "work"}),
Edge.model_validate({"from": "work", "outcome": "ok", "to": "each"}),
Edge.model_validate({"from": "each", "outcome": "done", "to": END}),
],
)
def test_serial_item_return_wakes_parent_and_admits_next_item() -> None:
workflow = _serial_workflow()
run = execute_workflow(
workflow,
{"items": ["a", "b"]},
{
"record": lambda payload, _ctx: {
"outcome": "ok",
"output": {"seen": payload["value"]},
}
},
)
assert run.status == RunStatus.COMPLETED
assert run.state["seen"] == ["a", "b"]
item_frames = [
frame for frame in run.frames.values() if frame.kind == "foreach_iteration"
]
assert len(item_frames) == 2
assert all(frame.status == FrameStatus.COMPLETED for frame in item_frames)
assert all(frame.finished_at_node_id == "each" for frame in item_frames)
assert run.output["seen"] == ["a", "b"]
def test_foreach_body_cycle_can_repeat_then_return() -> None:
foreach = ForeachNode.model_validate(
{
"id": "each",
"type": "foreach",
"over": "state.items",
"as": "item",
"mode": "serial",
}
)
workflow = Workflow(
name="foreach_body_cycle",
input_schema=SchemaRef(type="object", properties={"items": {"type": "array"}}),
state_schema=StateSchema.from_field_map(
{
"items": StateField(type="array"),
"seen": StateField(
type="array", reducer=ReducerRef(name="wf.std.append")
),
}
),
output_schema=SchemaRef(type="object", properties={"seen": {"type": "array"}}),
node_defs=[
NodeDef(
name="step_a",
input_schema=SchemaRef(
type="object", properties={"value": {}}, required=["value"]
),
output_schema=SchemaRef(
type="object", properties={"seen": {}}, required=["seen"]
),
outcomes=["again", "done"],
),
NodeDef(
name="step_b",
input_schema=SchemaRef(
type="object", properties={"seen": {}}, required=["seen"]
),
output_schema=SchemaRef(
type="object", properties={"seen": {}}, required=["seen"]
),
outcomes=["ok"],
),
],
start="each",
nodes=[
foreach,
NodeUse.model_validate(
{
"id": "a",
"type": "node",
"node": "step_a",
"input": [{"target": "value", "path": "context.item"}],
"output": [{"source": "seen", "target": "state.seen"}],
}
),
NodeUse.model_validate(
{
"id": "b",
"type": "node",
"node": "step_b",
"input": [{"target": "seen", "path": "context.item"}],
"output": [],
}
),
],
edges=[
Edge.model_validate({"from": "each", "outcome": "loop", "to": "a"}),
Edge.model_validate({"from": "a", "outcome": "again", "to": "b"}),
Edge.model_validate({"from": "b", "outcome": "ok", "to": "a"}),
Edge.model_validate({"from": "a", "outcome": "done", "to": "each"}),
Edge.model_validate({"from": "each", "outcome": "done", "to": END}),
],
)
calls = {"count": 0}
def step_a(payload: dict[str, Any], _ctx: object) -> dict[str, Any]:
calls["count"] += 1
outcome = "again" if calls["count"] == 1 else "done"
return {"outcome": outcome, "output": {"seen": payload["value"]}}
run = execute_workflow(
workflow,
{"items": ["x"]},
{
"step_a": step_a,
"step_b": lambda payload, _ctx: {
"outcome": "ok",
"output": {"seen": payload["seen"]},
},
},
)
assert run.status == RunStatus.COMPLETED
# `b` writes nothing; `a` writes once per visit (again + done).
assert run.state["seen"] == ["x", "x"]
def test_conditional_body_can_return_on_either_outcome() -> None:
foreach = ForeachNode.model_validate(
{
"id": "each",
"type": "foreach",
"over": "state.items",
"as": "item",
"mode": "serial",
}
)
workflow = Workflow(
name="foreach_conditional_return",
input_schema=SchemaRef(type="object", properties={"items": {"type": "array"}}),
state_schema=StateSchema.from_field_map(
{
"items": StateField(type="array"),
"seen": StateField(
type="array", reducer=ReducerRef(name="wf.std.append")
),
}
),
output_schema=SchemaRef(type="object", properties={"seen": {"type": "array"}}),
node_defs=[
NodeDef(
name="decide",
input_schema=SchemaRef(
type="object", properties={"value": {}}, required=["value"]
),
output_schema=SchemaRef(
type="object", properties={"seen": {}}, required=["seen"]
),
outcomes=["true", "false"],
),
NodeDef(
name="work",
input_schema=SchemaRef(
type="object", properties={"value": {}}, required=["value"]
),
output_schema=SchemaRef(
type="object", properties={"seen": {}}, required=["seen"]
),
outcomes=["ok"],
),
],
start="each",
nodes=[
foreach,
NodeUse.model_validate(
{
"id": "condition",
"type": "node",
"node": "decide",
"input": [{"target": "value", "path": "context.item"}],
"output": [],
}
),
NodeUse.model_validate(
{
"id": "work",
"type": "node",
"node": "work",
"input": [{"target": "value", "path": "context.item"}],
"output": [{"source": "seen", "target": "state.seen"}],
}
),
NodeUse.model_validate(
{
"id": "work_false",
"type": "node",
"node": "work",
"input": [{"target": "value", "path": "context.item"}],
"output": [{"source": "seen", "target": "state.seen"}],
}
),
],
edges=[
Edge.model_validate({"from": "each", "outcome": "loop", "to": "condition"}),
Edge.model_validate({"from": "condition", "outcome": "true", "to": "work"}),
Edge.model_validate(
{"from": "condition", "outcome": "false", "to": "work_false"}
),
Edge.model_validate({"from": "work", "outcome": "ok", "to": "each"}),
Edge.model_validate({"from": "work_false", "outcome": "ok", "to": "each"}),
Edge.model_validate({"from": "each", "outcome": "done", "to": END}),
],
)
def decide(payload: dict[str, Any], _ctx: object) -> dict[str, Any]:
outcome = "true" if payload["value"] == "a" else "false"
return {"outcome": outcome, "output": {"seen": payload["value"]}}
def _work(payload: dict[str, Any], _ctx: object) -> dict[str, Any]:
return {"outcome": "ok", "output": {"seen": payload["value"]}}
run = execute_workflow(
workflow,
{"items": ["a", "b"]},
{"decide": decide, "work": _work},
)
assert run.status == RunStatus.COMPLETED
assert sorted(run.state["seen"]) == ["a", "b"]
def test_nested_foreach_returns_inner_then_outer() -> None:
outer = ForeachNode.model_validate(
{
"id": "outer",
"type": "foreach",
"over": "state.items",
"as": "outer_item",
"mode": "concurrent",
"concurrent": {"max_active": 2, "max_outstanding": 2},
}
)
inner = ForeachNode.model_validate(
{
"id": "inner",
"type": "foreach",
"over": "state.inner_items",
"as": "inner_item",
"mode": "concurrent",
"concurrent": {"max_active": 2, "max_outstanding": 2},
}
)
workflow = Workflow(
name="nested_foreach_return",
input_schema=SchemaRef(type="object", properties={}),
state_schema=StateSchema.from_field_map(
{
"items": StateField(type="array"),
"inner_items": StateField(type="array"),
"seen": StateField(
type="array", reducer=ReducerRef(name="wf.std.append")
),
}
),
output_schema=SchemaRef(type="object", properties={"seen": {"type": "array"}}),
node_defs=[
NodeDef(
name="work",
input_schema=SchemaRef(
type="object", properties={"value": {}}, required=["value"]
),
output_schema=SchemaRef(
type="object", properties={"seen": {}}, required=["seen"]
),
outcomes=["ok"],
)
],
start="outer",
nodes=[
outer,
inner,
NodeUse.model_validate(
{
"id": "work",
"type": "node",
"node": "work",
"input": [{"target": "value", "path": "context.inner_item"}],
"output": [{"source": "seen", "target": "state.seen"}],
}
),
NodeUse.model_validate(
{
"id": "tail",
"type": "node",
"node": "work",
"input": [{"target": "value", "path": "context.outer_item"}],
"output": [{"source": "seen", "target": "state.seen"}],
}
),
NodeUse.model_validate(
{
"id": "after",
"type": "node",
"node": "work",
"input": [{"target": "value", "path": "state.seen"}],
"output": [],
}
),
],
edges=[
Edge.model_validate({"from": "outer", "outcome": "loop", "to": "inner"}),
Edge.model_validate({"from": "inner", "outcome": "loop", "to": "work"}),
Edge.model_validate({"from": "work", "outcome": "ok", "to": "inner"}),
Edge.model_validate({"from": "inner", "outcome": "done", "to": "tail"}),
Edge.model_validate({"from": "tail", "outcome": "ok", "to": "outer"}),
Edge.model_validate({"from": "outer", "outcome": "done", "to": "after"}),
Edge.model_validate({"from": "after", "outcome": "ok", "to": END}),
],
)
run = execute_workflow(
workflow,
{"items": ["a"], "inner_items": [1, 2]},
{
"work": lambda payload, _ctx: {
"outcome": "ok",
"output": {"seen": payload["value"]},
}
},
)
assert run.status == RunStatus.COMPLETED
# Inner items 1, 2 plus outer tail "a" plus after echo.
assert run.state["seen"][:3] == [1, 2, "a"]
def test_reentering_foreach_uses_fresh_activation_and_item_frames() -> None:
workflow = Workflow(
name="foreach_reentry",
input_schema=SchemaRef(type="object", properties={}),
state_schema=StateSchema.from_field_map(
{
"items": StateField(type="array"),
"count": StateField(type="integer", default=0),
"seen": StateField(
type="array", reducer=ReducerRef(name="wf.std.append")
),
}
),
output_schema=SchemaRef(type="object", properties={"seen": {"type": "array"}}),
node_defs=[
NodeDef(
name="record",
input_schema=SchemaRef(
type="object", properties={"value": {}}, required=["value"]
),
output_schema=SchemaRef(
type="object", properties={"seen": {}}, required=["seen"]
),
outcomes=["ok"],
),
NodeDef(
name="bump",
input_schema=SchemaRef(
type="object", properties={"count": {"type": "integer"}}
),
output_schema=SchemaRef(
type="object", properties={"count": {"type": "integer"}}
),
outcomes=["ok"],
),
],
start="again",
nodes=[
ConditionNode.model_validate(
{
"id": "again",
"type": "condition",
"check": {
"op": "lt",
"left": {"path": "state.count"},
"right": {"value": 2},
},
}
),
ForeachNode.model_validate(
{
"id": "each",
"type": "foreach",
"over": "state.items",
"as": "item",
"mode": "serial",
}
),
NodeUse.model_validate(
{
"id": "work",
"type": "node",
"node": "record",
"input": [{"target": "value", "path": "context.item"}],
"output": [{"source": "seen", "target": "state.seen"}],
}
),
NodeUse.model_validate(
{
"id": "bump",
"type": "node",
"node": "bump",
"input": [{"target": "count", "path": "state.count"}],
"output": [{"source": "count", "target": "state.count"}],
}
),
],
edges=[
Edge.model_validate({"from": "again", "outcome": "true", "to": "each"}),
Edge.model_validate({"from": "each", "outcome": "loop", "to": "work"}),
Edge.model_validate({"from": "work", "outcome": "ok", "to": "each"}),
Edge.model_validate({"from": "each", "outcome": "done", "to": "bump"}),
Edge.model_validate({"from": "bump", "outcome": "ok", "to": "again"}),
Edge.model_validate({"from": "again", "outcome": "false", "to": END}),
],
)
def bump(payload: dict[str, Any], _ctx: object) -> dict[str, Any]:
count = payload.get("count", 0)
assert isinstance(count, int)
return {"outcome": "ok", "output": {"count": count + 1}}
run = execute_workflow(
workflow,
{"items": ["a"]},
{
"record": lambda payload, _ctx: {
"outcome": "ok",
"output": {"seen": payload["value"]},
},
"bump": bump,
},
)
assert run.status == RunStatus.COMPLETED
assert run.state["seen"] == ["a", "a"]
item_frames = [
frame for frame in run.frames.values() if frame.kind == "foreach_iteration"
]
assert len(item_frames) == 2
owners = [item_frame_owner(frame) for frame in item_frames]
assert all(owner is not None for owner in owners)
assert owners[0] is not None and owners[1] is not None
assert owners[0].activation_id != owners[1].activation_id
assert item_frames[0].id != item_frames[1].id
# Both visits run item zero, but activation-qualified frame ids differ.
assert all(frame.id.endswith(":0") for frame in item_frames)
def test_subgraph_end_returns_to_subgraph_node_then_foreach_owner() -> None:
from wf_core import PreparedSubgraph
child = Workflow(
name="child",
input_schema=SchemaRef(type="object", properties={"value": {}}),
state_schema=StateSchema.from_field_map({"seen": StateField(type="string")}),
output_schema=SchemaRef(type="object", properties={"seen": {}}),
node_defs=[
NodeDef(
name="inner_record",
input_schema=SchemaRef(
type="object", properties={"value": {}}, required=["value"]
),
output_schema=SchemaRef(
type="object", properties={"seen": {}}, required=["seen"]
),
outcomes=["ok"],
)
],
start="inner_record",
nodes=[
NodeUse.model_validate(
{
"id": "inner_record",
"type": "node",
"node": "inner_record",
"input": [{"target": "value", "path": "input.value"}],
"output": [{"source": "seen", "target": "state.seen"}],
}
)
],
edges=[
Edge.model_validate({"from": "inner_record", "outcome": "ok", "to": END})
],
)
foreach = ForeachNode.model_validate(
{
"id": "each",
"type": "foreach",
"over": "state.items",
"as": "item",
"mode": "serial",
}
)
workflow = Workflow(
name="foreach_subgraph",
input_schema=SchemaRef(type="object", properties={"items": {"type": "array"}}),
state_schema=StateSchema.from_field_map(
{
"items": StateField(type="array"),
"seen": StateField(
type="array", reducer=ReducerRef(name="wf.std.append")
),
}
),
output_schema=SchemaRef(type="object", properties={"seen": {"type": "array"}}),
node_defs=[],
start="each",
nodes=[
foreach,
SubgraphNode.model_validate(
{
"id": "child",
"type": "subgraph",
"workflow": "child.workflow",
"input_schema": {"type": "object", "properties": {"value": {}}},
"output_schema": {"type": "object", "properties": {"seen": {}}},
"input": [{"target": "value", "path": "context.item"}],
"output": [{"source": "seen", "target": "state.seen"}],
"outcomes": ["ok"],
}
),
],
edges=[
Edge.model_validate({"from": "each", "outcome": "loop", "to": "child"}),
Edge.model_validate({"from": "child", "outcome": "ok", "to": "each"}),
Edge.model_validate({"from": "each", "outcome": "done", "to": END}),
],
)
run = execute_workflow(
workflow,
{"items": ["a", "b"]},
{},
subgraphs={
"child.workflow": PreparedSubgraph(
workflow=child,
registry={
"inner_record": lambda payload, _ctx: {"seen": payload["value"]}
},
)
},
)
assert run.status == RunStatus.COMPLETED
assert run.state["seen"] == ["a", "b"]
def test_nonlocal_runtime_return_fails_closed_when_validation_is_bypassed() -> None:
run = RunState(
workflow_name="nonlocal",
status=RunStatus.RUNNING,
workflow_input={},
state={},
frames={},
)
add_frame(
run,
ExecutionFrame(id="root", kind="workflow", node_id="outer"),
)
add_frame(
run,
ExecutionFrame(
id="outer-item",
kind="foreach_iteration",
node_id="inner",
parent_frame_id="root",
metadata={
"foreach_node_id": "outer",
"activation_id": "root:outer#0",
"loop_index": 0,
"loop_item": "a",
"loop_alias": "outer_item",
},
),
)
add_frame(
run,
ExecutionFrame(
id="inner-item",
kind="foreach_iteration",
node_id="work",
parent_frame_id="outer-item",
metadata={
"foreach_node_id": "inner",
"activation_id": "outer-item:inner#0",
"loop_index": 0,
"loop_item": 1,
"loop_alias": "inner_item",
},
),
)
run.current_frame_id = "inner-item"
run.sync_from_current_frame()
from wf_core.runtime.ops.flow import advance_frame
with pytest.raises(WorkflowExecutionError, match="non-local|ancestor|immediate"):
advance_frame(run, run.frames["inner-item"], outcome="ok", next_node_id="outer")
def test_completed_activation_cannot_consume_later_activation_result_or_wake() -> None:
"""A closed visit rejects buffered results and wake-ups from other visits."""
from wf_core.runtime.foreach_state import (
close_foreach_activation,
load_or_begin_foreach_activation,
require_foreach_activation,
)
from wf_core.runtime.scheduler import (
block_frame_on_children,
wake_parent_for_child_progress,
)
parent = ExecutionFrame(id="root", kind="workflow", node_id="each")
first = load_or_begin_foreach_activation(parent, "each", mode="concurrent")
first.barrier.next_index = 1
first.barrier.start_child(f"{first.id}:0")
from wf_core.runtime.foreach_state import save_foreach_activation
save_foreach_activation(parent, first)
close_foreach_activation(parent, first)
second = load_or_begin_foreach_activation(parent, "each", mode="concurrent")
save_foreach_activation(parent, second)
assert second.id != first.id
with pytest.raises(WorkflowExecutionError, match="closed|superseded"):
require_foreach_activation(parent, "each", first.id)
run = RunState(
workflow_name="activation_isolation",
status=RunStatus.RUNNING,
workflow_input={},
state={},
frames={parent.id: parent},
)
stale_child = ExecutionFrame(
id=f"{first.id}:0",
kind="foreach_iteration",
node_id="work",
parent_frame_id="root",
metadata={
"foreach_node_id": "each",
"activation_id": first.id,
"loop_index": 0,
"loop_item": "a",
"loop_alias": "item",
},
)
run.frames[stale_child.id] = stale_child
block_frame_on_children(run, "root", (stale_child.id,))
stale_child.status = FrameStatus.COMPLETED
with pytest.raises(WorkflowExecutionError, match="closed activation"):
wake_parent_for_child_progress(run, stale_child.id)