From 3d9d855b8e42de9301ba767de10e21f4ff6fb157 Mon Sep 17 00:00:00 2001 From: lda Date: Fri, 4 Sep 2026 07:01:45 +0700 Subject: [PATCH] docs: design runtime context and step budgets --- docs/README.md | 5 + docs/current_roadmap.md | 7 + .../2026-09-04-run-step-budget-design.md | 280 ++++++++++++++ ...09-04-structured-runtime-context-design.md | 362 ++++++++++++++++++ 4 files changed, 654 insertions(+) create mode 100644 docs/superpowers/specs/2026-09-04-run-step-budget-design.md create mode 100644 docs/superpowers/specs/2026-09-04-structured-runtime-context-design.md diff --git a/docs/README.md b/docs/README.md index b636aa80..ff5bacf5 100644 --- a/docs/README.md +++ b/docs/README.md @@ -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): diff --git a/docs/current_roadmap.md b/docs/current_roadmap.md index 27578115..000026d1 100644 --- a/docs/current_roadmap.md +++ b/docs/current_roadmap.md @@ -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). diff --git a/docs/superpowers/specs/2026-09-04-run-step-budget-design.md b/docs/superpowers/specs/2026-09-04-run-step-budget-design.md new file mode 100644 index 00000000..e0d27a2a --- /dev/null +++ b/docs/superpowers/specs/2026-09-04-run-step-budget-design.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. diff --git a/docs/superpowers/specs/2026-09-04-structured-runtime-context-design.md b/docs/superpowers/specs/2026-09-04-structured-runtime-context-design.md new file mode 100644 index 00000000..f8a50b0b --- /dev/null +++ b/docs/superpowers/specs/2026-09-04-structured-runtime-context-design.md @@ -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.