fix: close workflow review gaps

This commit is contained in:
lda
2026-09-04 20:00:21 +07:00 Verified
parent 220df14314
commit 6b5c79ba21
15 changed files with 169 additions and 60 deletions
@@ -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"
+9 -2
View File
@@ -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
)
+13 -14
View File
@@ -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",
+10 -2
View File
@@ -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",