fix: close workflow review gaps
This commit is contained in:
@@ -19,6 +19,7 @@ from wf_core import (
|
||||
execute_workflow_async,
|
||||
resume_workflow_async,
|
||||
)
|
||||
from wf_core.runtime.foreach_state import item_frame_owner
|
||||
|
||||
|
||||
async def test_concurrent_foreach_interrupt_returns_before_refill() -> None:
|
||||
@@ -44,6 +45,9 @@ async def test_resume_prioritizes_interrupted_item_before_siblings() -> None:
|
||||
{"route": _interrupt_on_b},
|
||||
)
|
||||
interrupted_trace_len = len(run.trace)
|
||||
interrupted_owner = item_frame_owner(run.frames["root:each#0:1"])
|
||||
assert interrupted_owner is not None
|
||||
interrupted_activation_id = interrupted_owner.activation_id
|
||||
|
||||
resumed = await resume_workflow_async(
|
||||
workflow,
|
||||
@@ -52,15 +56,11 @@ 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_owner.activation_id == interrupted_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"
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from wf_core.errors import WorkflowExecutionError
|
||||
@@ -65,8 +67,13 @@ def test_closing_stale_activation_fails_closed() -> None:
|
||||
second = load_or_begin_foreach_activation(frame, "each", mode="serial")
|
||||
save_foreach_activation(frame, second)
|
||||
|
||||
with pytest.raises(WorkflowExecutionError, match="stale|closed|active"):
|
||||
with pytest.raises(
|
||||
WorkflowExecutionError, match="cannot close stale foreach activation"
|
||||
) as exc_info:
|
||||
close_foreach_activation(frame, first)
|
||||
message = str(exc_info.value)
|
||||
assert repr(first.id) in message
|
||||
assert "'root'" in message
|
||||
|
||||
|
||||
def test_activation_json_round_trip_through_frame_metadata() -> None:
|
||||
@@ -75,7 +82,7 @@ def test_activation_json_round_trip_through_frame_metadata() -> None:
|
||||
activation.barrier.next_index = 2
|
||||
save_foreach_activation(frame, activation)
|
||||
|
||||
dumped = dict(frame.metadata)
|
||||
dumped = json.loads(json.dumps(frame.metadata))
|
||||
restored_frame = ExecutionFrame(
|
||||
id="root", kind="workflow", node_id="each", metadata=dumped
|
||||
)
|
||||
|
||||
@@ -28,18 +28,6 @@ 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(
|
||||
{
|
||||
@@ -1360,8 +1348,14 @@ def test_nonlocal_runtime_return_fails_closed_when_validation_is_bypassed() -> N
|
||||
|
||||
from wf_core.runtime.ops.flow import advance_frame
|
||||
|
||||
with pytest.raises(WorkflowExecutionError, match="non-local|ancestor|immediate"):
|
||||
with pytest.raises(
|
||||
WorkflowExecutionError, match="targets non-immediate ancestor"
|
||||
) as exc_info:
|
||||
advance_frame(run, run.frames["inner-item"], outcome="ok", next_node_id="outer")
|
||||
message = str(exc_info.value)
|
||||
assert "'inner-item'" in message
|
||||
assert "'outer'" in message
|
||||
assert "'inner'" in message
|
||||
|
||||
|
||||
def test_root_frame_targeting_foreach_enters_normally() -> None:
|
||||
@@ -1415,8 +1409,13 @@ def test_completed_activation_cannot_consume_later_activation_result_or_wake() -
|
||||
|
||||
assert second.id != first.id
|
||||
|
||||
with pytest.raises(WorkflowExecutionError, match="closed|superseded"):
|
||||
with pytest.raises(
|
||||
WorkflowExecutionError, match="closed or superseded"
|
||||
) as exc_info:
|
||||
require_foreach_activation(parent, "each", first.id)
|
||||
message = str(exc_info.value)
|
||||
assert repr(first.id) in message
|
||||
assert "'each'" in message
|
||||
|
||||
run = RunState(
|
||||
workflow_name="activation_isolation",
|
||||
|
||||
@@ -361,21 +361,29 @@ def _failed_result(
|
||||
|
||||
|
||||
def test_pending_item_result_rejects_error_index_mismatch() -> None:
|
||||
with pytest.raises(WorkflowExecutionError, match="error.*index|index.*error"):
|
||||
with pytest.raises(WorkflowExecutionError, match="error identity") as exc_info:
|
||||
PendingItemResult.from_metadata(
|
||||
_failed_result(
|
||||
index=0, frame_id="child-0", error_index=7, error_frame="child-0"
|
||||
)
|
||||
)
|
||||
message = str(exc_info.value)
|
||||
assert "index 7" in message
|
||||
assert "index 0" in message
|
||||
assert "frame 'child-0'" in message
|
||||
|
||||
|
||||
def test_pending_item_result_rejects_error_frame_mismatch() -> None:
|
||||
with pytest.raises(WorkflowExecutionError, match="error.*frame|frame.*error"):
|
||||
with pytest.raises(WorkflowExecutionError, match="error identity") as exc_info:
|
||||
PendingItemResult.from_metadata(
|
||||
_failed_result(
|
||||
index=0, frame_id="child-0", error_index=0, error_frame="other"
|
||||
)
|
||||
)
|
||||
message = str(exc_info.value)
|
||||
assert "frame 'other'" in message
|
||||
assert "frame 'child-0'" in message
|
||||
assert "index 0" in message
|
||||
|
||||
|
||||
def test_pending_item_result_accepts_matching_error_identity() -> None:
|
||||
|
||||
@@ -417,6 +417,42 @@ def test_foreach_body_cannot_target_explicit_end_node() -> None:
|
||||
assert matching[0].path == "edges[1]"
|
||||
|
||||
|
||||
def test_explicit_end_reached_from_three_regions_stays_conflicted() -> None:
|
||||
workflow = _workflow(
|
||||
start="start",
|
||||
nodes=[
|
||||
_node("start"),
|
||||
_foreach("f1"),
|
||||
_node("b1"),
|
||||
_foreach("f2"),
|
||||
_node("b2"),
|
||||
{"id": "stop", "type": "end", "outcome": "ok"},
|
||||
],
|
||||
edges=[
|
||||
{"from": "start", "outcome": "direct", "to": "stop"},
|
||||
{"from": "start", "outcome": "left", "to": "f1"},
|
||||
{"from": "start", "outcome": "right", "to": "f2"},
|
||||
{"from": "f1", "outcome": "loop", "to": "b1"},
|
||||
{"from": "b1", "outcome": "ok", "to": "stop"},
|
||||
{"from": "f1", "outcome": "done", "to": END},
|
||||
{"from": "f2", "outcome": "loop", "to": "b2"},
|
||||
{"from": "b2", "outcome": "ok", "to": "stop"},
|
||||
{"from": "f2", "outcome": "done", "to": END},
|
||||
],
|
||||
)
|
||||
|
||||
analysis = analyze_control_regions(workflow)
|
||||
|
||||
conflicts = [
|
||||
issue
|
||||
for issue in analysis.issues
|
||||
if issue.kind == ControlRegionIssueKind.FOREACH_REGION_CONFLICT
|
||||
and issue.path == "nodes[stop]"
|
||||
]
|
||||
assert len(conflicts) == 1
|
||||
assert "stop" not in analysis.owner_stack_by_node
|
||||
|
||||
|
||||
def test_every_unreachable_node_is_reported() -> None:
|
||||
workflow = _workflow(
|
||||
start="work",
|
||||
|
||||
Reference in New Issue
Block a user