docs: design runtime context and step budgets

This commit is contained in:
lda
2026-09-04 07:01:45 +07:00 Verified
parent 79f3054426
commit 3d9d855b8e
4 changed files with 654 additions and 0 deletions
+5
View File
@@ -116,6 +116,11 @@ docs as the active references:
proposed explicit fork, gather-slot, and activation-token semantics.
- [`superpowers/specs/2026-09-04-foreach-back-edge-design.md`](superpowers/specs/2026-09-04-foreach-back-edge-design.md):
approved canonical foreach body-return and validation semantics.
- [`superpowers/specs/2026-09-04-structured-runtime-context-design.md`](superpowers/specs/2026-09-04-structured-runtime-context-design.md):
proposed 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.
- [`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):
+7
View File
@@ -801,6 +801,13 @@ stable.
This slice also makes foreach control regions fail-closed, rejects unreachable
workflow nodes, and gives repeated visits to one foreach node distinct
persisted activation identities.
- Proposed follow-up: replace innermost-only foreach values with same-scope
[`structured runtime context`](superpowers/specs/2026-09-04-structured-runtime-context-design.md),
including typed Python lookup, graph paths, schema analysis, authoring refs,
and explicit subgraph input boundaries.
- Proposed runtime guard: add a persisted, run-wide
[`step budget`](superpowers/specs/2026-09-04-run-step-budget-design.md) for
valid graph cycles that cannot be proven terminating during validation.
- After that correction, reuse the foreach barrier/lineage machinery for
fork/gather. The proposed control semantics are recorded in
[`ADR-0006`](adr/0006-explicit-fork-and-topology-driven-gather.md).
@@ -0,0 +1,280 @@
# Run Step Budget Design
## Status
Proposed for review on 2026-09-04. This document specifies a persisted
run-wide guard against unbounded graph execution. It is independent of foreach
control-region validation and structured runtime context.
## Purpose
Valid workflows may contain ordinary or data-dependent cycles:
```text
a -> b
^ |
|____|
```
Static validation cannot prove that such a cycle eventually exits. The runtime
therefore needs a deterministic step budget that stops a runaway run without
pretending every legal loop can be rejected during validation.
The intended core configuration is:
```python
RunLimits(max_steps=10_000)
```
The budget belongs to the run, covers all of its frames and subgraph scopes,
and survives checkpoint/resume.
## Terminology
**Run Limits** are immutable execution limits captured when a run is created.
They are runtime policy, not workflow graph semantics.
**Step Attempt** is one admitted attempt to execute a selected workflow `Step`
in one frame. Node uses, conditions, foreach controllers, subgraph boundaries,
interrupt nodes, joins, and explicit end nodes all count.
**Step Number** is the one-based ordinal assigned to an admitted step attempt
within a run.
**Step Budget Exhaustion** occurs when the runtime would begin another step
after `max_steps` attempts have already been admitted.
## Configuration and State
Core runtime state gains JSON-compatible limit and progress fields:
```python
@dataclass(frozen=True, slots=True)
class RunLimits:
max_steps: int = 10_000
@dataclass(slots=True)
class RunState:
limits: RunLimits = field(default_factory=RunLimits)
steps_executed: int = 0
```
`max_steps` must be a positive integer. The first implementation does not add
an unlimited sentinel: callers that intentionally need large runs can choose a
larger explicit value while every run retains a finite protection boundary.
New execution entry points accept optional limits and capture the normalized
value in `RunState`:
```python
execute_workflow(
workflow,
workflow_input,
registry,
limits=RunLimits(max_steps=50_000),
)
```
Resume entry points use the limits stored in the run. They do not silently
reset the counter or accept a replacement budget. A future administrative
operation may deliberately extend a stopped run, but ordinary resume is not
that operation.
The platform may enforce a lower deployment- or account-level maximum when it
creates the core `RunLimits`. Core state still records the effective value so
inspection and resume do not depend on mutable external configuration.
## Counting Semantics
The runtime consumes one budget unit after selecting a runnable frame and
resolving its current `Step`, immediately before dispatching that step's
behavior. The counter is incremented before user code or external capability
code begins, so failures and interrupts still consume the attempt that caused
them.
If `steps_executed == max_steps`, the next attempted dispatch is denied. A
budget of one therefore admits exactly one step. The denied step does not
increment the counter and does not invoke a handler.
Repeated visits count independently:
- each trip through an ordinary graph cycle counts each selected step;
- each foreach-controller dispatch counts, including admissions and final
barrier completion;
- each foreach item body step counts in its item frame;
- starting and later completing a subgraph boundary are separate attempts;
- every step executed inside the child subgraph counts against the same run;
- an interrupt activation counts once; supplying its external resume payload
completes that admitted activation without consuming another step; and
- an explicit `EndNode` counts, while the legacy `END` token itself does not
because it is a transition target rather than an executable `Step`.
The counter is deliberately not `len(run.trace)`. Some attempts fail before a
normal trace entry is emitted, and scheduler/control-flow implementation may
record traces differently. Budget correctness must not depend on observability.
## Sync and Async Admission
Sync execution consumes one unit before each `step_workflow()` dispatch.
Async execution applies the same rule. The concurrent foreach fast path may
claim several item frames and invoke their node handlers together; it reserves
one step number per admitted frame in deterministic ready-queue order before
launching any handler. It may claim at most the remaining budget.
For example, with three units remaining and five otherwise eligible item
frames, the runtime admits the first three frames in ready-queue order. It does
not start the other two. After the admitted batch settles deterministically,
the next dispatch observes exhaustion and fails the run.
Reserved async attempts remain consumed even if one handler raises. This
matches the rule that admission, rather than successful completion, consumes
the budget and avoids making counts depend on task completion timing.
## Exhaustion Behavior
Exhaustion is a runtime failure, not a workflow outcome. The runtime raises a
specific `WorkflowStepLimitExceeded` derived from `WorkflowExecutionError` and
marks the run failed through the same raising-versus-result conventions used by
existing execution entry points.
The error reports at least:
```text
workflow name
configured max_steps
steps_executed
selected frame id
selected scope id
next node id
```
No edge may catch the failure as an `error` outcome. A node's declared error
outcome remains ordinary graph control flow and consumes a step like every
other completed attempt.
The failed run retains its frames, ready queue, state, lineages, trace, and
counter for inspection. The denied handler is never invoked. Whether a future
administrative rerun can raise the budget is outside ordinary resume semantics.
## Persistence and Resume
`RunLimits` and `steps_executed` are serialized inside the existing persisted
`RunState` checkpoint. An interrupted run resumes with its original maximum and
cumulative count.
The persisted run envelope may remain at version 1 because adding dataclass
fields with defaults is structurally additive. Loading an older checkpoint
that lacks these fields yields the default limit and a zero count. The
repository has no declared production migration requirement for reconstructing
historical counts that were never recorded; if real stored checkpoints exist,
their migration policy must be established before release.
Subgraph scopes do not receive independent counters. They are part of the same
run and consume the root run's budget. This prevents an outer workflow from
bypassing its protection by repeatedly entering children.
## Trace and Future Time Travel
Each normal `TraceEntry` emitted for an admitted step should carry its assigned
`step_number`. Step numbers are allocated in deterministic scheduler order;
async batch traces retain those numbers regardless of handler completion
order. The interrupt request persists its assigned number, and both the
initial `interrupt` trace entry and its later resume-completion entry use that
same number because they describe one admitted interrupt activation.
Trace step numbers support inspection and future checkpoint/trace navigation,
but gaps are valid when an attempt fails or interrupts before a normal trace
entry exists. `RunState.steps_executed` remains authoritative for enforcement
and resume.
A future time-machine system may use step numbers to correlate checkpoints,
trace events, frames, and state changes. This design does not require replaying
runtime state from trace alone and does not choose event-sourcing semantics.
## Relationship to Other Limits
The step budget protects against unbounded control flow. It does not replace:
- foreach `max_active`, which limits simultaneously active item work;
- foreach `max_outstanding`, which limits admitted and blocked item frames;
- a future global active-node-call limit, which bounds simultaneous expensive
handler calls;
- source-, account-, or provider-specific rate limits; or
- input-expression depth and node-count validation limits.
These limits measure different resources. A workflow can stay below every
concurrency limit while looping forever, and it can exhaust concurrency or
provider capacity long before reaching its step budget.
## Public Inspection
Core and client-facing run inspection should expose:
```python
run.limits.max_steps
run.steps_executed
run.steps_remaining
```
`steps_remaining` is a computed convenience value, not separately persisted:
```python
max(run.limits.max_steps - run.steps_executed, 0)
```
Run creation accepts an optional requested limit where the surrounding
platform authorizes it. Inspection returns the effective limit actually stored
with the run.
## Required Tests
### Core counting
- A budget of one admits exactly one step and denies the second.
- Node, condition, foreach, subgraph, interrupt, join, and explicit end steps
count.
- A transition to legacy `END` does not create an extra attempt.
- Handler failure still consumes its admitted step.
- A node `error` outcome remains normal control flow and consumes one step.
- An ordinary closed cycle fails at the configured limit.
- A data-dependent loop that exits within the budget completes normally.
### Frames and scopes
- Foreach controller and item-frame attempts share one counter.
- Re-entered foreach activations continue the same run budget.
- Child subgraph attempts consume the parent run budget.
- Interrupt resume completes the admitted interrupt activation without
consuming a second step.
- Execution after interrupt resume continues from the persisted cumulative
count.
- Budget exhaustion does not invoke the denied node handler.
### Async execution
- Concurrent foreach batching reserves one unit per item frame.
- A batch claims no more frames than the remaining budget.
- Step numbers follow ready-queue order rather than completion order.
- Reserved attempts remain counted when one async handler fails.
- Sync and async runs produce the same count for equivalent serial execution.
### Persistence and API
- Limits and counts round-trip through `dump_run_state()` and
`load_run_state()`.
- A stored interrupted run resumes without resetting or replacing its budget.
- Older additive checkpoints receive documented defaults.
- Run inspection exposes effective maximum, executed, and remaining counts.
- Trace entries expose deterministic step numbers without becoming the source
of enforcement truth.
## Non-Goals
- Static termination proofs.
- Treating budget exhaustion as a routable workflow outcome.
- Per-subgraph or per-foreach step budgets.
- Changing concurrency, rate-limit, or provider-admission policy.
- An ordinary-resume option that resets or extends a budget.
- Full time-machine, event-sourcing, or trace-replay semantics.
@@ -0,0 +1,362 @@
# Structured Runtime Context Design
## Status
Proposed for review on 2026-09-04. This document specifies structured
foreach context inside one runtime scope. It complements the foreach back-edge
design without expanding that implementation slice.
## Purpose
Let nested foreach bodies read every active same-scope iteration explicitly and
with useful schemas:
```python
customer = ctx.foreach["customers"].item
order = ctx.foreach["orders"].item
```
The matching serialized graph paths are ordinary `GraphSourcePath` values:
```text
context.foreach.customers.item
context.foreach.orders.item
```
This replaces the current innermost-only runtime representation as the
canonical model. Existing `loop_item`, `loop_index`, and configured aliases
remain convenience fields for the innermost active foreach while callers move
to structured paths.
## Existing Path Model
The existing path types describe two different namespaces and should remain
separate:
| Type | Purpose | Whole value |
| --- | --- | --- |
| `GraphSourcePath` | Read the current workflow scope | Named root |
| `StatePath` | Write a state field | Not supported |
| `LocalPath` | Address one boundary payload | `.` |
The named graph roots are `input`, `state`, and `context`. `LocalPath` applies
to temporary node, subgraph-boundary, and workflow-output payloads.
`GraphSourcePath` does not gain an `output` root. A node result is a local
payload whose selected fields are committed through `OutputBinding` into
state. A workflow output is another local payload projected at completion from
`input`, `state`, or `context`. A graph-level `output` root would be ambiguous
about the producing node, dynamic activation, foreach item, and lineage.
Path serialization continues to use TOML key syntax. Bare identifiers stay
compact, while identifiers containing punctuation are quoted as one literal
segment:
```text
context.foreach.orders.item
context.foreach."orders.v2".item
```
The structural representation is authoritative:
```python
GraphSourcePath(
root="context",
parts=("foreach", "orders.v2", "item"),
)
```
Code constructing paths from node identifiers must append literal tuple
segments rather than reparsing identifiers as dotted expressions.
## Scope Boundary
Structured foreach context is local to one `RuntimeScope`.
- Nested foreach activations in the same workflow scope are inherited.
- A subgraph starts a new runtime scope and does not inherit its caller's
`context.foreach` mapping.
- A caller passes required values through the subgraph's declared input
bindings.
- A child workflow reads those values from `input`, not from its caller's
context.
- Parent and child workflows may use the same foreach node identifier without
collision.
For example:
```python
orders = parent.foreach(
id="orders",
over=state_path("orders"),
as_="order",
)
process = parent.subgraph(
workflow=child_workflow,
input=[
{
"target": "order",
"path": orders.item,
}
],
)
```
Inside `child_workflow`, the value is `input.order`. This keeps a saved
subgraph reusable across call sites and preserves `RuntimeScope` as the state,
input, and context boundary for one workflow invocation.
## Context Shape
Python runtime context gains a typed entry for each active same-scope foreach:
```python
@dataclass(frozen=True, slots=True)
class ForeachContext:
node_id: str
activation_id: str
frame_id: str
scope_id: str
lineage_id: str
index: int
item: object
@dataclass(slots=True)
class RuntimeContext:
# Existing execution fields remain.
foreach: Mapping[str, ForeachContext] = field(default_factory=dict)
```
The mapping key is the static `ForeachNode.id` within the current workflow
scope. It is not the configured alias and does not contain a dynamic suffix.
The value discloses the dynamic foreach activation, item frame, scope, and
lineage identities when advanced runtime code needs them.
The mapping contains active entries in outermost-to-innermost insertion order.
Lookup semantics do not depend on that order; the order exists for inspection
and deterministic serialization only. A valid control region cannot contain
the same foreach node identifier twice, so lookup by static id is unambiguous
within one scope.
The graph-visible context object has the equivalent JSON-compatible shape:
```json
{
"foreach": {
"customers": {
"node_id": "customers",
"activation_id": "act_1234567890123",
"frame_id": "frame_1234567890123",
"scope_id": "root",
"lineage_id": "lineage_1234567890123",
"index": 0,
"item": {"name": "Ada"}
},
"orders": {
"node_id": "orders",
"activation_id": "act_2345678901234",
"frame_id": "frame_2345678901234",
"scope_id": "root",
"lineage_id": "lineage_2345678901234",
"index": 2,
"item": {"sku": "A-17"}
}
},
"loop_item": {"sku": "A-17"},
"loop_index": 2,
"customer": {"name": "Ada"},
"order": {"sku": "A-17"}
}
```
`loop_item` and `loop_index` refer to the innermost active foreach. Configured
aliases for every active same-scope foreach are also available when their names
are unique. Validation rejects collisions between simultaneously active aliases
instead of allowing an inner foreach to shadow an outer value.
## Runtime Derivation
Structured context is derived from persisted frame ancestry rather than copied
as one flattened object into every frame.
For the selected frame, the runtime walks `parent_frame_id` while ancestors
remain in the same `scope_id`. Each foreach item frame contributes one typed
entry from its validated metadata. The collected entries are reversed into
outermost-to-innermost order and materialized into both `RuntimeContext.foreach`
and the JSON-compatible context mapping used by input bindings.
Traversal stops at a runtime-scope boundary even though a subgraph root frame
has a scheduling parent in the caller. Frame ancestry describes scheduling
ownership; it does not grant cross-scope context visibility.
The required persisted item metadata is:
```text
foreach node id
foreach activation id
item index
item value
configured alias
scope id
lineage id
```
`scope_id` and `lineage_id` may remain first-class `ExecutionFrame` fields
rather than being duplicated inside metadata. The structured context builder
must read typed metadata helpers and fail on malformed foreach item metadata;
corrupt persisted state is not equivalent to a missing context value.
## Static Context Analysis
Context analysis uses the full static foreach-owner stack established by the
foreach back-edge design:
```python
control_region(work_node) == ("customers", "orders")
```
At `work_node`, the generated context schema contains both entries. Each
`.item` schema is the item schema inferred from its owning foreach `over`
collection. `.index` is an integer, and identity fields are strings.
Validation rejects a structured foreach context path when:
- the referenced foreach id does not exist in the current workflow;
- the referenced foreach is not active in the consuming node's control region;
- the path asks for an unknown `ForeachContext` field;
- active configured aliases collide with one another or with reserved context
fields; or
- a child workflow tries to address a caller's foreach context instead of
receiving a declared input.
The analyzer must not represent context with a single active foreach id. It
assigns one owner stack per reachable node and derives all active entries from
that stack. Unreachable nodes remain validation errors rather than receiving a
fabricated root context.
## Authoring Surface
The `ForeachNode` returned by `WorkflowBuilder.foreach()` already acts as the
step reference. It gains non-serialized computed path properties instead of a
second wrapper hierarchy:
```python
orders = graph.foreach(
id="orders",
over=state_path("orders"),
as_="order",
)
orders.item
# GraphSourcePath("context", ("foreach", "orders", "item"))
orders.index
# GraphSourcePath("context", ("foreach", "orders", "index"))
```
These values work anywhere an existing `GraphSourcePath` works:
```python
charge = graph.use(
charge_order,
input=[{"target": "order", "path": orders.item}],
)
graph.set_route(orders, "loop", charge)
graph.set_route(charge, "ok", orders)
```
The compiled binding remains ordinary protocol data:
```json
{
"target": "order",
"path": "context.foreach.orders.item"
}
```
No serialized `ForeachRef`, node-address type, or dynamic activation path is
introduced. Field-selection sugar beneath `.item` can be considered with the
broader ergonomic Python DSL; it is not required for structured context.
## Trace, Checkpoints, and Inspection
The resumable source of truth remains `RunState`: frames, scopes, lineages,
ready queue, barriers, interrupt route, and foreach activation metadata. The
runtime recreates structured context from that state after loading a
checkpoint.
Trace remains chronological history. It may expose the same stable `frame_id`,
`scope_id`, `lineage_id`, and foreach activation identities for inspection, but
runtime context must not be reconstructed from trace entries alone. The current
trace does not contain every live scheduler or barrier invariant required to
resume safely.
A future time-machine design may combine checkpoints with trace or richer
events. This design preserves stable identities for that work without choosing
event sourcing, checkpoint navigation, or rerun-from-step semantics now.
## Compatibility and Migration
The structured `foreach` field is additive. Existing `GraphSourcePath`,
`StatePath`, and `LocalPath` serialized forms remain unchanged.
The current convenience fields remain during this migration:
- `context.loop_item`;
- `context.loop_index`; and
- each active foreach's configured alias when unambiguous.
They are derived from the same structured entries so Python context, input
binding resolution, schema analysis, and trace inspection cannot disagree.
Future removal requires evidence that public callers and stored workflow
artifacts no longer use them; this design does not create an indefinite
compatibility promise.
## Required Tests
### Path and authoring
- `ForeachNode.item` and `.index` produce structural literal path segments.
- A foreach id containing a dot serializes as a quoted single segment.
- The computed properties do not appear as persisted `ForeachNode` fields.
- Foreach refs work in node and subgraph input bindings.
- `GraphSourcePath` still rejects an `output` root.
### Runtime context
- A single foreach exposes one structured entry.
- A nested same-scope body exposes outer and inner entries simultaneously.
- `loop_item` and `loop_index` select the innermost entry.
- Unique outer and inner aliases remain available together.
- Inner completion restores the outer entry and removes the inner entry.
- Concurrent item frames receive distinct activation/frame/lineage values.
- Interrupt resume recreates the same structured entries from persisted state.
- Malformed persisted foreach metadata fails closed.
### Scope isolation
- A child subgraph does not inherit the caller's `context.foreach` mapping.
- A parent can map a foreach item into child workflow input.
- Parent and child foreach nodes may reuse the same static id.
- Context ancestry traversal stops at the child runtime scope.
### Validation and schemas
- The item schema under each structured entry matches the foreach collection's
item schema.
- Referencing an inactive, missing, or cross-scope foreach is rejected.
- Nested active alias collisions are rejected.
- The analyzer handles legal nested cycles without losing the owner stack.
- Unreachable nodes do not receive structured context schemas.
## Non-Goals
- Direct node-output dataflow or an `output` graph root.
- Cross-subgraph implicit context inheritance.
- A universal static node-address or dynamic execution-path type.
- Time-machine behavior or event-sourced resume.
- Fork/gather context, scheduling context, or run step limits.
- The broader doubly ergonomic Python DSL.