docs: complete run step budget slice

This commit is contained in:
lda
2026-09-05 23:15:57 +07:00 Verified
parent 715035dce8
commit fd48a67819
19 changed files with 735 additions and 541 deletions
+2 -1
View File
@@ -38,6 +38,7 @@ from .run_codec import (
load_run_state,
load_run_state_with_upgrade,
)
from .run_limits import RunLimits
from .run_state import (
ExecutionFrame,
ForeachContext,
@@ -65,7 +66,7 @@ from .runtime import (
step_workflow,
step_workflow_async,
)
from .runtime.limits import RunLimits, admit_step_attempt, remaining_step_attempts
from .runtime.limits import admit_step_attempt, remaining_step_attempts
from .tokens import END, START
from .validation import (
ValidationIssue,
+23 -12
View File
@@ -5,8 +5,8 @@ from typing import Any, Literal, cast
from pydantic import BaseModel, ConfigDict, TypeAdapter, ValidationError
from .run_limits import RunLimits
from .run_state import ROOT_SCOPE_ID, RunState
from .runtime.limits import RunLimits
class PersistedRunState(BaseModel):
@@ -57,7 +57,13 @@ def _check_step_number(value: object, *, steps_executed: int, what: str) -> None
"""
if value is None:
return
if not _is_strict_int(value) or not 1 <= value <= steps_executed: # type: ignore[operator]
if not _is_strict_int(value):
raise ValueError(
"invalid persisted workflow run state: "
f"{what} has incoherent step number {value!r}"
)
number = cast(int, value)
if not 1 <= number <= steps_executed:
raise ValueError(
"invalid persisted workflow run state: "
f"{what} has incoherent step number {value!r}"
@@ -101,27 +107,32 @@ def _require_v2_budget_fields(state: dict[str, Any]) -> None:
corruption, not another request for defaults. Values are validated
strictly on the raw envelope (exact ints, ranges, coherence) because lax
coercion would otherwise accept bools, numeric strings, negatives, or
future frame numbers and silently inflate or distort the budget. Trace
and interrupt entries always carry the key (``None`` only for unadmitted
or upgraded pre-budget history), so a missing key is likewise corrupt
even though the dataclass default would otherwise mask it.
future frame numbers and silently inflate or distort the budget. The
limits object holds exactly ``max_steps``: unknown fields are corrupt
rather than silently dropped, since no stored-data contract emits them.
Trace and interrupt entries always carry the key (``None`` only for
unadmitted or upgraded pre-budget history), so a missing key is likewise
corrupt even though the dataclass default would otherwise mask it.
"""
limits = state.get("limits")
if not isinstance(limits, dict) or "max_steps" not in limits:
if not isinstance(limits, dict) or set(limits.keys()) != {"max_steps"}:
raise ValueError("invalid persisted workflow run state: missing step budget")
max_steps = limits["max_steps"]
if not _is_strict_int(max_steps) or max_steps < 1: # type: ignore[operator]
if not _is_strict_int(max_steps):
raise ValueError(
"invalid persisted workflow run state: corrupt step budget limit"
)
max_steps_value = cast(int, max_steps)
if max_steps_value < 1:
raise ValueError(
"invalid persisted workflow run state: corrupt step budget limit"
)
steps_executed = state.get("steps_executed")
if not _is_strict_int(steps_executed):
raise ValueError("invalid persisted workflow run state: missing step budget")
if not 0 <= steps_executed <= max_steps: # type: ignore[operator]
raise ValueError(
"invalid persisted workflow run state: corrupt step counter"
)
exec_count = cast(int, steps_executed)
if not 0 <= exec_count <= max_steps_value:
raise ValueError("invalid persisted workflow run state: corrupt step counter")
frames = state.get("frames")
if not isinstance(frames, dict):
raise ValueError("invalid persisted workflow run state: missing frames")
+28
View File
@@ -0,0 +1,28 @@
"""Neutral run-wide step budget value model.
`RunLimits` is immutable policy captured when a run is created. It lives
here (next to `run_state`, not under `runtime`) so `run_state` can import
it at module top without executing the `wf_core.runtime` package whose
engine imports `run_state` back. Admission policy stays in
`wf_core.runtime.limits`.
"""
from __future__ import annotations
from dataclasses import dataclass
@dataclass(frozen=True, slots=True)
class RunLimits:
"""Immutable step budget captured when a run is created."""
max_steps: int = 10_000
def __post_init__(self) -> None:
if isinstance(self.max_steps, bool) or not isinstance(self.max_steps, int):
raise TypeError("max_steps must be an integer")
if self.max_steps < 1:
raise ValueError("max_steps must be positive")
__all__ = ["RunLimits"]
+4 -23
View File
@@ -2,14 +2,12 @@ from __future__ import annotations
from dataclasses import asdict, dataclass, field
from enum import StrEnum
from typing import TYPE_CHECKING, Any
from typing import Any
from wf_core.models.reducers import ReducerRef
from wf_core.models.workflow_refs import WorkflowRef
from wf_core.paths import StatePath
if TYPE_CHECKING:
from wf_core.runtime.limits import RunLimits
from wf_core.run_limits import RunLimits
ROOT_SCOPE_ID = "root"
ROOT_LINEAGE_ID = "root"
@@ -193,23 +191,12 @@ class InterruptRequest:
step_number: int | None = None
def _default_run_limits() -> RunLimits:
"""Build the default budget without a top-level runtime import.
Importing ``wf_core.runtime.limits`` at module top would execute the
``wf_core.runtime`` package, whose engine imports this module back.
"""
from wf_core.runtime.limits import RunLimits
return RunLimits()
@dataclass(slots=True)
class RunState:
"""Mutable execution state for one workflow run.
``limits``/``steps_executed`` form the persisted run-wide step budget (see
``wf_core.runtime.limits``); ``steps_remaining`` is computed from them.
``wf_core.run_limits``); ``steps_remaining`` is computed from them.
"""
workflow_name: str
@@ -229,7 +216,7 @@ class RunState:
activated_incoming_edge: str | None = None
error: str | None = None
interrupt: InterruptRequest | None = None
limits: RunLimits = field(default_factory=_default_run_limits)
limits: RunLimits = field(default_factory=RunLimits)
steps_executed: int = 0
@property
@@ -257,9 +244,3 @@ class RunState:
def to_dict(self) -> dict[str, Any]:
return asdict(self)
# Deferred runtime import: binding ``RunLimits`` here (after every class is
# defined) lets ``wf_core.runtime`` engine modules import this module back
# without a cycle, and gives pydantic a resolvable annotation for the codec.
from wf_core.runtime.limits import RunLimits # noqa: E402
+1 -1
View File
@@ -5,8 +5,8 @@ from typing import Any
from wf_core.errors import WorkflowExecutionError
from wf_core.models.workflow import Workflow
from wf_core.run_limits import RunLimits
from wf_core.run_state import ROOT_SCOPE_ID, RunState, RunStatus
from wf_core.runtime.limits import RunLimits
from wf_core.runtime.ops.flow import finalize_run
from wf_core.runtime.ops.merges import ReducerDefinition
from wf_core.runtime.ops.nodes import AsyncNodeHandler, NodeHandler
+1 -15
View File
@@ -10,7 +10,6 @@ checkpoints; the counter is persisted inside the existing stopped-run envelope.
from __future__ import annotations
from dataclasses import dataclass
from typing import TYPE_CHECKING
from wf_core.errors import WorkflowStepLimitExceeded
@@ -19,19 +18,6 @@ if TYPE_CHECKING:
from wf_core.run_state import ExecutionFrame, RunState
@dataclass(frozen=True, slots=True)
class RunLimits:
"""Immutable step budget captured when a run is created."""
max_steps: int = 10_000
def __post_init__(self) -> None:
if isinstance(self.max_steps, bool) or not isinstance(self.max_steps, int):
raise TypeError("max_steps must be an integer")
if self.max_steps < 1:
raise ValueError("max_steps must be positive")
def admit_step_attempt(run: RunState, frame: ExecutionFrame, node_id: str) -> int:
"""Admit one step attempt for ``frame`` about to dispatch ``node_id``.
@@ -50,4 +36,4 @@ def admit_step_attempt(run: RunState, frame: ExecutionFrame, node_id: str) -> in
def remaining_step_attempts(run: RunState) -> int:
"""Return the unspent budget, floored at zero (never negative)."""
return max(run.limits.max_steps - run.steps_executed, 0)
return run.steps_remaining
+1 -1
View File
@@ -4,6 +4,7 @@ from copy import deepcopy
from wf_core.models.workflow import Workflow
from wf_core.paths import set_nested_value
from wf_core.run_limits import RunLimits
from wf_core.run_state import (
ROOT_FRAME_ID,
ROOT_LINEAGE_ID,
@@ -15,7 +16,6 @@ from wf_core.run_state import (
RunStatus,
RuntimeScope,
)
from wf_core.runtime.limits import RunLimits
from wf_core.runtime.scheduler import add_frame