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
+58
View File
@@ -2,6 +2,7 @@ from __future__ import annotations
from typing import Any
from wf_core.errors import WorkflowExecutionError
from wf_core.models.workflow import Workflow
from wf_core.run_state import (
ExecutionFrame,
@@ -76,6 +77,39 @@ def advance_frame(
next_node_id: str,
front: bool = False,
) -> None:
# Foreach back-edge return is an ownership check, not generic cycle
# detection. Only the frame's immediate recorded owner completes the item;
# a root frame targeting the same foreach enters it normally.
from wf_core.runtime.foreach_state import item_frame_owner
owner = item_frame_owner(frame)
if owner is not None:
if next_node_id == END:
raise WorkflowExecutionError(
f"foreach item frame {frame.id!r} cannot target workflow END; "
f"return to owning foreach {owner.foreach_node_id!r}"
)
if next_node_id == owner.foreach_node_id:
source_node_id = frame.node_id
frame.prior_outcome = outcome
frame.activated_incoming_edge = source_node_id
frame.node_id = owner.foreach_node_id
frame.status = FrameStatus.COMPLETED
frame.finished_at_node_id = owner.foreach_node_id
# The child does not execute the controller again; the blocked
# parent activation consumes the result and admits the next item
# or emits done. The owner location stays inspectable in trace
# and checkpoint state.
wake_parent_for_child_progress(run, frame.id)
run.sync_from_current_frame()
return
ancestors = _foreach_ancestor_ids(run, frame)
if next_node_id in ancestors[1:]:
raise WorkflowExecutionError(
f"foreach item frame {frame.id!r} targets non-immediate "
f"ancestor {next_node_id!r}; only {owner.foreach_node_id!r} "
"can complete this item"
)
frame.prior_outcome = outcome
frame.activated_incoming_edge = frame.node_id
frame.node_id = next_node_id
@@ -93,6 +127,30 @@ def advance_frame(
run.sync_from_current_frame()
def _foreach_ancestor_ids(run: RunState, frame: ExecutionFrame) -> list[str]:
"""Derive active foreach owners from frame ancestry for fail-closed checks.
The first entry is the frame's immediate owner; later entries are older
ancestors. A target naming an older ancestor is a non-local return, while
a target naming an inactive foreach is an ordinary nested entry.
"""
from wf_core.runtime.foreach_state import item_frame_owner
ancestors: list[str] = []
cursor: ExecutionFrame | None = frame
seen: set[str] = set()
while cursor is not None:
owner = item_frame_owner(cursor)
if owner is not None:
if owner.foreach_node_id in seen:
break
seen.add(owner.foreach_node_id)
ancestors.append(owner.foreach_node_id)
parent_id = cursor.parent_frame_id
cursor = run.frames.get(parent_id) if parent_id is not None else None
return ancestors
def finalize_run(workflow: Workflow, run: RunState) -> RunState:
if run.outcome is None:
run.outcome = "ok"
+17
View File
@@ -12,6 +12,7 @@ from wf_core.runtime.foreach_state import (
ForeachBarrierState,
ItemErrorRecord,
PendingItemResult,
close_foreach_activation,
load_or_begin_foreach_activation,
save_foreach_activation,
)
@@ -87,6 +88,9 @@ def _step_foreach_serial(
state_changes={},
),
)
# Close the visit before following `done` so a self-looping completion
# edge or a later revisit starts a fresh activation.
close_foreach_activation(frame, activation)
advance_frame(run, frame, outcome=outcome, next_node_id=next_node_id)
return run
@@ -96,6 +100,15 @@ def _step_foreach_serial(
save_foreach_activation(frame, activation)
child_id = _child_frame_id(activation, loop_index)
child_lineage_id = _child_lineage_id(activation, loop_index)
# Serial items still own a lineage so nested subgraph/boundary commits have
# a parent lineage to buffer into; top-level serial writes commit through
# the parent scope root.
add_lineage(
run,
scope_id=frame.scope_id,
lineage_id=child_lineage_id,
parent_id=frame.lineage_id,
)
add_frame(
run,
ExecutionFrame(
@@ -168,6 +181,7 @@ def _step_foreach_concurrent(
frame=frame,
step=step,
index=index,
activation=activation,
barrier=barrier,
reducers=reducers,
)
@@ -318,6 +332,7 @@ def _finish_concurrent_foreach(
frame: ExecutionFrame,
step: ForeachNode,
index: WorkflowIndex,
activation: ForeachActivationState,
barrier: ForeachBarrierState,
reducers: Mapping[str, ReducerDefinition] | None = None,
) -> RunState:
@@ -373,6 +388,8 @@ def _finish_concurrent_foreach(
state_changes=state_changes,
),
)
# Close the visit before following completion so later revisits start fresh.
close_foreach_activation(frame, activation)
advance_frame(run, frame, outcome=outcome, next_node_id=next_node_id)
return run
+5
View File
@@ -87,6 +87,11 @@ def complete_end_step(
"""Record an explicit workflow terminal and complete the active frame."""
result = StepExecutionResult(outcome=outcome)
frame = run.frames[frame_id]
if item_frame_owner(frame) is not None:
raise WorkflowExecutionError(
f"foreach item frame {frame.id!r} cannot target explicit end node "
f"{node_id!r}; return to its owning foreach"
)
frame.metadata["workflow_outcome"] = outcome
if frame.parent_frame_id is None:
run.outcome = outcome
+25 -1
View File
@@ -236,7 +236,31 @@ def _finish_subgraph(
reducers=reducers,
missing_field_message="subgraph output did not include required field {field}",
)
state_changes = commit_patch_for_frame(run, frame, patch)
# Match node execution: serial item writes commit through the parent
# scope so top-level serial subgraphs land in root state; concurrent
# item writes stay buffered in the item lineage for barrier merge.
from wf_core.runtime.foreach_state import (
item_frame_owner,
load_foreach_activation,
)
commit_frame = frame
try:
owner = item_frame_owner(frame)
except Exception:
owner = None
if owner is not None:
parent_frame = run.frames.get(owner.parent_frame_id)
if parent_frame is not None:
foreach_activation = load_foreach_activation(
parent_frame, owner.foreach_node_id, owner.activation_id
)
if (
foreach_activation is not None
and foreach_activation.barrier.mode == "serial"
):
commit_frame = parent_frame
state_changes = commit_patch_for_frame(run, commit_frame, patch)
return StepExecutionResult(
outcome=child_outcome,
resolved_input=activation.child_input,