ADR-executor-003
Multi-scope execution — per-scope state, settle isolation, portOut latch propagation
Scope split. This is the executor-side half of the multi-scope iteration change. The dataflow / authoring side (multiple three-pivot triples in one graph; portIn/portOut as the structural IO interface for inner scopes; topology-determined nesting) is in ADR-dataflow-007. Both landed together.
Context
ADR-executor-002 settled the three-pivot scheduling for a single iteration scope: one step_counter, one _termination_ids set, one terminated flag, one settle loop drained on every iter_out cycle. Hierarchical agents (VoxPoser-class) need two coexisting iteration cadences — an outer reasoning loop wrapping an inner execution loop — and the existing executor cannot represent that: an inner-scope termination would halt the run; the settle loop would drain across both scopes; step-budget exhaustion at any scope would terminate the outermost. The dataflow side (ADR-dataflow-007) reshapes the graph to allow N (Initialize, iterIn, iterOut) triples and adds analyze_scopes() to compute the scope forest. The executor must match: per-scope counters, per-scope termination dispatch, per-scope step-budget exhaust paths, per-scope settle drain, and a new latch-and-propagate semantics for portOut nodes inside non-graph scopes (so outer sees the FINAL inner result rather than every inner iter's value).
Decision
(1) _ScopeState per scope: dict[scope_id, _ScopeState] keyed by iter_in id (the scope's canonical key) plus one synthetic GRAPH_SCOPE_ID = "" entry for graph-scope nodes (run-start seeds, post-loop sinks, graph-scope terminations). Each entry carries step_counter, step_budget (resolved with fallback chain: scope's own iter_in.config.step_budget → parent scope → graph), terminated, termination_node_ids, member_node_ids, portout_node_ids, iter_out_id. Seeded once at run-start from analyze_scopes(graph). (2) iter_out cycling is scope-aware: when an iter_out fires, look up its scope (config.pairedWith == iter_in_id), advance THAT scope's counter, pull-check THAT scope's terminations only. Inner termination ends the inner scope (set _io_scope.terminated = True) and propagates inner portOut latches; outer scope continues. Root-scope (= graph-scope or outermost author scope) termination sets the global terminated flag and breaks the main loop. (3) portOut latch + propagation. portOut nodes inside a non-graph scope SKIP standard edge propagation and instead BUFFER the incoming value into state["latched_value"]. When the owning scope terminates, _propagate_portout_latches(scope, ready_queue) walks every portOut in that scope, routes its latched value through outgoing edges, and queues newly-ready downstream targets. portOuts in graph-scope (e.g. eval_graph metric harvest) keep the legacy propagate-on-fire behaviour — the change is gated by _scope_of(node_id) != GRAPH_SCOPE_ID. (4) Termination latch via state["latched_done"]. The pull-check at the iter-boundary reads _term.pending_inputs.get("done") OR _term.state.get("latched_done") so a termination that fired earlier in the same iter (and had its pending_inputs cleared) is still observed at the canonical halt-decision point. The _fire_node termination path latches state["latched_done"] = True and only raises StopExecution if the termination's scope == graph-scope (root); inner-scope terminations let the iter-boundary pull-check do the scope-bounded halt. The latch is cleared on scope re-entry. (5) Scope re-entry reset on inner iter_in: when the inner scope's iter_in fires and that scope's _ScopeState.terminated is True (i.e. we're re-entering from outer), reset terminated = False and step_counter = 0. This makes inner scopes function-call-shaped: each outer iter gets a fresh inner loop. (6) Settle-loop scope isolation: after iter_out fires, the settle drain processes only nodes whose innermost scope == the just-cycled iter_out's scope; peer-scope and outer-scope nodes are left for their own iter_out cycle. (7) Per-scope step_budget exhaust: each scope's step_counter >= step_budget check halts THAT scope only. If the scope is non-root, propagate inner portOut latches and continue the main loop (outer keeps running). If root, set the global terminated flag and break. (8) Backward compat fast-path: forest.is_single_scope == True (len(forest.scopes) <= 1) means the executor takes paths that mirror the pre-refactor behaviour — one _ScopeState (the outermost or the synthetic graph scope), the legacy _termination_ids list as a fallback, no per-scope dispatch on the hot path.
Alternatives
(a) Per-scope sub-executors — spawn a child GraphExecutor for each inner scope, hand it the inner subgraph, run to completion, harvest portOut as the return value — rejected; reintroduces the executor-instance nesting that ADR-dataflow-007 explicitly avoids, prevents shared state-container access across scopes, and forks the lifecycle/signal model. (b) Streaming portOut (no latch) — propagate portOut on every inner-iter fire — rejected on the dataflow side; outer would see N values per outer iter, breaking function-return semantics. (c) Author-supplied scope_id on every node to bypass _scope_of(node_id) lookups — rejected; analyze_scopes() runs once at run-start, the lookup is dict.get, perf is not a concern. (d) Reset inner ctx state on scope re-entry (so inner-body node-instance ctx clears between outer iters) — deferred; today inner ctx persists across outer iters. Inner-body authors that need per-scope ctx detect their own re-entry (e.g. dispense_waypoint resets cursor when input trajectory identity changes). A future lifetime="scope" for state containers would generalise this.
Rationale
The change is one new branch per cycling step (look up scope, dispatch on scope id) plus one new propagation mode (latch-and-flush on scope termination); no new firing modes, no new state-machine, no executor-instance nesting. The single-scope fast-path makes the existing 17 graphs byte-identical to pre-refactor — covered by 12 scope-analysis tests + 7 executor-multi-scope tests + a clean validation pass on every graph. The latch + scope-bounded pull-check pattern resolves a concrete ordering hazard: inner termination fires mid-iter, the iter_out boundary pull is the canonical halt point — without the latch, pending_inputs clears between fire and pull and the halt is missed. The scope re-entry reset lets the same inner scope structure run multiple times per run (essential for the outer-reasoning + inner-execution shape) without authors having to wire reset signals manually. portOut as latched return value is the smallest possible semantic upgrade: outer scopes see the final inner result the way a function call sees its return value, so the inner scope is a complete, reusable sub-function — exactly the structural promise the dataflow side makes.
Affected docs
design-docs/graph-executor.md (new "Multi-scope execution" section), design-docs/loop-control-system.md (cross-link to multi-scope), core/glossary.md (new terms: scope state, latched value, scope re-entry), core/codebase-map.md (new types: _ScopeState; new method: _propagate_portout_latches)