audit: unify item write routing, fix serial interrupt loss, enforce result coherence

This commit is contained in:
lda
2026-09-04 11:20:15 +07:00 Verified
parent f155e6651a
commit 8a0737fe8b
9 changed files with 206 additions and 75 deletions
+64
View File
@@ -9,6 +9,7 @@ from wf_core import (
ConditionNode,
Edge,
ForeachNode,
InterruptNode,
NodeDef,
NodeUse,
ReducerRef,
@@ -19,6 +20,8 @@ from wf_core import (
Workflow,
WorkflowExecutionError,
execute_workflow,
execute_workflow_async,
resume_workflow_async,
)
from wf_core.run_state import ExecutionFrame, FrameStatus, RunState, RunStatus
from wf_core.runtime.foreach_state import item_frame_owner
@@ -742,6 +745,67 @@ def test_concurrent_subgraph_item_returns_through_owner() -> None:
assert sorted(run.state["seen"]) == ["a", "b"]
async def test_serial_interrupt_resume_commits_answer_to_parent_state() -> None:
"""A serial item resume must land in parent state, not the child lineage."""
foreach = ForeachNode.model_validate(
{
"id": "each",
"type": "foreach",
"over": "state.items",
"as": "item",
"mode": "serial",
}
)
workflow = Workflow(
name="foreach_serial_interrupt",
input_schema=SchemaRef(type="object", properties={"items": {"type": "array"}}),
state_schema=StateSchema.from_field_map(
{
"items": StateField(type="array"),
"answers": StateField(
type="array", reducer=ReducerRef(name="wf.std.append")
),
}
),
output_schema=SchemaRef(
type="object", properties={"answers": {"type": "array"}}
),
node_defs=[],
start="each",
nodes=[
foreach,
InterruptNode.model_validate(
{
"id": "ask",
"type": "interrupt",
"kind": "approval",
"request": [{"target": "item", "path": "context.item"}],
"resume": [{"source": "answer", "target": "state.answers"}],
}
),
],
edges=[
Edge.model_validate({"from": "each", "outcome": "loop", "to": "ask"}),
Edge.model_validate({"from": "ask", "outcome": "submitted", "to": "each"}),
Edge.model_validate({"from": "each", "outcome": "done", "to": END}),
],
)
run = await execute_workflow_async(workflow, {"items": ["a", "b"]}, {})
assert run.status == RunStatus.INTERRUPTED
resumed = await resume_workflow_async(
workflow, run, {}, resume_payload={"answer": "a"}
)
assert resumed.status == RunStatus.INTERRUPTED
finished = await resume_workflow_async(
workflow, resumed, {}, resume_payload={"answer": "b"}
)
assert finished.status == RunStatus.COMPLETED
assert finished.state["answers"] == ["a", "b"]
def test_nonlocal_runtime_return_fails_closed_when_validation_is_bypassed() -> None:
run = RunState(
workflow_name="nonlocal",
+50
View File
@@ -1,5 +1,7 @@
from __future__ import annotations
from copy import deepcopy
import pytest
from wf_core.errors import WorkflowExecutionError
@@ -290,3 +292,51 @@ def test_pending_item_result_requires_lineage_for_success() -> None:
"lineage_id": None,
}
)
def test_pending_item_result_rejects_error_on_success() -> None:
with pytest.raises(WorkflowExecutionError, match="must not carry an error"):
PendingItemResult.from_metadata(
{
"index": 0,
"frame_id": "child",
"status": "succeeded",
"lineage_id": "root:each#0[0]",
"error": {
"index": 0,
"frame_id": "child",
"node_id": "work",
"error_type": "ValueError",
"message": "bad",
},
}
)
def test_pending_item_result_requires_error_for_failure() -> None:
with pytest.raises(WorkflowExecutionError, match="requires an error"):
PendingItemResult.from_metadata(
{
"index": 0,
"frame_id": "child",
"status": "failed",
"lineage_id": None,
"error": None,
}
)
def test_pending_item_result_rejects_index_key_mismatch() -> None:
frame = ExecutionFrame(id="root", kind="root", node_id="each")
activation = load_or_begin_foreach_activation(frame, "each", mode="concurrent")
activation.barrier.add_success_patch(
index=0, frame_id="child-0", lineage_id="root:each#0[0]"
)
save_foreach_activation(frame, activation)
raw = deepcopy(frame.metadata["foreach_activations"])
raw["each"]["active"]["barrier"]["pending_results"] = {
"7": raw["each"]["active"]["barrier"]["pending_results"]["0"]
}
with pytest.raises(WorkflowExecutionError, match="index mismatch"):
ForeachBarrierState.from_metadata(raw["each"]["active"]["barrier"])