Files
lda-wf/tests/core/test_run_step_budget.py
T

877 lines
27 KiB
Python

from __future__ import annotations
import pytest
from wf_core import (
END,
ConditionNode,
Edge,
EndNode,
ForeachNode,
InterruptNode,
NodeDef,
NodeUse,
PreparedSubgraph,
ReducerRef,
RunStatus,
SchemaRef,
StateField,
StateSchema,
SubgraphNode,
Workflow,
WorkflowExecutionError,
dump_run_state,
execute_workflow,
load_run_state,
resume_workflow,
step_workflow,
)
from wf_core.errors import WorkflowStepLimitExceeded
from wf_core.run_codec import load_run_state_with_upgrade
from wf_core.runtime.limits import (
RunLimits,
admit_step_attempt,
remaining_step_attempts,
)
from wf_core.runtime.ops.runs import create_run_state
def _minimal_workflow(name: str = "budget") -> Workflow:
return Workflow(
name=name,
input_schema=SchemaRef(type="object", properties={}),
state_schema=StateSchema.from_field_map({}),
output_schema=SchemaRef(type="object", properties={}),
node_defs=[
NodeDef(
name="finish",
input_schema=SchemaRef(type="object", properties={}),
output_schema=SchemaRef(type="object", properties={}),
outcomes=["ok"],
)
],
start="finish",
nodes=[
NodeUse.model_validate({"id": "finish", "type": "node", "node": "finish"})
],
edges=[Edge.model_validate({"from": "finish", "outcome": "ok", "to": END})],
)
def test_run_limits_default() -> None:
limits = RunLimits()
assert limits.max_steps == 10_000
def test_run_limits_rejects_non_positive() -> None:
with pytest.raises(ValueError):
RunLimits(max_steps=0)
with pytest.raises(ValueError):
RunLimits(max_steps=-3)
def test_run_limits_rejects_bool_and_non_int() -> None:
with pytest.raises(TypeError):
RunLimits(max_steps=True) # type: ignore[arg-type]
with pytest.raises(TypeError):
RunLimits(max_steps=False) # type: ignore[arg-type]
with pytest.raises(TypeError):
RunLimits(max_steps="10") # type: ignore[arg-type]
with pytest.raises(TypeError):
RunLimits(max_steps=10.0) # type: ignore[arg-type]
def test_create_run_state_defaults_to_budget() -> None:
workflow = _minimal_workflow()
run = create_run_state(workflow, {})
assert run.limits.max_steps == 10_000
assert run.steps_executed == 0
assert run.steps_remaining == 10_000
assert run.current_frame().step_number is None
assert remaining_step_attempts(run) == 10_000
def test_create_run_state_captures_limits() -> None:
workflow = _minimal_workflow()
limits = RunLimits(max_steps=5)
run = create_run_state(workflow, {}, limits=limits)
assert run.limits.max_steps == 5
assert run.steps_remaining == 5
def test_budget_of_one() -> None:
workflow = _minimal_workflow()
limits = RunLimits(max_steps=1)
run = create_run_state(workflow, {}, limits=limits)
number = admit_step_attempt(run, run.current_frame(), workflow.start)
assert number == 1
assert run.steps_executed == 1
assert run.steps_remaining == 0
with pytest.raises(WorkflowStepLimitExceeded):
admit_step_attempt(run, run.current_frame(), workflow.start)
def test_denied_admission_does_not_increment() -> None:
workflow = _minimal_workflow()
run = create_run_state(workflow, {}, limits=RunLimits(max_steps=1))
admit_step_attempt(run, run.current_frame(), workflow.start)
with pytest.raises(WorkflowStepLimitExceeded):
admit_step_attempt(run, run.current_frame(), workflow.start)
assert run.steps_executed == 1
assert run.current_frame().step_number == 1
assert run.steps_remaining == 0
assert remaining_step_attempts(run) == 0
def test_admission_assigns_step_numbers() -> None:
workflow = _minimal_workflow()
run = create_run_state(workflow, {}, limits=RunLimits(max_steps=3))
first = admit_step_attempt(run, run.current_frame(), workflow.start)
second = admit_step_attempt(run, run.current_frame(), workflow.start)
assert first == 1
assert second == 2
assert run.steps_executed == 2
assert run.current_frame().step_number == 2
assert run.steps_remaining == 1
assert remaining_step_attempts(run) == 1
def test_step_limit_error_details() -> None:
workflow = _minimal_workflow(name="budget_details")
run = create_run_state(workflow, {}, limits=RunLimits(max_steps=1))
frame = run.current_frame()
admit_step_attempt(run, frame, workflow.start)
with pytest.raises(WorkflowStepLimitExceeded) as exc_info:
admit_step_attempt(run, frame, workflow.start)
assert isinstance(exc_info.value, WorkflowExecutionError)
message = str(exc_info.value)
assert "budget_details" in message
assert "1" in message
assert frame.id in message
assert frame.scope_id in message
assert workflow.start in message
def test_remaining_never_negative() -> None:
workflow = _minimal_workflow()
run = create_run_state(workflow, {}, limits=RunLimits(max_steps=1))
run.steps_executed = 5
assert run.steps_remaining == 0
assert remaining_step_attempts(run) == 0
def test_dump_writes_v2_envelope() -> None:
workflow = _minimal_workflow()
run = create_run_state(workflow, {}, limits=RunLimits(max_steps=7))
admit_step_attempt(run, run.current_frame(), workflow.start)
stored = dump_run_state(run)
assert stored["version"] == 2
def test_v2_round_trip() -> None:
workflow = _minimal_workflow()
run = create_run_state(workflow, {}, limits=RunLimits(max_steps=7))
admit_step_attempt(run, run.current_frame(), workflow.start)
stored = dump_run_state(run)
restored, upgraded = load_run_state_with_upgrade(stored)
assert upgraded is False
assert restored.limits.max_steps == 7
assert restored.steps_executed == 1
assert restored.steps_remaining == 6
assert restored.frames["root"].step_number == 1
via_legacy = load_run_state(stored)
assert via_legacy.limits.max_steps == 7
assert via_legacy.steps_executed == 1
assert via_legacy.frames["root"].step_number == 1
def _strip_to_v1(stored: dict) -> dict:
state = dict(stored["state"])
state.pop("limits", None)
state.pop("steps_executed", None)
frames = {
frame_id: {key: value for key, value in frame.items() if key != "step_number"}
for frame_id, frame in dict(state["frames"]).items()
}
state["frames"] = frames
return {"version": 1, "state": state}
def test_v1_payload_receives_defaults_once() -> None:
workflow = _minimal_workflow()
run = create_run_state(workflow, {}, limits=RunLimits(max_steps=7))
stored = _strip_to_v1(dump_run_state(run))
restored, upgraded = load_run_state_with_upgrade(stored)
assert upgraded is True
assert restored.limits.max_steps == 10_000
assert restored.steps_executed == 0
assert restored.steps_remaining == 10_000
assert restored.frames["root"].step_number is None
via_legacy = load_run_state(stored)
assert via_legacy.limits.max_steps == 10_000
assert via_legacy.steps_executed == 0
def test_v2_missing_limits_is_corrupt() -> None:
workflow = _minimal_workflow()
run = create_run_state(workflow, {})
stored = dump_run_state(run)
stored["state"].pop("limits")
with pytest.raises(ValueError):
load_run_state_with_upgrade(stored)
def test_v2_missing_steps_executed_is_corrupt() -> None:
workflow = _minimal_workflow()
run = create_run_state(workflow, {})
stored = dump_run_state(run)
stored["state"].pop("steps_executed")
with pytest.raises(ValueError):
load_run_state_with_upgrade(stored)
def test_v2_missing_frame_step_number_is_corrupt() -> None:
workflow = _minimal_workflow()
run = create_run_state(workflow, {})
stored = dump_run_state(run)
del stored["state"]["frames"]["root"]["step_number"]
with pytest.raises(ValueError):
load_run_state_with_upgrade(stored)
# --- Task 2: sync dispatch and trace numbering ---
def _empty_schema() -> SchemaRef:
return SchemaRef(type="object", properties={})
def _ok_handler(payload: dict, _context: object) -> dict:
return {"outcome": "ok", "output": {}}
def _trace_numbers(run) -> list:
return [entry.step_number for entry in run.trace]
def _chain_workflow() -> Workflow:
defs = [
NodeDef(
name="da",
input_schema=_empty_schema(),
output_schema=_empty_schema(),
outcomes=["ok"],
),
NodeDef(
name="db",
input_schema=_empty_schema(),
output_schema=_empty_schema(),
outcomes=["ok"],
),
]
return Workflow(
name="chain",
input_schema=_empty_schema(),
state_schema=StateSchema.from_field_map({}),
output_schema=_empty_schema(),
outcomes=["ok"],
node_defs=defs,
start="a",
nodes=[
NodeUse(id="a", type="node", node="da"),
NodeUse(id="b", type="node", node="db"),
],
edges=[
Edge.model_validate({"from": "a", "outcome": "ok", "to": "b"}),
Edge.model_validate({"from": "b", "outcome": "ok", "to": END}),
],
)
def test_sync_node_use_counts_and_numbers_trace() -> None:
workflow = _chain_workflow()
run = execute_workflow(workflow, {}, {"da": _ok_handler, "db": _ok_handler})
assert run.status == RunStatus.COMPLETED
assert run.steps_executed == 2
assert _trace_numbers(run) == [1, 2]
assert [entry.node_id for entry in run.trace] == ["a", "b"]
assert run.steps_remaining == 10_000 - 2
def test_sync_condition_counts() -> None:
workflow = Workflow(
name="condition_counts",
input_schema=SchemaRef(
type="object", properties={"count": {"type": "integer"}}
),
state_schema=StateSchema.from_field_map({"count": StateField(type="integer")}),
output_schema=_empty_schema(),
outcomes=["ok"],
node_defs=[
NodeDef(
name="finish",
input_schema=_empty_schema(),
output_schema=_empty_schema(),
outcomes=["done"],
)
],
start="pick",
nodes=[
ConditionNode.model_validate(
{
"id": "pick",
"type": "condition",
"check": {
"op": "lt",
"left": {"path": "state.count"},
"right": {"value": 1},
},
}
),
NodeUse(id="finish", type="node", node="finish"),
],
edges=[
Edge.model_validate({"from": "pick", "outcome": "true", "to": "finish"}),
Edge.model_validate({"from": "pick", "outcome": "false", "to": END}),
Edge.model_validate({"from": "finish", "outcome": "done", "to": END}),
],
)
run = execute_workflow(workflow, {"count": 10}, {"finish": _ok_handler})
assert run.status == RunStatus.COMPLETED
assert run.steps_executed == 1
assert _trace_numbers(run) == [1]
assert run.trace[0].node_id == "pick"
assert run.trace[0].outcome == "false"
def _serial_foreach_workflow() -> Workflow:
foreach = ForeachNode.model_validate(
{
"id": "each",
"type": "foreach",
"over": "state.items",
"as": "item",
"mode": "serial",
}
)
return Workflow(
name="foreach_counts",
input_schema=SchemaRef(type="object", properties={"items": {"type": "array"}}),
state_schema=StateSchema.from_field_map(
{
"items": StateField(type="array"),
"seen": StateField(
type="array", reducer=ReducerRef(name="wf.std.append")
),
}
),
output_schema=SchemaRef(type="object", properties={"seen": {"type": "array"}}),
node_defs=[
NodeDef(
name="record",
input_schema=SchemaRef(
type="object", properties={"value": {}}, required=["value"]
),
output_schema=SchemaRef(
type="object", properties={"seen": {}}, required=["seen"]
),
outcomes=["ok"],
)
],
start="each",
nodes=[
foreach,
NodeUse.model_validate(
{
"id": "work",
"type": "node",
"node": "record",
"input": [{"target": "value", "path": "context.item"}],
"output": [{"source": "seen", "target": "state.seen"}],
}
),
],
edges=[
Edge.model_validate({"from": "each", "outcome": "loop", "to": "work"}),
Edge.model_validate({"from": "work", "outcome": "ok", "to": "each"}),
Edge.model_validate({"from": "each", "outcome": "done", "to": END}),
],
)
def test_sync_foreach_controller_and_body_share_counter() -> None:
workflow = _serial_foreach_workflow()
run = execute_workflow(
workflow,
{"items": ["a", "b"]},
{
"record": lambda payload, _ctx: {
"outcome": "ok",
"output": {"seen": payload["value"]},
}
},
)
assert run.status == RunStatus.COMPLETED
assert run.steps_executed == 5
assert _trace_numbers(run) == [1, 2, 3, 4, 5]
assert [entry.node_id for entry in run.trace] == [
"each",
"work",
"each",
"work",
"each",
]
assert [entry.outcome for entry in run.trace] == [
"loop",
"ok",
"loop",
"ok",
"done",
]
def _subgraph_parent_workflow() -> Workflow:
node = SubgraphNode.model_validate(
{
"id": "child",
"type": "subgraph",
"workflow": "child.workflow",
"input_schema": _empty_schema(),
"output_schema": _empty_schema(),
"input": [],
"output": [],
}
)
return Workflow(
name="subgraph_parent",
input_schema=_empty_schema(),
state_schema=StateSchema.from_field_map({}),
output_schema=_empty_schema(),
outcomes=["ok"],
start="child",
nodes=[node],
edges=[Edge.model_validate({"from": "child", "outcome": "ok", "to": END})],
)
def _subgraph_child_workflow() -> Workflow:
return Workflow(
name="child.workflow",
input_schema=_empty_schema(),
state_schema=StateSchema.from_field_map({}),
output_schema=_empty_schema(),
outcomes=["ok"],
node_defs=[
NodeDef(
name="answer",
input_schema=_empty_schema(),
output_schema=_empty_schema(),
outcomes=["ok"],
)
],
start="answer",
nodes=[NodeUse(id="answer", type="node", node="answer")],
edges=[Edge.model_validate({"from": "answer", "outcome": "ok", "to": END})],
)
def test_sync_subgraph_entry_and_return_share_counter() -> None:
parent = _subgraph_parent_workflow()
child = _subgraph_child_workflow()
run = execute_workflow(
parent,
{},
{},
subgraphs={
"child.workflow": PreparedSubgraph(
workflow=child, registry={"answer": _ok_handler}
)
},
)
assert run.status == RunStatus.COMPLETED
# Parent entry admits once without emitting a trace (gap), the child body
# admits once, and the parent return admits once more.
assert run.steps_executed == 3
assert _trace_numbers(run) == [2, 3]
assert run.trace[0].node_id == "answer"
assert run.trace[-1].node_id == "child"
def _interrupt_workflow() -> Workflow:
return Workflow(
name="interrupt_counts",
input_schema=_empty_schema(),
state_schema=StateSchema.from_field_map({}),
output_schema=_empty_schema(),
outcomes=["ok"],
node_defs=[
NodeDef(
name="work",
input_schema=_empty_schema(),
output_schema=_empty_schema(),
outcomes=["ok"],
)
],
start="ask",
nodes=[
InterruptNode(id="ask", type="interrupt", kind="approval"),
NodeUse(id="work", type="node", node="work"),
],
edges=[
Edge.model_validate({"from": "ask", "outcome": "submitted", "to": "work"}),
Edge.model_validate({"from": "work", "outcome": "ok", "to": END}),
],
)
def test_sync_interrupt_activation_counts_once() -> None:
workflow = _interrupt_workflow()
run = execute_workflow(workflow, {}, {"work": _ok_handler})
assert run.status == RunStatus.INTERRUPTED
assert run.steps_executed == 1
assert _trace_numbers(run) == [1]
assert run.trace[0].outcome == "interrupt"
assert run.interrupt is not None
assert run.interrupt.step_number == 1
def test_sync_interrupt_resume_reuses_activation_number() -> None:
workflow = _interrupt_workflow()
interrupted = execute_workflow(workflow, {}, {"work": _ok_handler})
resumed = resume_workflow(
workflow,
interrupted,
{"work": _ok_handler},
resume_payload={},
resume_outcome="submitted",
)
assert resumed.status == RunStatus.COMPLETED
# Resume completes the admitted activation without a new attempt: both the
# interrupt entry and its completion entry carry number 1.
assert resumed.steps_executed == 2
assert _trace_numbers(resumed) == [1, 1, 2]
assert resumed.trace[1].node_id == "ask"
assert resumed.trace[1].outcome == "submitted"
assert resumed.interrupt is None
def test_sync_explicit_end_counts() -> None:
workflow = Workflow(
name="explicit_end",
input_schema=_empty_schema(),
state_schema=StateSchema.from_field_map({}),
output_schema=_empty_schema(),
outcomes=["done"],
node_defs=[
NodeDef(
name="finish",
input_schema=_empty_schema(),
output_schema=_empty_schema(),
outcomes=["done"],
)
],
start="finish",
nodes=[
NodeUse(id="finish", type="node", node="finish"),
EndNode(id="end", type="end", outcome="done"),
],
edges=[Edge.model_validate({"from": "finish", "outcome": "done", "to": "end"})],
)
def finish(_payload: dict, _context: object) -> dict:
return {"outcome": "done", "output": {}}
run = execute_workflow(workflow, {}, {"finish": finish})
assert run.status == RunStatus.COMPLETED
assert run.outcome == "done"
assert run.steps_executed == 2
assert _trace_numbers(run) == [1, 2]
assert run.trace[-1].step_type == "end"
def test_sync_legacy_end_creates_no_extra_attempt() -> None:
workflow = _minimal_workflow()
run = execute_workflow(workflow, {}, {"finish": _ok_handler})
assert run.status == RunStatus.COMPLETED
assert run.steps_executed == 1
assert _trace_numbers(run) == [1]
def test_sync_handler_failure_consumes_attempt() -> None:
workflow = _minimal_workflow()
def explode(_payload: dict, _context: object) -> dict:
raise ValueError("boom")
run = create_run_state(workflow, {})
with pytest.raises(ValueError, match="boom"):
step_workflow(workflow, run, {"finish": explode})
assert run.steps_executed == 1
# The attempt failed before any normal trace entry existed (gap, not a recount).
assert run.trace == []
def test_sync_handled_error_outcome_counts_once() -> None:
workflow = Workflow(
name="error_outcome",
input_schema=_empty_schema(),
state_schema=StateSchema.from_field_map({}),
output_schema=_empty_schema(),
outcomes=["ok"],
node_defs=[
NodeDef(
name="risky",
input_schema=_empty_schema(),
output_schema=_empty_schema(),
outcomes=["ok", "error"],
)
],
start="work",
nodes=[NodeUse(id="work", type="node", node="risky")],
edges=[
Edge.model_validate({"from": "work", "outcome": "ok", "to": END}),
Edge.model_validate({"from": "work", "outcome": "error", "to": END}),
],
)
def fail_soft(_payload: dict, _context: object) -> dict:
return {"outcome": "error", "output": {}}
run = execute_workflow(workflow, {}, {"risky": fail_soft})
assert run.status == RunStatus.COMPLETED
assert run.steps_executed == 1
assert _trace_numbers(run) == [1]
assert run.trace[0].outcome == "error"
def _cyclic_workflow() -> Workflow:
defs = [
NodeDef(
name="da",
input_schema=_empty_schema(),
output_schema=_empty_schema(),
outcomes=["ok"],
),
NodeDef(
name="db",
input_schema=_empty_schema(),
output_schema=_empty_schema(),
outcomes=["ok"],
),
]
return Workflow(
name="cycle",
input_schema=_empty_schema(),
state_schema=StateSchema.from_field_map({}),
output_schema=_empty_schema(),
outcomes=["ok"],
node_defs=defs,
start="a",
nodes=[
NodeUse(id="a", type="node", node="da"),
NodeUse(id="b", type="node", node="db"),
],
edges=[
Edge.model_validate({"from": "a", "outcome": "ok", "to": "b"}),
Edge.model_validate({"from": "b", "outcome": "ok", "to": "a"}),
],
)
def test_sync_closed_cycle_fails_at_limit() -> None:
workflow = _cyclic_workflow()
calls: list[str] = []
def make(name: str): # type: ignore[no-untyped-def]
def handler(_payload: dict, _context: object) -> dict:
calls.append(name)
return {"outcome": "ok", "output": {}}
return handler
run = create_run_state(workflow, {}, limits=RunLimits(max_steps=3))
# Bounded manual stepping: without a budget this cycle would never stop,
# so the test itself caps iterations instead of relying on the engine loop.
with pytest.raises(WorkflowStepLimitExceeded):
for _ in range(10):
step_workflow(workflow, run, {"da": make("a"), "db": make("b")})
assert run.steps_executed == 3
assert _trace_numbers(run) == [1, 2, 3]
assert calls == ["a", "b", "a"]
def _counting_loop_workflow() -> Workflow:
return Workflow(
name="counting_loop",
input_schema=SchemaRef(
type="object", properties={"count": {"type": "integer"}}
),
state_schema=StateSchema.from_field_map({"count": StateField(type="integer")}),
output_schema=_empty_schema(),
outcomes=["ok"],
node_defs=[
NodeDef(
name="bump",
input_schema=SchemaRef(
type="object", properties={"count": {"type": "integer"}}
),
output_schema=SchemaRef(
type="object", properties={"count": {"type": "integer"}}
),
outcomes=["ok"],
)
],
start="again",
nodes=[
ConditionNode.model_validate(
{
"id": "again",
"type": "condition",
"check": {
"op": "lt",
"left": {"path": "state.count"},
"right": {"value": 2},
},
}
),
NodeUse.model_validate(
{
"id": "bump",
"type": "node",
"node": "bump",
"input": [{"target": "count", "path": "state.count"}],
"output": [{"source": "count", "target": "state.count"}],
}
),
],
edges=[
Edge.model_validate({"from": "again", "outcome": "true", "to": "bump"}),
Edge.model_validate({"from": "bump", "outcome": "ok", "to": "again"}),
Edge.model_validate({"from": "again", "outcome": "false", "to": END}),
],
)
def test_sync_exiting_loop_completes_within_budget() -> None:
workflow = _counting_loop_workflow()
def bump(payload: dict, _context: object) -> dict:
count = payload.get("count", 0)
assert isinstance(count, int)
return {"outcome": "ok", "output": {"count": count + 1}}
run = execute_workflow(workflow, {"count": 0}, {"bump": bump})
assert run.status == RunStatus.COMPLETED
assert run.steps_executed == 5
assert _trace_numbers(run) == [1, 2, 3, 4, 5]
def test_sync_denial_never_invokes_handler() -> None:
workflow = _chain_workflow()
b_calls: list[dict] = []
def b_handler(payload: dict, _context: object) -> dict:
b_calls.append(payload)
return {"outcome": "ok", "output": {}}
run = create_run_state(workflow, {}, limits=RunLimits(max_steps=1))
with pytest.raises(WorkflowStepLimitExceeded):
resume_workflow(workflow, run, {"da": _ok_handler, "db": b_handler})
assert run.steps_executed == 1
assert _trace_numbers(run) == [1]
assert b_calls == []
def _strip_budget_fields(stored: dict) -> dict:
state = dict(_strip_to_v1(stored)["state"])
state["trace"] = [
{key: value for key, value in entry.items() if key != "step_number"}
for entry in state.get("trace", [])
]
if state.get("interrupt") is not None:
state["interrupt"] = {
key: value
for key, value in state["interrupt"].items()
if key != "step_number"
}
return {"version": 1, "state": state}
def test_v1_traces_and_interrupt_receive_none_step_numbers() -> None:
workflow = _interrupt_workflow()
run = execute_workflow(workflow, {}, {"work": _ok_handler})
stored = _strip_budget_fields(dump_run_state(run))
restored, upgraded = load_run_state_with_upgrade(stored)
assert upgraded is True
assert restored.trace[0].step_number is None
assert restored.interrupt is not None
assert restored.interrupt.step_number is None
def test_v2_missing_trace_step_number_is_corrupt() -> None:
workflow = _interrupt_workflow()
run = execute_workflow(workflow, {}, {"work": _ok_handler})
stored = dump_run_state(run)
del stored["state"]["trace"][0]["step_number"]
with pytest.raises(ValueError):
load_run_state_with_upgrade(stored)
def test_v2_missing_interrupt_step_number_is_corrupt() -> None:
workflow = _interrupt_workflow()
run = execute_workflow(workflow, {}, {"work": _ok_handler})
stored = dump_run_state(run)
del stored["state"]["interrupt"]["step_number"]
with pytest.raises(ValueError):
load_run_state_with_upgrade(stored)