/** * Graph Execution Engine v2 — Core Signal-Driven Advancement Algorithm * * Version: 2.0 * Date: 2026-07-24 * * The heart of the engine. Advances the graph in response to terminating * signals emitted by dispatched worker nodes. There is no polling loop — the * engine reacts to a signal on a node and walks: transition the node's * lifecycle → evaluate outbound edges → check downstream fan-in joins → * enqueue satisfied downstream nodes → dispatch ready nodes. * * Re-entrancy guard: the whole advancement runs inside the `_advancing` * critical section (the `advancingLock` on `EngineState`), modeled exactly on * `src/loop/coordinator.ts:397-404` (defer under lock) and * `src/loop/coordinator.ts:450-462` (drain in the `finally` block). Only one * advancement critical section runs at a time for a graph instance; signals * that arrive while it is held are queued to `pendingCompletions` and drained * (re-processed under a fresh critical section) once the current one exits. * * Phase 1 scope: * - `onNodeSignalEmitted` self-contained entry: records the signal via the * `SignalBridge`, then advances under the lock. * - Node lifecycle transitions for `answer` (→ completed) and `escalate` * (→ escalated). `revise_needed` completes the reviewing node's own * lifecycle; the back-edge re-activation is implemented in Phase 2. * - Forward edge evaluation on `answer`: `always` and `on_signal` (signal * filter) are evaluated; `on_condition` delegates to an injected resolver. * - `escalate` / `revise_needed` propagation are delegated to * `signal-propagation.ts` (Phase 2). * * Design reference: `.rolebox/design/engine-state-machine.md` §3.3. */ import { NodeStatus } from "../../constants.ts"; import type { EngineState, NodeRuntimeState, NodeLivenessState } from "../../types.engine-v2.ts"; import type { DispatchTask } from "../../dispatch/types.ts"; import type { BudgetCheckResult, UsageRecord } from "../../dispatch/budget/budget-tracker.ts"; import { type DispatchParentContext, type TaskTerminatedCallback } from "./dispatch-bridge.ts"; import { type GraphTerminalEvent } from "./engine-termination.ts"; import { type ApproveReport, type PruneReport, type RejectReport } from "./approval-handler.ts"; import { type RetryNodeOptions, type RetryReport } from "./node-retry.ts"; import type { SignalType, SignalBridge } from "./signal-bridge.ts"; import type { SignalLedgerSource } from "../../types.engine-v2.ts"; import type { GraphEventRecorder } from "./graph-events.ts"; /** * The dispatch surface the engine touches. Structurally satisfied by * {@link DispatchBridge} (`src/graph/engine/dispatch-bridge.ts`). Declared as * a minimal interface so tests can inject a fake and avoid real sub-agent * dispatch. */ export interface NodeDispatchPort { /** Execute a graph node by dispatching to its bound agent (background). */ executeNode(node: NodeRuntimeState, parentContext: DispatchParentContext, description?: string): Promise; /** * Cancel a dispatched graph node's background task. Optional — omitted by * test fakes and any port without a cancellation surface; structurally * satisfied by {@link DispatchBridge}. Consumed by the cascade canceller * (`cascade-canceller.ts`) to stop now-unneeded upstream nodes once a fan-in * join resolves (see failure-resilience.md §3.3). Never awaited — the engine * proceeds without a cancellation acknowledgement. */ cancelTask?(taskId: string): Promise; /** * Look up a dispatched task's current status. Optional — recovery and the * status→signal mapping use it to read `task.error`. Structurally satisfied * by {@link DispatchBridge}; omitted by test fakes. */ getTask?(taskId: string): DispatchTask | undefined; /** * Register a one-time listener for a task's terminal transition. Optional — * a port without it simply cannot push dispatch completions into the engine; * the engine degrades to signal-driven advancement only. Structurally * satisfied by {@link DispatchBridge}. Wired by {@link _dispatchNode} and by * recovery to close the dispatch→signal delivery seam. */ onTaskTerminated?(taskId: string, callback: TaskTerminatedCallback): void; /** * Remove a previously-registered task-terminated listener. Optional — a port * without it simply cannot clean up its subscriptions (leak-free teardown is * then the caller's concern). Structurally satisfied by {@link DispatchBridge} * (which delegates to `DispatchManager.removeTaskTerminatedListener`). * Consumed by the engine's subscription accessor * ({@link AdvanceEngine.getTerminationSubscriptions}) so a teardown path * (monitor M4 / S7 dispose) can unregister every listener this engine wired. */ removeTaskTerminatedListener?(taskId: string, callback: TaskTerminatedCallback): void; /** * Cumulative token/cost usage for a single dispatched session (keyed by the * dispatch session ID). OPTIONAL-ADDITIVE — a port without it simply cannot * report per-node usage, so the engine degrades to the pre-Phase-7 behavior * (zero consumption). Structurally satisfied by {@link DispatchBridge} * (`dispatch-bridge.ts:getSessionUsage`); consumed by * `engine-recovery.ts:captureNodeUsage` to populate `node.tokensConsumed` at * task termination. */ getSessionUsage?(sessionId: string): UsageRecord; } /** * The node-liveness surface the engine touches (subtask 2 of the * node-anomaly-detection feature). Structurally satisfied by the platform's * liveness feed — the layer that observes live dispatch sessions (tool calls, * messages, session errors) and reports activity back into the engine. * Declared as a minimal interface so tests can inject a fake and avoid any * real platform/session wiring. * * Direction: `attach` / `detach` are engine→feed — the engine registers a * node's live dispatch session when it is successfully launched and * unregisters it when the node reaches a terminal state, so the feed knows * which sessions to observe. The optional `onHeartbeat` / `onSessionError` / * `onSessionGone` members are feed→engine push hooks the platform may use to * relay session-level observations; the engine's stall monitor (subtask 3) * consumes them through {@link AdvanceEngine.recordLivenessHeartbeat}. * * Every member is `void`-returning — a throwing or absent feed must never * break advancement (the engine calls through optional chaining and contains * nothing here). All members are OPTIONAL-ADDITIVE: an engine without a feed * behaves exactly as before (no liveness recording, no index maintenance). */ export interface NodeLivenessFeed { /** * Register a node's live dispatch session with the feed. Called by the * engine immediately after a successful launch (`_dispatchNode`), when the * node's `dispatchTaskId` / `dispatchSessionId` are known. The engine ALSO * records the `sessionId → nodeId` mapping in its own reverse index at this * point, so the platform feed can look up the owning node for a session id * (see {@link AdvanceEngine.getNodeIdForSession}). */ attach(nodeId: string, sessionId: string): void; /** * Unregister a node's session. Called by the engine when a running node * reaches a terminal state (signal-driven completion / escalation) — the * feed stops observing the session, and the engine drops the node's * `sessionId → nodeId` index entry. A no-op for a node that was never * attached. */ detach(nodeId: string): void; /** * Push a session-level activity heartbeat for a node. Optional — a feed * without it simply cannot relay platform activity into the engine; the * engine's own dispatch-time heartbeat and `recordLivenessHeartbeat` * callers remain the alternative observation channels. */ onHeartbeat?(nodeId: string, source: NodeLivenessState["heartbeatSource"]): void; /** * Push a session-level error observation for a node. Optional — the stall * monitor (subtask 3) may surface the node's status accordingly. */ onSessionError?(nodeId: string, reason: string): void; /** * Push a session-gone observation — the platform can no longer see the * node's session at all. Optional — recovery / the stall monitor (subtask * 3) owns the authoritative timeout handling. */ onSessionGone?(nodeId: string): void; } /** * The budget-query surface the engine touches. Structurally satisfied by * {@link BudgetBridge} (`src/graph/engine/budget-bridge.ts`). Optional — * omitted in tests, and an engine without a port never enforces ceilings. * * Both checks are invoked as pre-dispatch pre-checks in `_dispatchNode`: the * graph-level check first, then the per-node check. The real bridge compares * the graph declaration's `max_total_*` ceilings against the cumulative * `EngineState.budget` counters, and each node's declared per-node ceilings * against its `tokensConsumed`. */ export interface GraphBudgetPort { checkGraphBudget(graphId: string, state: EngineState): BudgetCheckResult; /** * Per-node budget check — invoked pre-dispatch alongside * {@link checkGraphBudget}. The real {@link BudgetBridge} implementation * compares the node's declared per-node ceilings (`node.budget.*`) against * its cumulative `tokensConsumed`; a breach returns `exceeded: true`, and * the engine escalates the ready node without dispatching it. */ checkNodeBudget(node: NodeRuntimeState): BudgetCheckResult; } /** Evaluates a named `on_condition` edge condition. Phase 2 vocabulary. */ export type EdgeConditionResolver = (condition: string, source: NodeRuntimeState) => boolean; /** * A node-completion event emitted via the optional {@link AdvanceEngineOptions * .onNodeCompletion} callback seam (subtask 1). The engine stays role-agnostic * — it packages only the immutable facts; notification / delivery (a notifier) * is the consumer's concern and never lives in the engine. */ export type NodeCompletionSignalType = SignalType | "timeout" | "cancelled"; export interface NodeCompletionEvent { /** Owning graph id. */ graphId: string; /** The node that reached a terminal / notable status. */ nodeId: string; /** The node's bound agent id. */ nodeAgent: string; /** * The signal that drove the transition — one of the terminating * `SignalType`s (`answer` / `revise_needed` / `escalate`) for a * signal-driven transition, or a synthetic marker for a lifecycle * transition no worker signal drives: `timeout` (recovery-side * timing-out node) or `cancelled` (cancellation / budget-blocked * retirement, monitor H4). Narrowed from `string` (Y5) so a consumer * branching on the signal type cannot silently miss a new vocabulary * member. */ signalType: NodeCompletionSignalType; /** The signal payload that drove the transition (may be undefined). */ payload: unknown; /** The node's terminal / notable lifecycle status at emission time. */ nodeStatus: NodeStatus; /** * Epoch-ms timestamp when the node started (additive, may be absent for * synthetic events). Lets a notifier report a real duration. */ startedAt?: number; /** Epoch-ms timestamp when the node completed (additive, may be absent). */ completedAt?: number; } export interface AdvanceEngineOptions { /** The engine state container this engine advances. */ state: EngineState; /** Signal bridge — used to record signals and to subscribe as a listener. */ signalBridge: SignalBridge; /** Dispatch seam — `executeNode` dispatches ready nodes to their agents. */ dispatch: NodeDispatchPort; /** Optional budget seam — pre-dispatch graph- and per-node budget checks. */ budget?: GraphBudgetPort; /** Parent context for node dispatches (defaults to a graph-scoped one). */ parentContext?: DispatchParentContext; /** * Optional `on_condition` edge evaluator. When absent, `on_condition` * edges never activate. */ conditionResolver?: EdgeConditionResolver; /** * Optional engine-state persistence seam. When provided, the advancement * critical section performs a **write-through** save of the engine state in * its `finally` block — after every critical transition (node lifecycle, * graph phase, frontier) — so the state survives a crash. Per * `.rolebox/design/implementation-roadmap.md` Q2 Option A: critical * transitions write through immediately; noisy (non-critical) updates are * debounced elsewhere. When absent, the engine runs without persistence * (in-memory only), preserving the role-agnostic primitive's constructibility. * * Returns `true` when the state reached durable storage, `false` on a failed * write (never throws) — the engine only clears its dirty flag on success * (monitor M5), so a failed save is retried by the next mutating critical * section instead of silently dropped. A `void` return (or an absent seam) * is treated as success — backward-compatible with no-op test seams. */ persistState?: (state: EngineState) => boolean | void; /** * Optional debounced-persistence seam (Q2 Option A, non-critical tier). When * a critical section produced ONLY non-critical mutations (signal-ledger * history, budget / tokensConsumed counters), the `finally` block routes the * write through this debounced seam instead of the synchronous * {@link persistState}. Absent → non-critical-only sections skip persistence * (the in-memory engine still runs). * * Returns `false` on a definite write failure (the debounced store retains * the pending state and retries it); the engine keeps its non-critical dirty * flag set in that case so a later section re-hands the churn to the seam * (monitor M5, non-critical tier). A `void` return is treated as success — * `EnginePersistence.scheduleSave` is the legacy void-returning seam, whose * failure retry is owned internally by the debounced store's pending * retention. */ schedulePersistState?: (state: EngineState) => boolean | void; /** * Optional flush seam for the debounced tier. Invoked when the engine reaches * a terminal phase (`complete`) so no pending debounced write is lost. Absent * → no-op (in-memory engine). */ flushPersistState?: () => void; /** * Optional node-completion notification seam (subtask 1). Invoked exactly * once per terminating / notable transition — `answer → completed`, * `revise_needed → completed` (reviewer finished), `escalate`, * `blocked → completed` (approval-resume), and the recovery-side `timeout`. * Defaults to a no-op, so the engine's existing behavior is unchanged. * Notification logic never lives in the engine — this is a pure * role-agnostic DI seam, exactly like {@link AdvanceEngineOptions.dispatch} / * {@link AdvanceEngineOptions.budget} / {@link AdvanceEngineOptions.persistState}. */ onNodeCompletion?: (event: NodeCompletionEvent) => void | Promise; /** * Optional write-side durable event log (graph monitoring). When present, the * engine records node dispatch (`node_dispatched`) and node terminal * transitions (`node_completed`) into the recorder, alongside the * `onNodeCompletion` notifier. The recorder is total (never throws), so this * seam cannot break advancement. Absent → no event logging, engine behavior * unchanged. */ graphEvents?: GraphEventRecorder; /** * Optional node-liveness feed seam (subtask 2 of node-anomaly-detection). * When present, the engine records an initial `dispatch` heartbeat on every * successfully launched node, maintains a `sessionId → nodeId` reverse * index of running nodes, and registers / unregisters each node's session * with the feed (`attach` on launch, `detach` on terminal transition). * Absent → the engine behaves exactly as before: no liveness recording, no * index, no feed calls. A pure role-agnostic DI seam, exactly like * {@link AdvanceEngineOptions.dispatch} / {@link AdvanceEngineOptions.budget}. */ livenessFeed?: NodeLivenessFeed; /** * Optional graph-terminal notification seam. Invoked exactly once per * terminal transition (GRAPH COMPLETE / GRAPH BLOCKED). When absent, the * engine behaves identically — this is a pure DI seam like * {@link AdvanceEngineOptions.onNodeCompletion}. The seam may answer a * promise (C5): the engine contains BOTH a synchronous throw and the * returned promise's rejection, so a failing notifier can never break * advancement. */ onGraphTerminal?: (event: GraphTerminalEvent) => void | Promise; } export type { GraphTerminalEvent } from "./engine-termination.ts"; export type { TerminationContext } from "./engine-termination.ts"; /** * Core signal-driven advancement engine for a single graph instance. * * One instance owns one {@link EngineState}. Public surface: * * - {@link AdvanceEngine.onNodeSignalEmitted} — the entry point. Records the * signal and, if it is terminating, advances the graph under the lock. * - {@link AdvanceEngine.dispatchReady} — kickoff: dispatch any already-ready * frontier nodes (roots after `provision`) under the lock. * - {@link AdvanceEngine.register} — subscribe to the {@link SignalBridge} so * real signal emissions (recorded upstream) also advance the graph. * * Re-entrancy: every advancement path funnels through `_advanceSignal`, which * acquires `advancingLock`, runs the work, and — in `finally` — releases the * lock and drains deferred completions. The lock makes state mutation * single-threaded per graph; the drain makes deferred work eventually run. */ export declare class AdvanceEngine { private readonly state; private readonly signalBridge; private readonly dispatchPort; private readonly budgetPort?; private readonly parentContext; private readonly conditionResolver?; private readonly persistState?; private readonly schedulePersistState?; private readonly flushPersistState?; private readonly onNodeCompletion?; private readonly graphEvents?; private readonly livenessFeed?; private readonly onGraphTerminal?; /** * Reverse index of live dispatch sessions: `dispatchSessionId → nodeId`, * maintained for RUNNING nodes only, so the platform liveness feed can look * up the owning node for a session id (subtask 2). Populated on `attach` * (a node's successful launch inside {@link _dispatchNode}), dropped on * `detach` (the node's terminal transition). Empty when no feed is wired. */ private readonly _sessionToNodeId; /** * Per-instance terminal dedupe context. The two one-shot flags suppress a * repeated terminal event of the same type; {@link TerminationContext * .terminalEpoch} is the Y26 epoch counter that the termination checker * advances on every claim and both re-open paths (`retryNode` / * {@link resetTerminalDedupe}) bump, so terminal events stamped on either * side of a re-open are distinguishable by the notification consumer. */ private readonly _terminationCtx; /** * Every `onTaskTerminated` subscription this engine registered via * {@link subscribeTaskTermination} during `_dispatchNode` (monitor M4), * as the exact `{ taskId, callback }` pair handed to the dispatch port. * Consumed by {@link getTerminationSubscriptions} so a teardown path * (S7 dispose) can unregister each listener and never leak one. */ private readonly _terminationSubscriptions; /** * Single pending wake-up timer for escalate-retry backoff (subtask 2). Armed * by {@link _rescheduleBackoffDispatch} at the end of every dispatch pass * for the EARLIEST pending `retryBackoffUntil` deadline among Ready frontier * nodes; firing re-enters the advancement lock and re-runs the dispatch pass * plus the termination check. Exactly one handle per engine: re-scheduling * clears the previous timer, teardown (S7 dispose) clears it, and reaching * the terminal phase clears it — so a stale fire can never dispatch on a * completed / disposed graph. Absent (undefined) when no backoff is pending. */ private _backoffTimer?; /** * Owner token of the advancement lock (Y3). Set by * {@link _acquireAdvancingLock} on a successful acquisition and cleared by * {@link _releaseAdvancingLock}; {@link _runCriticalSection} verifies it at * entry so a section can never run unlocked (and release can never free * another holder's lock). `undefined` while this engine holds no lock. */ private _advanceLockToken?; /** * Whether a `dispatchReady()` kickoff arrived while the advancement lock was * held by an in-flight critical section (R4). Consumed by that section's * `finally` (after `_drainDeferred`), which re-runs the dispatch pass — so a * kickoff is deferred, never dropped, exactly like a signal completion. */ private _dispatchRequested; constructor(opts: AdvanceEngineOptions); /** * Advance the graph in response to a signal emitted by a node. * * Step 1: records the signal (type → payload) into the node's * `signalsObserved` via the {@link SignalBridge}. Non-terminating signals * (pausing / handoff / info) are recorded only — they never advance the graph * in Phase 1. Terminating signals then run the advancement critical section * (`_advanceSignal`), which also routes through the re-entrancy guard. * * @param source Origin discriminator for the signal ledger event (defaults * to `"dispatch"` for live worker signals; recovery deferred * drain paths pass `"recovery"`). */ onNodeSignalEmitted(nodeId: string, signalType: SignalType, signalPayload: unknown, source?: SignalLedgerSource): Promise; /** * Kickoff: dispatch every node currently `ready` in the frontier. * * After `provision()` the root nodes are `ready` + in the frontier; calling * this dispatches them (each becomes `running`), moving the graph from * `idle` to `executing`. Subsequent advancement is driven purely by signals. * * Lock contention (R4): when an in-flight critical section already holds the * advancement lock, the kickoff is DEFERRED — {@link _dispatchRequested} is * set and the in-flight section's `finally` re-runs the dispatch pass after * its own drain. It is never dropped: the former behaviour returned success * while dispatching nothing, so a ready node that appeared after the * in-flight pass had taken its frontier snapshot (e.g. `recover()`'s * `rebuildFrontier()` racing a fire-and-forget advance) could be stranded * forever, hanging the graph in `executing`. */ dispatchReady(): Promise; /** * Acquire the advancement lock and mint this engine's owner token (Y3). * Returns `undefined` when the lock is already held. */ private _acquireAdvancingLock; /** * Release the advancement lock, but only for its owner (Y3). A release by a * non-owner is an invariant violation: it is logged and the lock is left * alone rather than freeing a lock this call chain never took. */ private _releaseAdvancingLock; /** * Subscribe to the {@link SignalBridge}'s terminating-signal listener registry. * * The recorded signal is already written by `signalBridge.record()` before * listeners fire, so the listener advances WITHOUT re-recording — this is the * `_advanceSignal` (steps 2-7) path, avoiding a `record → fire → record` * recursion loop. Returns an unsubscribe function. */ register(): () => void; /** * The task-termination subscriptions this engine has registered during * `_dispatchNode` (monitor M4). Returns a defensive copy so a teardown path * (S7 dispose) can iterate it and unregister every listener via * `dispatch.removeTaskTerminatedListener(taskId, callback)` without mutating * the engine's internal ledger. Each `callback` is the exact value that was * handed to `port.onTaskTerminated`. * * The copy is ELEMENT-level (B7): both the array and each `{ taskId, * callback }` entry are fresh objects, so a caller mutating an entry cannot * rewrite the engine's ledger. */ getTerminationSubscriptions(): Array<{ taskId: string; callback: TaskTerminatedCallback; }>; /** * Clear the task-termination subscription ledger (review 06-F1 / M16). * * A teardown path (dispose) iterates {@link getTerminationSubscriptions} to * unregister every listener from the dispatch port, then MUST also empty the * ledger itself — otherwise a disposed engine keeps handles to stale * callbacks and a second dispose re-issues removals (no longer a no-op). * This is the only legitimate writer of the ledger from outside the class: * previously the caller reached in via `as unknown as`, which bypassed the * compiler entirely (a renamed/retyped field would silently create a new * property and the real ledger would never clear). * * Idempotent — clearing an already-empty ledger is a no-op. */ clearTerminationSubscriptions(): void; /** * Advancement critical-section wrapper. * * - If the lock is already held (a signal arrived mid-critical-section), the * node is deferred to `pendingCompletions` and returns immediately — the * current critical section will re-process it in its `finally` drain. * - Otherwise it acquires the lock, moves `idle → executing`, runs the work, * and in `finally` releases the lock and drains deferred completions. */ private _advanceSignal; /** * Run a user-triggered control-path operation (approve / reject / * partial-approve / retry) under the advancement lock, deferring the WHOLE * operation when the lock is held by an in-flight critical section. * * The four imperative control paths call this instead of invoking * `_runCriticalSection` directly: `_runCriticalSection`'s `finally` * unconditionally releases the lock (engine-state.ts:544-547), so a section * entered WITHOUT acquiring it would release an owner-less lock — letting a * signal-driven section interleave mid-body instead of deferring. Here, * `acquireAdvancingLock(this.state)` strictly precedes the critical section, * mirroring {@link dispatchReady} and {@link _advanceSignal}. * * Unlike the signal path, which defers by queueing a pending completion, a * control-path operation carries a user decision that must NEVER be lost — * silently dropping it or running it without the lock would both corrupt the * approval / retry semantics. When `acquireAdvancingLock` returns `false` * (an in-flight section holds the lock), this awaits a macrotask boundary * (a 0ms timer) so the section's `finally` can release the lock and * drain, then re-attempts. The retry is bounded by * {@link CONTROL_PATH_LOCK_RETRY_ATTEMPTS}; on exhaustion an explicit error * is surfaced rather than a silent drop or an unlocked mutation. */ private _runControlOperation; /** * Body shared by every critical section: ensure the engine is `executing`, * run the work, then — in `finally` — release the lock and drain any * completions deferred while the section was held. Resolves with the work's * return value (`T`), so callers like `retryNode` can report what the * section produced (e.g. a retry count). Existing `() => Promise` * callers are unaffected. * * INVARIANT (Y3): every `_runCriticalSection` invocation holds the * advancement lock, proven by the `token` the caller received from * {@link _acquireAdvancingLock}. The entry check fails fast when the token is * not the current owner — a section entered without acquiring would otherwise * release someone else's lock in its `finally` and let two critical sections * interleave. * * The overloads (Y1) split the contained variant (`onError` present → the * section resolves `void`) from the propagating variant (`onError` absent → * it answers `T`, rethrowing). The former implementation smuggled * `undefined as T` through the containment branch; with the split there is no * generic escape to lie about. */ private _runCriticalSection; /** * Run a `dispatchReady()` kickoff that was deferred because this engine held * the advancement lock (R4). No-op when no kickoff is pending. * * The flag is cleared BEFORE acquiring the lock: a kickoff arriving while this * pass runs is a genuine new request and is consumed by this section's own * `finally` rather than swallowed. The pass is contained — a throwing * dispatch must not reject the caller's already-resolved critical section. */ private _consumeDispatchRequest; /** * Drain deferred completions queued while a critical section was held. * * Mirrors `src/loop/coordinator.ts:450-462`: re-process each deferred node * under a fresh critical section. The signal to replay is re-derived from the * node's recorded `signalsObserved` (highest-severity terminating signal). */ private _drainDeferred; /** * Contain a throwing advancement critical section for the affected node * (subtask 2). Invoked via {@link _advanceSignal}'s onError hook so a * `work()` exception — a throwing conditionResolver, a broken propagation * invariant, a throwing recorder — surfaces as a terminal node failure * instead of an unhandled rejection: * * - Escalate the node when its lifecycle permits (`running` / `ready` / * `pending` / `completed` / `blocked` → `escalate`), carrying the error * reason, and surface it through the completion seam like a live escalate. * - When the node is already terminal, just log — there is no transition * left to apply. * * There is NO `timeout` fallback branch (L18): per the lifecycle table * (`node-lifecycle.ts` VALID_NODE_TRANSITIONS), every status from which * `timeout` is legal (`running`) also admits `escalate`, so the escalate * branch above always hits — the former "stuck running" fallback was * unreachable dead code. * * M7 (containment escalate propagation): the escalate branch mirrors the * dispatch-failure path (`_dispatchNode`, engine-advance.ts:1465-1473) — * the escalate is recorded to the ledger (source `race_guard`) and a * deferred completion is queued, so the failure is visible to * `_latestTerminating` / the F3 dead-end predicate / the signal ledger and * the drain re-runs the termination check after the lock releases. The * downstream fan-in joins are failed INLINE via * {@link _propagateEscalateSignal} (the shared live-escalate propagation * block): a deferred re-advance cannot re-run propagation for an * already-terminal node (`_applySignalTransition` only transitions * `running`, so the H1 migrated gate skips it), and a multi-input fan-in * downstream would otherwise stay `pending` forever — its join never sees * the failure, and the deadlock guard explicitly refuses to quiesce a * pending node reached via `always` edges (engine-advance.ts:1709) — the * graph hangs in `executing` until manual intervention (M7). * * Finally re-checks graph termination so the terminal transition (GRAPH * COMPLETE / BLOCKED) is never silently dropped by the containment — this * holds even when the affected node has vanished from `state.nodes` (L19): * the `!node` early-exit also runs the re-check. */ private _containAdvanceError; /** * Re-run the graph termination check after a containment pass, containing any * throw from the checker itself (a broken notifier must never escape the * containment hook — it is already inside `_runCriticalSection`'s onError * containment, but belt-and-braces keeps the promise absolute). */ private _recheckTerminationAfterContainment; /** * Best-effort, NEVER-throwing error text from an unknown throw value (C3 / * B5). Delegates to the shared {@link errorText}: an `Error` answers its * message (falling back to its name), anything unprintable answers * `""` — the former `String(err)` fallback threw * on a value with no primitive conversion, inside catch blocks whose whole * job was to turn a failure into a log line. */ private _errorString; /** * Run the signal-driven advancement algorithm (design §3.3, steps 2-7) for * one terminating signal on one node, inside the critical section. */ private _advance; /** * Capture the dispatch task's materialized result ref onto the node's * runtime state. Best-effort — a missing task, an absent `getTask` port * (test fakes), or a failed read are no-ops that never block advancement. * * Called after `markCompleted` in every completion path (answer / revise / * approval-resume) so `node.result` is populated before downstream * consumers (graph_status include_output, export_path) read it. Also stashes * the materialized sidecar text once (see {@link _stashResultText}) so the * EdgePayload `result` fallback never touches disk inside the critical * section (subtask 2). */ private _captureNodeResult; /** * Stash the node's materialized-result sidecar text ONCE at completion time * (subtask 2 — Y1). The synchronous `readFileSync` still runs inside the * advancement critical section (this method is called from * `_applySignalTransition` / `_captureNodeResult`, both under the lock); * the actual improvement is that the read moved from per-edge to per-node — * `_edgeResultText` used to read the sidecar again for every outbound edge * and every re-build of the edge payload, and now returns this stash. * * Best-effort, preserving the former read's I/O-failure → '' degradation: a * missing/unreadable sidecar stashes `''` (never throws into advancement). * Idempotent — skips when already stashed, so the sidecar is read at most * once per node lifetime. */ private _stashResultText; /** * Apply the generic node-lifecycle transition for the given signal. * * Idempotent by construction: a transition is only applied when the node is * actually in the from-state, so re-advancing an already-processed node is a * harmless no-op (this also makes deferred-completion replay safe). * * Returns whether a transition was ACTUALLY applied. This is the * propagation/forward-activation guard (H1): a duplicate / replayed * terminating signal on an already-terminal node must not re-run the * side-effect branches in {@link _advance} — double-delivery through the * `signalBridge` listener + `subscribeTaskTermination` seams (or a * race-guard synthetic signal) would otherwise double-count loop traversals, * mis-trigger the stuck early-exit, and re-activate downstream nodes on an * already-quiesced graph. The caller skips propagation / forward activation * when this returns `false`. */ private _applySignalTransition; /** * Fire the optional node-completion seam for a node that reached a terminal / * notable status (subtask 1). A no-op when no callback is registered, so the * engine's behavior is unchanged without the seam. The event packages the * immutable facts only ({@link NodeCompletionEvent}) — notification logic is * the consumer's concern. A throwing consumer must not corrupt the advancing * critical section (a notifier is observability, not a control path). */ private _notifyCompletion; /** * Notify the completion seam for a node timed out by the recovery path * (subtask 1). Recovery marks a `running` node `timeout` directly inside * `reconcileEngine` (`engine-recovery.ts`) when its dispatch task vanished — * that transition happens outside the signal-driven `_applySignalTransition`, * so recovery surfaces it through this public seam exactly once. No * terminating signal drives a timeout, so the event uses the synthetic * `timeout` marker with the node's recorded `errorReason` as payload. * * Monitor H2: the durable event log is written UNCONDITIONALLY — even when * no `onNodeCompletion` notifier is registered — mirroring * {@link _notifyCompletion} (the event is built and logged regardless of the * notifier seam). Only the `onNodeCompletion` callback invocation is * conditional. * * Contract (B9, unified with {@link notifyNodeTerminal}): a notification * entry point is best-effort observability, so an UNKNOWN node id is a strict * no-op — it never throws (the former `getNode` call threw * `Unknown node id`, while `notifyNodeTerminal` silently returned). * A known node not in `timeout` is also a no-op. */ notifyNodeTimeout(nodeId: string): void; /** * Public node-terminal notification entry point (monitor H4). Wraps the * private {@link _notifyCompletion} so external control paths that mutate * node lifecycle OUTSIDE the signal-driven advancement (e.g. graph * cancellation, S7) can surface the node's terminal transition through the * same completion seam + durable event log as signal-driven transitions. * * The caller supplies the transition facts (`signalType`, `payload`, * `nodeStatus`) — the engine stays role-agnostic and only packages them. * * Contract (B9, unified with {@link notifyNodeTimeout}): an unknown node id * is a strict no-op — this is a teardown / control-path observability entry, * never a mutation gate, so it does not throw for a node that has already * vanished. */ notifyNodeTerminal(nodeId: string, signalType: NodeCompletionSignalType, payload: unknown, nodeStatus: NodeStatus): void; /** * Record a session-activity heartbeat for a running node (subtask 2 of * node-anomaly-detection). The public liveness intake — called by the * platform liveness feed (session tool-call / message observations) and by * the stall monitor (subtask 3) when it re-classifies activity. * * Guarded: a heartbeat only lands on a node that is BOTH `running` AND * actually dispatched by this engine (`dispatchTaskId` set — matching the * launch that produced the session). Every other case is a strict no-op: * a completed / escalated / blocked / pending node must never be revived * into activity, and a node that was never launched has no live session to * heartbeat. The mutation is non-critical observability churn — it rides * the debounced persistence tier (`markNonCriticalDirty`), never the * synchronous write-through. */ recordLivenessHeartbeat(nodeId: string, source: NodeLivenessState["heartbeatSource"]): void; /** * Immediate-failure fast path (subtask 4 of node-anomaly-detection). Public * intake for session-level failure observations relayed by the platform * liveness feed — a dispatch session reported `error` (session.error) or * `gone` (session.deleted: the platform can no longer see the session at all). * * Strictly guarded — a no-op unless ALL of: * - the node is RUNNING (a terminal / pending / ready / blocked / cancelled * node must never be revived or re-advanced); * - the liveness feed is attached: the seam is wired AND the node's dispatch * session was registered with it at launch (`dispatchSessionId` present — * the same predicate {@link _detachLiveness} uses to unregister). * * Per-kind semantics: * - `gone` is AUTHORITATIVE — the worker vanished and the platform can no * longer observe it, so the node fails immediately through the existing * escalate advance ({@link onNodeSignalEmitted} with source `"dispatch"`), * reusing the standard escalate propagation + cascade cancel so the * abnormal node never blocks graph advancement. * - `error` is first re-checked against the dispatch port via * {@link isDispatchTaskLive}: a task that is STILL LIVE (running / pending / * awaiting_approval) means the session error was transient — the engine * records a `session` heartbeat (activity continues) and returns, keeping * the node running (matching the dispatch layer's guardedMarkError * semantics). A task that is genuinely NOT live escalates like `gone`. * * Returns the contained advance promise — it resolves when the observation * was processed (or rejected by a guard) and never rejects out of the box: * the escalate advance's critical section contains its own errors (subtask 2), * so a throwing feed relay can never break the engine. */ handleFeedSessionEvent(nodeId: string, kind: "error" | "gone", reason?: string): Promise; /** * Reverse-lookup the node owning a live dispatch session (subtask 2). Backs * the platform liveness feed's `sessionId → nodeId` reverse index: given a * session the platform observes, the feed can find the graph node it * belongs to. Returns `undefined` for an unknown session or a session whose * node has detached (terminal transition). Only meaningful when a * {@link NodeLivenessFeed} is wired — the index is otherwise empty. */ getNodeIdForSession(sessionId: string): string | undefined; /** * Unregister a node's session from the feed + reverse index (subtask 2). * Mirrors {@link NodeLivenessFeed.attach}: called when a running node * reaches a terminal state, so the feed stops observing the session and the * `sessionId → nodeId` index entry is dropped. A no-op when no feed is * wired (the index is empty then) or the node never attached. The feed * call is optional-chained and total — it can never break advancement. */ private _detachLiveness; /** * Public termination re-check (monitor H4). Wraps the private * {@link _checkTermination} so external control paths (e.g. graph * cancellation, S7) can re-evaluate graph termination after manual state * mutation without entering a full advancement critical section. Fires the * `onGraphTerminal` seam (deduped via the two-layer guards) and surfaces * runtime-deadlock synthetic escalations through the completion seam, exactly * like the signal-driven path. */ checkTermination(): void; /** * Package a node's terminating signal into an {@link EdgePayload} for * downstream consumption (design §2.2 edge payload shape). */ private _buildEdgePayload; /** * Edge payload `result` text for a source node's terminating signal (M3). * * Precedence: * 1. A real string payload → verbatim. * 2. A real object payload → JSON-serialized. * 3. Missing / empty / synthetic-`__inferred` payloads — the worker never * emitted genuine output, or the engine inferred an answer on its behalf * (`engine-recovery.ts` `{ __inferred: true }`) — → fall back to the * node's materialized result text: the real output the worker produced. * * The materialized text is NOT read from disk here (subtask 2 — Y1). The * sidecar was read ONCE at completion time by `_captureNodeResult` → * `_stashResultText` (which preserves the I/O-failure → '' degradation) and * stashed on `node.resultText`; this method returns the stash. The advancing * lock is held while this runs, so a synchronous disk read in the * advancement critical section was the defect being removed. * * Without a stashed text the empty string is used, replacing the previous * `'""'` (JSON-quoted empty string) / `'{"__inferred":true}'` artifacts so * downstream fan-in consumers see the node's actual output. */ private _edgeResultText; /** * Whether an outbound edge activates for the given signal. * * - `always` → true for the activating signal (only reached on the answer * forward-flow — escalate/revise_needed propagate through their own * propagators and never evaluate outbound edges here). * - `on_signal` → true when the signal is in the edge's `signal_filter`. * - `on_condition` → delegates to the injected resolver; with no * resolver the edge never activates. */ private _edgeActivates; /** * Dispatch every node currently `ready` in the frontier (design §3.3 step 6). * Each becomes `running` atomically inside the critical section and is * removed from the frontier, then dispatched to its bound agent. * * Subtask 2 (escalate-retry backoff): a Ready node whose retry-backoff * deadline has not yet passed (`retryBackoffUntil > now`) is SKIPPED on this * pass — it stays Ready in the frontier, is never `markRunning`-ed, and is * never launched. Because a Ready node is scheduler-active to * `checkGraphTermination`, the graph phase stays `executing` through the * backoff window; `_rescheduleBackoffDispatch` (called below) arms the * single wake-up timer that re-runs this pass at the earliest deadline. */ private _dispatchReadyNodes; /** * Dispatch a single ready node: budget pre-check, mark running, launch. * * Always operates on {@link AdvanceEngine.state} — the former `state` * parameter was the only caller's own `this.state` (B10); keeping it in the * signature invited a future caller to write lifecycle into one graph and * frontier/budget bookkeeping into another, which the compiler could not * catch. The pre-dispatch budget gate and the post-launch race decision are * extracted as the pure module-level functions * {@link resolveDispatchBudgetGate} / {@link resolvePostLaunchSignal}. */ private _dispatchNode; /** * Escalate a ready node that failed a budget pre-check and drop it from the * frontier, surfacing the rejection through the completion seam / durable * event log exactly like a live `escalate` signal (markEscalated is a * lifecycle transition, not a signal, so _applySignalTransition never sees * it). Shared by the graph-level and per-node budget pre-checks in * {@link _dispatchNode}. A no-op when the node is no longer `ready`. */ private _escalateBudgetRejected; /** * Cancel every remaining `pending` node after a GRAPH-level budget breach. * * Rationale (must hold): an escalated source never forward-activates its * `always` edges (escalation is not an `answer`), and the F3 dead-end * predicate ({@link _isPendingDeadEnded}) never counts an `always` edge as * dead-ended — so without this sweep a pending downstream node would hang * the graph in `executing` forever. Each pending node is marked cancelled, * dropped from the frontier (a no-op — pending nodes are not frontier * members — but idempotent), and surfaced through the completion seam. * * Idempotent across per-node dispatch passes: the status guard * (`status === Pending`) means a node already advanced by a prior pass is * never re-cancelled, and `Pending → Cancelled` is a legal transition * (node-lifecycle.ts:61). The PER-NODE breach path does NOT sweep — only * the graph-level path calls this. */ private _cancelBudgetStrandedNodes; /** * Advance `executing → complete` when no node remains active (running, ready, * pending, or blocked). Mirrors the exit guard in design §1.2. * * Also detects the quiescent-blocked terminal: no running / ready / pending * nodes remain but ≥1 blocked node exists. Fires `onGraphTerminal` with * `isBlocked=true` WITHOUT a phase transition. Each terminal type (complete / * blocked) fires at most once via separate dedupe guards. * * Monitor (M1c): the runtime-deadlock guard's synthetic escalations (pending * nodes escalated with `DEADLOCK_REASON` inside checkGraphTermination) are * surfaced through the completion seam via the `onSyntheticEscalate` hook — * one `_notifyCompletion` per escalated node. Dedup: the deadlock guard only * escalates `pending` nodes and only when no node is already escalated * (`counts.escalate === 0`), so a node escalated by propagation (M1b) or a * live signal is never double-notified; the status guard below is the * defense-in-depth. */ private _checkTermination; /** * Whether a Ready node's automatic escalate-retry dispatch is currently * withheld by an unexpired backoff deadline (subtask 2). A node re-marked * `ready` by the escalate retry gate (signal-propagation.ts) with * `retryBackoffUntil > now` must not be dispatched until the deadline * passes — it stays Ready in the frontier, keeping the graph `executing` * (a Ready node is scheduler-active to `checkGraphTermination`). */ private _isBackoffPending; /** * (Re)arm the single wake-up timer for the earliest pending backoff deadline. * * Called at the end of every dispatch pass ({@link _dispatchReadyNodes}): * clears any previously-armed timer, then — when at least one Ready frontier * node is still inside its retry-backoff window — schedules ONE setTimeout * for the earliest deadline. The timer callback re-enters the advancement * lock via {@link _runControlOperation} (the same bounded lock-acquisition * retry used by the approve/reject control paths) and re-runs the dispatch * pass plus the termination check, so a backoff-withheld retry node is * re-dispatched the moment its window closes — with no polling and no lock * held between passes (a setTimeout callback is a fresh macrotask, never * inside a dispatch await — the "no await inside dispatch" constraint). * * A pass that finds no pending backoff leaves no timer armed: a stale timer * from an earlier pass would otherwise fire a dispatch pass on a graph that * no longer needs one. The timer is unref'd so it never keeps the process * alive on its own. */ private _rescheduleBackoffDispatch; /** * Clear the pending backoff wake-up timer (no-op when none is armed). * Public — the S7 dispose path (`src/graph/engine/index.ts` dispose) and the * manual terminal-transition paths (cancel) call it so a disposed / * completed engine never fires a dispatch pass. */ clearBackoffTimer(): void; /** Clear the armed wake-up timer (no-op when none is armed). */ private _clearBackoffTimer; /** * Whether a pending node is dead-ended: every one of its incoming edges is * provably unable to activate, so the node can never become `ready` (F3). * * A pending node is dead-ended iff EVERY incoming edge is: * (i) an `on_condition` edge with no condition, no injected resolver, or a * resolver that returns false (the edge can never fire); * (ii) an `on_signal` edge whose `signal_filter` excludes the source's * recorded terminating signal while the source is terminal * (Completed / Done / Escalate / Timeout — the source can never emit * an in-filter signal again); * (iii) sourced from a Cancelled node (a cancelled source never emits). * * Branch order (B11): (iii) is tested FIRST — when the source is * `Cancelled`, the edge counts as never-activatable for EVERY edge type, * including `always`. Only then does the `always` rule apply: an `always` * edge from any other source NEVER counts as dead-ended, even from a * terminal source — the graph is then in an error state awaiting * orchestrator attention, and an escalated node with a pending downstream * via an `always` edge must keep the engine `executing` * (engine-terminal.test.ts "does NOT deadlock-terminate a graph with an * escalated node and a pending downstream"). * * Pure state reader — never mutates. Unknown / unverifiable topology * conservatively reports NOT dead-ended so the guard never force-completes * a graph it cannot prove is stuck. */ private _isPendingDeadEnded; /** * Highest-severity terminating signal recorded for a node, or null. * * Reads the per-node ledger through the shared {@link getSignal} accessor * (C2 / Y8) — the severity vocabulary stays this method's concern, the * ledger narrowing stays the seam's. */ private _latestTerminating; /** * Propagate an `escalate` forward to the nearest fan-in convergence node(s), * consulting each outbound edge's `retry` policy (retry re-marks the node * `ready`; otherwise the escalation travels up the escalation lattice). * Delegates to {@link propagateEscalate} (`signal-propagation.ts`). * * Used for **non-loop-group** nodes; loop-group members are routed through * the {@link executeLoopStep} executor (which also applies the cascade * canceller on a failed convergence join). */ private _propagateEscalate; /** * Shared escalate-propagation block for a just-escalated source node (M7). * * Used by BOTH the live escalate path ({@link _advance}) and the * containment path ({@link _containAdvanceError}) so the two never drift: * * - Loop-group members route through {@link executeLoopStep} (which owns the * loop's own §3.3 cascade + traversal accounting). * - Non-loop nodes propagate via {@link _propagateEscalate}, then every * join-failed NON-loop convergence node's still-pending upstreams are * retired via the cascade canceller so they stop consuming dispatch * budget. Loop-group targets are skipped — their cascade is owned by * executeLoopStep, and the revise back-edge topology makes a blind * cascade here unsafe (it would wrongly retire the still-needed back-edge * source). Finally the propagation's escalations are surfaced through the * completion seam exactly once (monitor M1b). */ private _propagateEscalateSignal; /** * Surface every node a propagation pass escalated (monitor M1b). * * `propagateEscalate` / `propagateRevise` escalate nodes via the lifecycle * `markEscalated` / `markDone` transitions inside signal-propagation.ts — * those are not signals, so `_applySignalTransition` never sees them and the * completion seam would otherwise stay silent. This helper replays the * propagation report's `escalated` list through {@link _notifyCompletion}. * * Dedup: a node is only notified when it actually landed in a terminal * escalated state this pass (`Escalate`, or `Done` for a stuck / exhausted * revise). The escalating SOURCE node was already notified by * `_applySignalTransition`, and an already-terminal node cannot be escalated * again (escalate is terminal), so no node is double-notified here; the * status guard is the defense-in-depth against any future overlap with the * synthetic-escalation path ({@link _checkTermination}). * * The payload is the propagation's machine-readable reason when present * (e.g. `max_traversals exhausted`), falling back to the original signal * payload for a join-failure cascade. * * The parameter is the minimal `{ escalated, reason }` shape rather than * `SignalPropagationReport` so the loop lane's {@link LoopStepReport} (Y16) * can be consumed by the same helper without an adapter. */ private _notifyPropagatedEscalations; /** * Back-propagate a `revise_needed` along the loop group's * `on_signal(revise_needed)` back-edges so upstream nodes re-enter `ready`, * bounded by the loop group's `max_traversals` (escalate when exhausted). * Delegates to {@link propagateRevise} (`signal-propagation.ts`). * * Used for **non-loop-group** nodes (a plain revise with nowhere to re-enter * escalates with reason `no loop group`); loop-group members are routed * through the {@link executeLoopStep} executor (which applies the §4.3 stuck * and exhaustion early-exits first). */ private _propagateRevise; /** * Pause a `needs_approval` node for human decision. * * Called inside the advancement critical section when the node emits the * pausing `need_approval` signal. Transitions `running → blocked` via * {@link markNodeBlocked}, removes the node from the frontier, and gates its * downstream branch (no forward data flow). The structured approval context * (upstream results + graph totals, §1.4) is assembled and stashed on the * node's `signalsObserved.approval_payload` for consumers to render. * * Idempotent: a replayed `need_approval` on a non-`running` node (or on a * node that did not declare `needs_approval`) is a no-op. */ private _pauseForApproval; /** * Resume a blocked `needs_approval` node with an **approval**. * * Under the advancement critical section: `blocked → completed` + record an * `answer` signal ({@link approveBlockedNode}), then run the forward `answer` * data flow so downstream `on_signal(answer)` / `always` edges activate and * satisfied downstream joins become `ready` (§1.3 resume-on-approval). * Freshly-ready nodes are dispatched and termination re-checked inside the * same section (durable via the write-through persistence seam). * * No-op guard: when {@link approveBlockedNode} returns `null` (the node was * not actually `blocked` — e.g. a replayed approve on a completed node, or an * approve against an untouched `ready` node), the forward data flow, the * ready-frontier dispatch, and the termination re-check are ALL skipped. A * no-op approval performs no graph mutation. * * Control-path lock: the section is entered only after * `acquireAdvancingLock` succeeds. When an in-flight critical section holds * the lock, the WHOLE operation defers (macrotask-bound retry in * {@link _runControlOperation}) instead of running unlocked — a user * approval is never lost and never interleaved with a signal-driven section. * * @returns the {@link ApproveReport} of THIS call (contract C6), projected * from the very `approveBlockedNode` result that drove the transition: * `applied: false` for the idempotent no-op (the node was not `blocked`), * `true` when the approval transitioned it to `completed`. */ approveNode(nodeId: string, payload?: unknown): Promise; /** * Resume a blocked `needs_approval` node with a **rejection**. * * Delegates to {@link rejectBlockedNode} (`signal-propagation.ts` re-entry * lane reuse): `blocked → ready` re-enter with the rejection feedback merged * into the re-execution prompt, or `blocked → escalate` when the node has no * loop group to re-open. No forward data flow runs on reject. * * M13: the `blocked → escalate` lane is a TERMINAL transition — the * completion seam fires an `escalate` event so the monitor can perceive "the * gate was rejected and escalated" (the last silent HITL lane; approve and * partialApprove already notify). Guarded on the {@link RejectReport} kind — * a replayed reject on an already-resolved node is a no-op that must not * re-fire. * * The revise lane (`blocked → ready` re-entry) intentionally stays silent: * it is NOT a terminal transition — the node re-runs and its eventual * terminating signal fires its own completion event, matching the * signal-driven conventions (a notify only accompanies an actual lifecycle * completion). The synthetic `revise_needed` ledger entry recorded by * `rejectBlockedNode` already gives observers the rejection fact. * * Control-path lock: the section is entered only after `acquireAdvancingLock` * succeeds (see {@link _runControlOperation}); under contention the whole * rejection defers rather than running unlocked. * * @returns the {@link RejectReport} of THIS call (contract C6) — the exact * value `rejectBlockedNode` answered: the lane the rejection took * (`escalate` / `revise`), or `already_resolved` with the node's status * for an idempotent replay. */ rejectNode(nodeId: string, reason?: string): Promise; /** * Partially approve a blocked `needs_approval` node (§1.5). * * - {@link pruneDownstreamSubgraph} — cancel the rejected branches' * transitive dependents that cannot survive on the approved sources alone. * - {@link resetRejectedUpstreams} — drop the rejected sources from the * approval node's accumulated results and recompute its join, so it * re-waits for their re-execution. * - {@link reenterRejectedUpstreams} — re-mark the rejected upstreams `ready` * (completed → ready) with feedback so they re-run and re-answer. * - If the approval node's join is still satisfied by the surviving approved * sources (e.g. `any`), it re-enters `ready` to re-render immediately; * otherwise it stays `blocked` awaiting the re-executed branches. * * Member validation (Y12): before ANY mutation, `approved` and `rejected` * must be disjoint subsets of the gate's DECLARED upstream set and must not * contain the gate itself (see {@link _assertPartialApproveMembers}). Without * the check an arbitrary id that happens to be re-enterable is silently * re-run, and putting the gate in `rejected` makes the rejected list look * like a full upstream failure — cancelling the gate's whole downstream under * an `all` join. Invalid input throws (the {@link getNode} contract). * * Control-path lock: the section is entered only after `acquireAdvancingLock` * succeeds (see {@link _runControlOperation}); under contention the whole * partial approval defers rather than running unlocked. * * @returns the {@link PruneReport} of THIS verdict (contract C6) — the * dependents cancelled and the ones surviving on their remaining approved * upstreams. When the gate was not `blocked` the verdict is an idempotent * no-op, answered as an empty report rather than re-derived from a * snapshot. */ partialApprove(nodeId: string, approved: string[], rejected: string[], reason?: string): Promise; /** * Validate a partial-approval verdict against the gate's declared upstream * set, BEFORE any mutation (Y12). Throws on: * * - an id that is not an upstream of the gate (e.g. a downstream node or a * typo) — the mutation helpers would otherwise re-run any re-enterable * node they are handed; * - an id listed twice within one list; * - an id in BOTH lists (the two verdicts contradict each other); * - the gate itself in either list — a "rejected gate" makes the approved * count look like a full upstream failure and cancels the gate's whole * downstream under an `all` join. * * Empty lists stay legal: a partial approval may approve everything * (rejected empty) or reject everything (approved empty). * * @throws when the verdict is not a partition of a subset of the gate's * upstreams. */ private _assertPartialApproveMembers; /** * Retry a terminal graph's node (tool-merge-map.md §2.2 `graph_run(node_id, * retry=true, modify_prompt=...)`). * * Under the advancement critical section: {@link resetNodeForRetry} re-opens * the target node (and its downstream subgraph) into a clean `pending` state, * prepends `modify_prompt` to the target's prompt, re-marks the target `ready` * (into the frontier), and re-opens a terminal graph phase. The freshly-ready * target is then dispatched and termination re-checked inside the same * section (durable via the write-through persistence seam). * * Downstream nodes reset to `pending` re-activate via their joins once the * target re-completes and re-emits — only the target is re-dispatched here, so * {@link RetryReport.reDispatched} is the number of reset nodes that ended this * call in `running` (normally the single target). * * Control-path lock: the section is entered only after `acquireAdvancingLock` * succeeds (see {@link _runControlOperation}); under contention the whole * retry defers rather than running unlocked. */ retryNode(nodeId: string, opts?: RetryNodeOptions): Promise; /** * Unregister + drop the `onTaskTerminated` subscriptions of dispatch tasks a * retry just superseded (M11). `resetNodeForRetry` cleared those task ids * from the reset nodes; their listeners — already fired (terminal tasks) or * inert (the `current.dispatchTaskId !== completedTaskId` superseded-task * guard in `subscribeTaskTermination`) — must not linger in the port or the * {@link _terminationSubscriptions} ledger. * * Ledger consistency (Y13): an entry is dropped ONLY when the port has a * removal surface AND the removal call returned normally. When the surface is * absent the listener is still registered in the port, so the entry is KEPT * — a teardown path iterating {@link getTerminationSubscriptions} is then * still able to unregister it (dropping it would create exactly the zombie * subscription M11 exists to prevent). A throwing removal is contained and * logged with the same keep-the-entry result, so one bad listener cannot * abort the retry. */ private _purgeSupersededTerminationSubscriptions; /** * Reset the terminal dedupe guards (`terminalComplete` / `terminalBlocked`) * on the internal {@link TerminationContext}. Call this after manually * re-opening a terminal graph phase (e.g. adding nodes to a completed graph * and transitioning phase back to `Executing`) so that the next legitimate * terminal event fires exactly once. * * This is the public counterpart of the guard-reset embedded in * {@link retryNode} — use it for non-retry re-open paths (e.g. extend after * complete) where the engine instance is reused. */ resetTerminalDedupe(): void; /** * Forward an approved node's `answer` payload along its outbound edges, * activating downstream joins and readying satisfied targets. Mirrors the * `answer` forward-data-flow block in {@link _advance} but is driven by an * approval resume instead of a live worker signal. */ private _forwardAnswerOnApproval; /** * Shared forward-activation for an `answer` signal: walk the source's * outbound edges, apply edge activation, per-edge data mapping, collect * upstream results, and re-enter satisfied downstream joins into `ready`. * Includes loop-group traversal accounting (intra-group `always`-edge * re-entries) + round recording. * * Used by BOTH the live-signal path ({@link _advance} answer block) and the * approval-resume path ({@link _forwardAnswerOnApproval}) so the two never * drift. The approval path previously omitted the loop traversal increment * and round recording — this shared method brings it into parity. */ private _forwardActivation; } //# sourceMappingURL=engine-advance.d.ts.map