feat: return foreach items through owner back-edges
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user