/** * Graph Execution Engine v2 — Signal Propagation * * Version: 2.0 * Date: 2026-07-24 * * The two propagation half of the engine's advancement algorithm (steps 4a * and 4b in `engine-advance.ts`), complementing the forward data flow that * runs on `answer`. Whereas `answer` flows *down* along edges to activate * downstream joins, failure signals travel the other two lanes: * * - {@link propagateRevise} — a convergence node emits `revise_needed`; the * revision feedback is routed *back* along the loop group's * `on_signal(revise_needed)` back-edges so the offending upstream nodes * re-enter `ready`, bounded by the loop group's `max_traversals` counter. * When the cap is reached (or there is no loop group), the revision * *escalates* instead of looping. * * - {@link propagateEscalate} — a node signals `escalate`; the worst signal * (escalate > revise_needed > answer, graph-model.md §5.1) travels *forward* * along outbound edges to the nearest fan-in convergence node. The node's * effective escalate-retry budget is resolved first — the max of its * `budget.max_retries` and the `retry.max` of any OUTBOUND or INCOMING edge * (graph-model.md §5.3): while `retryCount` is below the budget, the node is * re-marked `ready` for an automatic retry; otherwise the escalation lands * on the convergence node, whose join re-evaluates and fails → that node * escalates too, and the walk continues. Single-input (non-convergence) * nodes are transparent pass-throughs. * * `on_signal(revise_needed)` back-edges are loop-routing edges, not forward * flow: the walk never traverses them (Y14), so an escalation cannot travel * backward into the loop group it came from and cascade-cancel its peers. * * Both functions are **pure state-mutation** steps. They mutate node lifecycle * status and the frontier only — they never dispatch. Dispatch of the * re-marked-ready nodes is done by the caller's existing `_dispatchReadyNodes` * step (which also applies the budget pre-check before each dispatch). This * keeps the propagation logic free of I/O and easy to test in isolation. * * Design references: * - `.rolebox/design/graph-model.md` §4 (bounded-cycle loops), §5.1 (signal * escalation lattice), §5.3 (retry as an edge property) * - `.rolebox/design/failure-resilience.md` §1.5 (join failure), §1.6 (loop * traversal exhaustion), §2 (propagation rules), §4 (bounded-cycle exit) * - `.rolebox/design/orchestration-patterns.md` §1.6 (bounded-cycle loop * groups, revise-driven re-dispatch) */ import type { EngineState, NodeRuntimeState } from "../../types.engine-v2.ts"; /** * What a propagation step did, for diagnostics and tests. * * The fields are intentionally separate so a single call can be inspected * precisely: which upstream nodes were re-marked `ready` (revise), which nodes * were retired terminally (stuck / exhaustion / join failure), which node was * re-marked `ready` for an automatic retry (escalate), and where the * escalation was absorbed. * * Note on the removed `rootReached` field (former review finding F3/L8): the * interface used to promise "escalation reached the graph root (graph will * terminate with error)", but the promise was never fulfilled. Escalation * propagation is **forward-only** — {@link propagateEscalationForward} walks * outbound edges from the escalating node toward graph *sinks* (nodes with no * outbound edges), and a "graph root" in this codebase is a node with no * *incoming* edges (`engine-state.ts:getRootNodeIds`, `join-evaluator.ts`). * Forward propagation can therefore never reach a graph root: it starts * downstream of the roots and moves further downstream. The only truthful * sink-side observable — "the escalation reached the end of the line and the * graph will terminate with error" — is already reported by {@link escalated} * (the join-failed sink node lands there) plus an empty {@link absorbed}; * re-labeling that event "rootReached" would misname sinks as roots. The field * had zero consumers, so it was removed rather than repurposed. */ export interface SignalPropagationReport { /** Which propagation lane ran: `revise` or `escalate`. */ kind: "revise" | "escalate"; /** (revise) Upstream nodes re-marked `ready` and added to the frontier. */ revisedUpstream: string[]; /** * Nodes this propagation retired terminally: an `escalate` transition for a * join failure / no-loop-group revision, or `done` for the `stuck` / * `max_traversals exhausted` exits (Y17 — the list does not imply the node's * status is `escalate`; read the actual status when that distinction * matters). */ escalated: string[]; /** The escalating node that was re-marked `ready` for an automatic retry. */ retried: string[]; /** Escalation absorbed — the convergence node's join is still satisfiable. */ absorbed: string[]; /** Machine-readable reason for an escalation (e.g. "max_traversals exhausted"). */ reason?: string; } /** * Back-propagate a `revise_needed` signal from a convergence node into its * loop group. * * 1. No loop group → the revision has nowhere to re-enter; the node escalates * with reason `no loop group`. * 2. Stuck detection — identical consecutive convergence outputs for * `>= CONSECUTIVE_STALE_THRESHOLD` traversals → `completed → done` with * reason `"stuck"`; no traversal consumed. * 3. Loop group present but `incrementLoopTraversal` is rejected * (`traversalCount >= max_traversals`) → the `revise_needed` back-edge is * deactivated (graph-model.md §4.2); the node escalates with reason * `max_traversals exhausted`. * 4. Otherwise the traversal counter is incremented and every upstream target * reachable via an `on_signal(revise_needed)` back-edge within the loop * group that may legitimately re-open (`completed` / `pending` / `blocked` * — see {@link REVISE_REENTRY_STATUSES}) re-enters `ready` (added to the * frontier) with the revision feedback merged into its re-execution prompt. * The caller's `_dispatchReadyNodes` step re-dispatches them (completed → * ready is the loop re-entry edge, node-lifecycle.ts). * * The escalating node is expected to already be `completed` (the reviewing * pass finished); exhausting the cap flips it to `done` * (`completed → done`). */ export declare function propagateRevise(state: EngineState, node: NodeRuntimeState, payload: unknown): SignalPropagationReport; /** * Propagate the worst signal (`escalate`) from an escalating node forward to * the nearest fan-in convergence node(s), per the signal escalation lattice * (graph-model.md §5.1) and retry policy (§5.3). * * 1. **Retry gate:** resolve the node's effective escalate-retry budget — the * max of its declared `budget.max_retries`, the `retry.max` of any OUTBOUND * edge, and the `retry.max` of any INCOMING edge (graph-model.md §5.3). If * the budget allows another attempt (`retryCount < max`), increment * `retryCount` and re-mark the node `ready` (escalate → ready) so it * re-runs — no upward propagation this round. The absorbed escalate's * dual-write recording (`node.signalsObserved["escalate"]` + the graph * `signalLedger` entry) is cleared at absorption so a deferred drain * (`AdvanceEngine._drainDeferred` → `_latestTerminating`) cannot replay it * against the re-dispatched (Running) node and re-escalate the retry. When * the qualifying retry edge declares `backoff_ms`, the retry is withheld * until `now + backoff_ms` (`retryBackoffUntil`); backoff/budget for the * retry dispatch is otherwise handled by the caller's dispatch step * (graph-model.md §6.3). * 2. **Forward propagation:** otherwise walk the node's outbound edges. * Single-input (non-convergence) nodes are transparent pass-throughs. * At the first multi-input fan-in node, record the `escalate` as an upstream * result and re-evaluate its join: * - join `failed` → that convergence node escalates too; the walk * continues from it. * - join `satisfied` / `waiting` → partial failure absorbed (`any` / * `quorum` can still proceed); the walk stops on that branch. * If the escalation reaches a node with no outbound edge (a graph sink), * it has reached the end of the line — nothing further to abort. * * A still-satisfiable downstream join (absorption) is what keeps a partial * failure from misjudging the graph `complete`: the escalating node and any * join-failed convergence node are terminal, but a pending/running branch that * legitimately continues keeps the engine in `executing` (engine-advance's * `_checkTermination` guard). */ export declare function propagateEscalate(state: EngineState, node: NodeRuntimeState, payload: unknown): SignalPropagationReport; //# sourceMappingURL=signal-propagation.d.ts.map