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
+1 -1
View File
@@ -120,7 +120,7 @@ docs as the active references:
implemented same-scope nested foreach context, path, schema, and
authoring-ref semantics.
- [`superpowers/specs/2026-09-04-run-step-budget-design.md`](superpowers/specs/2026-09-04-run-step-budget-design.md):
proposed persisted run-wide protection against unbounded graph execution.
implemented persisted run-wide step budget against unbounded graph execution.
- [`superpowers/specs/2026-05-24-native-subgraphs-design.md`](superpowers/specs/2026-05-24-native-subgraphs-design.md):
native subgraph design.
- [`superpowers/specs/2026-05-26-durable-workflow-runs-and-resume-design.md`](superpowers/specs/2026-05-26-durable-workflow-runs-and-resume-design.md):
+8 -14
View File
@@ -40,27 +40,18 @@ author -> validate -> save artifact -> deploy -> run -> inspect or resume
## Active runtime sequence
The next three slices build on the foreach control-region, structured-context,
scheduler, lineage, and barrier foundations in this order.
The next two slices build on the foreach control-region, structured-context,
scheduler, lineage, barrier, and persisted run step budget foundations in
this order.
### 1. Add a persisted run step budget
Implement the proposed run-wide limit:
- [`run step budget design`](superpowers/specs/2026-09-04-run-step-budget-design.md)
The budget must cover every frame and subgraph scope in one run, survive
checkpoint and resume, and stop valid but non-terminating graph cycles with a
clear runtime failure.
### 2. Consolidate runtime identity resolution
### 1. Consolidate runtime identity resolution
Introduce one internal resolver for a frame, lineage, runtime scope, and
foreach activation environment. The resolver should validate the canonical
identity chain once so fork/gather code does not pass related identifiers
independently or repeat ownership walks.
### 3. Implement explicit fork and gather
### 2. Implement explicit fork and gather
Reuse the scheduler, activation, lineage, and reducer-aware barrier machinery:
@@ -141,6 +132,9 @@ The active sequence can assume these foundations:
- Removal of the pass-through `JoinNode`; future `GatherNode` starts with its
actual synchronization contract and no placeholder compatibility
- Durable stopped-run inspection and resume
- Persisted run-wide step budget (`RunLimits`, `steps_executed`,
computed `steps_remaining`) covering every frame and subgraph scope,
surviving checkpoint and resume, with exhaustion as a failed run
- Python client reconstruction of capabilities, artifacts, deployments, and
runs through the API
@@ -55,7 +55,7 @@ async workflow runtimes, FastAPI JSON-RPC, the Python workflow client, pytest.
- Produces: `remaining_step_attempts(run) -> int`.
- Produces: `load_run_state_with_upgrade(payload) -> tuple[RunState, bool]`.
- [ ] **Step 1: Write failing model, admission, and codec tests**
- [x] **Step 1: Write failing model, admission, and codec tests**
Cover positive validation, the default, a budget of one, denied admission not
incrementing, error details, v2 round-trip, v1 default injection, and v2
@@ -73,7 +73,7 @@ with pytest.raises(WorkflowStepLimitExceeded):
admit_step_attempt(run, run.current_frame(), workflow.start)
```
- [ ] **Step 2: Run the new tests and verify they fail for missing symbols**
- [x] **Step 2: Run the new tests and verify they fail for missing symbols**
Run:
@@ -81,7 +81,7 @@ Run:
uv run pytest tests/core/test_run_step_budget.py tests/core/test_run_codec.py -q
```
- [ ] **Step 3: Implement the minimal core model and admission module**
- [x] **Step 3: Implement the minimal core model and admission module**
```python
@dataclass(frozen=True, slots=True)
@@ -109,14 +109,14 @@ Add `limits`, `steps_executed`, and computed `steps_remaining` to `RunState`;
add `step_number: int | None` to `ExecutionFrame`; pass optional limits through
`create_run_state()`.
- [ ] **Step 4: Implement strict v2 output and explicit v1 loading**
- [x] **Step 4: Implement strict v2 output and explicit v1 loading**
`dump_run_state()` writes envelope version 2. Version 1 may omit the three new
fields and receives defaults. Version 2 validates that `limits`,
`steps_executed`, and each serialized frame's `step_number` field are present
before using the dataclass adapter. Return `upgraded=True` only for v1.
- [ ] **Step 5: Run the focused tests and commit**
- [x] **Step 5: Run the focused tests and commit**
Run:
@@ -143,18 +143,18 @@ Commit: `feat: add persisted run step budget state`
- Produces: `TraceEntry.step_number: int`.
- Produces: `InterruptRequest.step_number: int`.
- [ ] **Step 1: Add failing sync behavior tests**
- [x] **Step 1: Add failing sync behavior tests**
Test NodeUse, condition, foreach controller/body, subgraph entry/return,
interrupt/resume, explicit End, legacy `END`, handler failure, handled `error`
outcome, a closed cycle, an exiting loop, and denial without handler invocation.
Assert trace numbers rather than inferring counts from trace length.
- [ ] **Step 2: Verify the focused tests fail before dispatch is counted**
- [x] **Step 2: Verify the focused tests fail before dispatch is counted**
Run: `uv run pytest tests/core/test_run_step_budget.py -q`
- [ ] **Step 3: Admit after resolving the selected step and before dispatch**
- [x] **Step 3: Admit after resolving the selected step and before dispatch**
Call `admit_step_attempt()` exactly once in the non-batched paths of
`step_workflow()` and `step_workflow_async()`. Make `append_trace()` fail closed
@@ -163,7 +163,7 @@ trace produced during the dispatch. Store the interrupt activation's number on
`InterruptRequest`; its resume-completion trace reuses that value and does not
admit another attempt.
- [ ] **Step 4: Verify sync semantics and commit**
- [x] **Step 4: Verify sync semantics and commit**
Run:
@@ -190,7 +190,7 @@ Commit: `feat: enforce step budget during sync dispatch`
- Consumes: `admit_step_attempt(...) -> int`.
- Produces: bounded `_claim_matching_async_item_frames(..., limit: int)`.
- [ ] **Step 1: Write failing async reservation tests**
- [x] **Step 1: Write failing async reservation tests**
Use handlers gated by `asyncio.Event` to prove that a three-unit remainder
starts only the first three eligible frames, assigns numbers in queue order,
@@ -198,18 +198,18 @@ keeps reservations after a handler failure, settles siblings before raising,
and discards later sibling state/trace commits after the first unhandled result
in reservation order.
- [ ] **Step 2: Verify the tests fail because batching claims every sibling**
- [x] **Step 2: Verify the tests fail because batching claims every sibling**
Run: `uv run pytest tests/core/test_run_step_budget_async.py -q`
- [ ] **Step 3: Bound claims and reserve before creating handler tasks**
- [x] **Step 3: Bound claims and reserve before creating handler tasks**
Before `_step_async_foreach_item_batch()` creates any coroutine, require one
unit for `first_frame`, claim at most `remaining - 1` matching frames, then call
`admit_step_attempt()` for the resulting ordered frame list. Do not launch any
task until every selected frame has its number.
- [ ] **Step 4: Verify async and parity suites and commit**
- [x] **Step 4: Verify async and parity suites and commit**
Run:
@@ -242,14 +242,14 @@ Commit: `feat: reserve async workflow step attempts`
- Produces: optional `max_steps` on run creation only.
- Produces: `max_steps`, `steps_executed`, and `steps_remaining` in run results.
- [ ] **Step 1: Write failing API and migration tests**
- [x] **Step 1: Write failing API and migration tests**
Pin requested/effective limit inspection, interrupted resume preserving the
counter, resume accepting no replacement, and a v1 interrupted checkpoint being
rewritten as v2 before runtime dispatch. Make the fake runtime assert it has not
been called until the upgraded checkpoint exists.
- [ ] **Step 2: Verify the API tests fail on the missing fields**
- [x] **Step 2: Verify the API tests fail on the missing fields**
Run:
@@ -257,7 +257,7 @@ Run:
uv run pytest tests/wf_api/test_runs.py tests/wf_api/test_run_lifecycle.py -q
```
- [ ] **Step 3: Thread limits through creation and project inspection fields**
- [x] **Step 3: Thread limits through creation and project inspection fields**
`WorkflowRunApi.run_deployment(..., max_steps: int | None = None)` constructs
`RunLimits(max_steps=max_steps)` when supplied and otherwise uses the default.
@@ -270,14 +270,14 @@ passes it to the core async executor. `_run_payload()` always includes:
"steps_remaining": run.steps_remaining,
```
- [ ] **Step 4: Persist a v1 upgrade before resume dispatch**
- [x] **Step 4: Persist a v1 upgrade before resume dispatch**
In `restore_interrupted_run()`, load the raw latest checkpoint with the upgrade
flag. If true, call `persist_stopped_run()` with the same run id and pinned
environment, producing a v2 interrupted checkpoint before returning the run to
the caller. Ordinary inspection may decode v1 prospectively without mutation.
- [ ] **Step 5: Verify API behavior and commit**
- [x] **Step 5: Verify API behavior and commit**
Run:
@@ -313,13 +313,13 @@ Commit: `feat: persist and inspect run step budgets`
`.steps_remaining`.
- Produces: CLI `wf run start --max-steps INTEGER`.
- [ ] **Step 1: Write failing round-trip and client reconstruction tests**
- [x] **Step 1: Write failing round-trip and client reconstruction tests**
Assert the request includes `max_steps` only when supplied; the response
decoder requires all three inspection fields; refresh/resume preserve them;
and CLI rejects zero before making an API request.
- [ ] **Step 2: Verify transport/client tests fail**
- [x] **Step 2: Verify transport/client tests fail**
Run:
@@ -329,12 +329,12 @@ uv run pytest tests/wf_transport_rpc_http/test_client.py
uv run pytest tests/wf_transport_rpc_http/test_app.py tests/wf_cli/test_app.py -q
```
- [ ] **Step 3: Thread the optional creation value and reconstruct results**
- [x] **Step 3: Thread the optional creation value and reconstruct results**
Keep `max_steps` off resume signatures. Validate the CLI option with Typer
`min=1`; server-side `RunLimits` remains authoritative for non-CLI callers.
- [ ] **Step 4: Regenerate contracts, verify, and commit**
- [x] **Step 4: Regenerate contracts, verify, and commit**
Run:
@@ -361,14 +361,14 @@ Commit: `feat: expose run step budgets to clients`
**Interfaces:** None.
- [ ] **Step 1: Document creation, inspection, exhaustion, and resume**
- [x] **Step 1: Document creation, inspection, exhaustion, and resume**
Show `Deployment.run(..., max_steps=50_000)`, `wf run start --max-steps`, the
three inspection fields, and that resume cannot reset the budget. Remove the
step-budget item from the active roadmap and leave runtime identity resolution
as the next fork/gather prerequisite.
- [ ] **Step 2: Run focused and full verification**
- [x] **Step 2: Run focused and full verification**
Run:
@@ -385,7 +385,7 @@ pnpm --dir web test
pnpm --dir web typecheck
```
- [ ] **Step 3: Retire the completed plan and commit**
- [x] **Step 3: Retire the completed plan and commit**
Commit: `docs: complete run step budget slice`
+34
View File
@@ -702,6 +702,29 @@ wf run start concat_ws.default \
--input '{"items":["red","blue"],"separator":" + "}'
```
Start with an explicit run-wide step budget (default `10_000` when omitted):
```bash
wf run start concat_ws.default \
--input '{"items":["red","blue"]}' \
--max-steps 50000
```
`--max-steps` must be at least 1; the server validates it again. The budget
covers every frame and subgraph scope in the run and stops runaway cycles
with a failed run instead of a routable workflow outcome.
The Python client accepts the same creation-only value:
```python
run = await deployment.run({"items": ["red", "blue"]}, max_steps=50_000)
assert (run.max_steps, run.steps_executed, run.steps_remaining) == (
50_000,
run.steps_executed,
50_000 - run.steps_executed,
)
```
List durable stopped runs:
```bash
@@ -721,6 +744,17 @@ Inspect a run without trace detail:
wf run inspect run_123
```
Inspection reports the effective step budget alongside status and output:
- `max_steps`: effective limit stored with the run
- `steps_executed`: admitted step attempts so far
- `steps_remaining`: unspent budget, floored at zero
Resume reuses the persisted budget and accepts no replacement value.
`wf run resume` takes only a payload and outcome; it never resets the
counter. Budget exhaustion fails the run with a step-budget error and never
invokes the denied handler.
Poll a run until it stops:
```bash
+6
View File
@@ -13,7 +13,9 @@ and user-facing control belong in `wf_mcp`.
| --- | --- |
| `wf_core.models` | Pydantic workflow schema package: schemas, condition expressions, executable steps, workflow graph, and node results. |
| `wf_core.run_state` | Serializable execution state: run status, frames, trace entries, interrupt requests, and runtime context. |
| `wf_core.run_limits` | Immutable run-wide step budget (`RunLimits`). |
| `wf_core.runtime` | Public execution interface: execute, resume, and step in sync or async mode. |
| `wf_core.runtime.limits` | Admits one counted attempt per dispatched step. |
| `wf_core.runtime.scheduler` | Internal frame scheduler: ready queue, selected cursor, frame creation, block/wake helpers, and typed foreach frame metadata. |
| `wf_core.runtime.ops` | Executor-only operations used behind `wf_core.runtime`: node execution, state writes, frame movement, foreach, interrupts, indexes, and schema checks. |
| `wf_core.validation` | Structural workflow validation split by validation concern. |
@@ -35,6 +37,10 @@ flat modules.
`RunState.ready_frame_ids`, marks that frame `RUNNING`, and updates
compatibility cursor fields such as `current_frame_id`.
5. `step_workflow` resolves the selected frame and dispatches by step type.
Admission runs first: `runtime.limits.admit_step_attempt` consumes one unit
of the run-wide budget (`RunLimits`, default `10_000`) and stamps the frame
with its step number before any handler runs. Exhaustion fails the run and
never invokes the denied handler.
6. A normal non-terminal step marks the same frame `PENDING` and puts it back at
the end of the ready queue.
7. Terminal, blocked, interrupted, and failed frames are not re-enqueued.