/** * Graph Execution Engine v2 — Node Retry (re-open / re-dispatch) * * Version: 2.0 * Date: 2026-07-25 * * Retry support for the imperative `graph_run(node_id, retry=true, modify_prompt=...)` * surface (`.rolebox/design/tool-merge-map.md` §2.2 `graph_run`). Phase-4 concern: * a terminal graph's failed / escalated / completed node is re-opened for a clean * re-dispatch, clearing the stale per-run artifacts of the node **and its * downstream subgraph** so a re-run sees a fresh execution state. * * The work is split into two layers, mirroring the approval-handler convention * (`approval-handler.ts` — pure state primitives + an engine method that runs * them inside the advancement critical section): * * - {@link resetNodeForRetry} — a **pure state mutation**. Resets the target * node and its transitive downstream to `pending`, prepends `modifyPrompt` to * the target's prompt, re-marks the target `ready` (into the frontier) so it * is picked up by `_dispatchReadyNodes`, and re-opens a terminal graph phase * (`complete → executing`). Never dispatches, never acquires the lock. * - {@link retryNode} — the engine-facing orchestration (see `AdvanceEngine.retryNode` * in `engine-advance.ts`): runs the reset inside a critical section, dispatches * the now-ready target, re-checks termination, and returns a {@link RetryReport}. * * Design-vs-code notes: * * 1. **Deliberate reset, not a signal-driven transition.** Retry of a terminal * node has to clear a `done` / `timeout` / `cancelled` node, none of which have * a legal transition out of the terminal state in `node-lifecycle.ts` (`done` * has no out-edges at all). So the reset writes `NodeStatus.Pending` directly * instead of routing through `transitionNode`/`canTransitionNode`. It is a * manual re-open, not an event the graph observed. `markReady` is still reused * for the `pending → ready` hop (a legal transition), and * `addToFrontier`/`removeFromFrontier` (engine-state.ts) manage the frontier. * 2. **Engine-phase re-entry.** The phase machine (`engine-state.ts`) only moves * forward (`idle → executing → complete`); there is no `complete → executing` * edge. Retrying a terminal graph therefore writes the phase back to * `executing` directly. This is the same deliberate-reopen rationale as the * node-level reset above and keeps the forward-only invariant intact for all * signal-driven advancement. * 3. **Counters are preserved.** `sessionsSpawned`, `tokensConsumed`, * `traversalCount`, and `retryCount` accumulate across retries so budget and * loop accounting stay honest; only the per-run execution artifacts * (status, signals, result, dispatch ids, join state) are cleared. * 4. **The graph-level signal ledger is synced.** The per-node * `signalsObserved` reset alone would leave the dual-write * `state.signalLedger` entry (signals, lastSignalAt, history) carrying * PRE-retry events, so `resetNodeForRetry` also drops the ledger entry for * the target and every reset downstream node — restoring the invariant * "per-node signalsObserved ⇔ graph-level ledger" and keeping `graph_status` * `stream` / `since` / `include_history` / `progress` views free of stale * signal events from the previous run. * 5. **M11 retry guard (state check).** Retry only re-opens a QUIESCENT node. * A node still mid-execution — `running` (a live dispatch task: tokens/cost * keep flowing and the net-live `sessionsSpawned` slot would never be * refunded) or `blocked` (a HITL approval gate in flight, owned by * `graph_approve`) — anywhere in the reset scope (target + downstream) is * REFUSED with an actionable error BEFORE any mutation. The caller cancels * the node (`graph_cancel`) or waits for it to terminate, then retries. * 6. **Superseded subscriptions are reported.** The previous `dispatchTaskId` * of every reset node is captured into {@link RetryResetReport.supersededTaskIds} * so the engine layer can unregister the matching `onTaskTerminated` * listeners (`AdvanceEngine.retryNode` → `_purgeSupersededTerminationSubscriptions`) * — no zombie subscriptions accumulate across retries. * 7. **`modifyPrompt` is replace-style deduplicated.** Re-applying the SAME * `modify_prompt` block is idempotent: when the block already prefixes the * prompt it is NOT prepended again, so repeated retries never grow the * prompt unboundedly (M11). * * Design reference: `.rolebox/design/tool-merge-map.md` §2.2 `graph_run`. */ import type { EngineState } from "../../types.engine-v2.ts"; import type { EngineRuntime } from "./index.ts"; /** Options for {@link retryNode} / {@link resetNodeForRetry}. */ export interface RetryNodeOptions { /** * When provided, prepended to the target node's `prompt` before it re-dispatches * (tool-merge-map.md §2.2 `graph_run` `modify_prompt`). */ modifyPrompt?: string; } /** Result of the pure {@link resetNodeForRetry} mutation (no dispatch). */ export interface RetryResetReport { /** The node id being retried. */ target: string; /** Node ids reset to `pending` for a clean re-run (target + downstream). */ reset: string[]; /** The node ids left `ready` (in the frontier) for immediate dispatch. */ ready: string[]; /** * The previous `dispatchTaskId` of every reset node (target + downstream), * captured as the reset cleared them (M11). The engine layer unregisters the * matching `onTaskTerminated` subscriptions so no zombie subscription * outlives its superseded task. Empty when no reset node carried a task. */ supersededTaskIds: string[]; } /** Result of the full {@link retryNode} orchestration (reset + dispatch). */ export interface RetryReport extends RetryResetReport { /** Number of nodes actually (re-)dispatched into `running` this call. */ reDispatched: number; } /** * Pure state mutation that re-opens a node for retry (tool-merge-map.md §2.2 * `graph_run(node_id, retry=true, modify_prompt=...)`). * * Steps: * 0. **M11 state guard.** Refuse (throw, no mutation) when ANY node in the * reset scope — the target or a transitive downstream — is still * mid-execution (`running` with a live dispatch task, or `blocked` under a * HITL gate). Resetting such a node would clear its `dispatchTaskId` * without cancelling the task: the live dispatch session keeps consuming * (tokens/cost, an unrefunded `sessionsSpawned` slot) and its * `onTaskTerminated` listener would become a zombie. The error is * actionable — cancel the node (`graph_cancel`) or wait for termination. * 1. Collect the target node's **transitive downstream** (BFS along every edge * type — same walk shape as `pruneDownstreamSubgraph` in approval-handler.ts, * except we reset instead of cancel). Any downstream node that already ran * (terminal or currently pending/ready with stale artifacts) is reset to * `pending` and removed from the frontier. It will re-activate via its join * once the target re-completes and re-emits. * 2. Reset the **target**: clear its per-run artifacts, force `pending`, prepend * `modifyPrompt` to its prompt (replace-style dedup — re-applying the same * block is a no-op), then re-mark it `ready` and add it to the frontier so * `_dispatchReadyNodes` re-dispatches it regardless of its upstreams (a * manual retry forces a re-run). * 3. Re-open a terminal graph phase (`complete → executing`) so advancement and * termination checks keep working (see header note 2). * 4. **Sync the graph-level signal ledger** (header note 4): for the target and * every reset downstream node, drop its `state.signalLedger` entry so no * pre-retry `history` / `lastSignalAt` leaks into `graph_status` * `stream` / `since` / `include_history` / `progress` views of the retry run. * 5. Report every superseded `dispatchTaskId` in * {@link RetryResetReport.supersededTaskIds} (header note 6) for the engine * layer's zombie-subscription cleanup. * * Never dispatches and never acquires the advancement lock — callers (the advance * engine's critical section) own that. * * @throws when `nodeId` is unknown (reuses `getNode`) or when a node in the * reset scope is still `running` / `blocked` (M11 state guard). */ export declare function resetNodeForRetry(state: EngineState, nodeId: string, opts?: RetryNodeOptions): RetryResetReport; /** * Retry a node on an {@link EngineRuntime}: delegates to the runtime's * `retryNode` method (implemented by `AdvanceEngine.retryNode`, which runs the * {@link resetNodeForRetry} reset inside the advancement critical section, then * dispatches the re-ready target and re-checks termination). * * @returns a {@link RetryReport} — {@link RetryReport.reDispatched} is the number * of nodes (re-)dispatched into `running` this call. */ export declare function retryNode(runtime: EngineRuntime, nodeId: string, opts?: RetryNodeOptions): Promise; //# sourceMappingURL=node-retry.d.ts.map