/** * Graph Termination Checker * * Extracted from engine-advance.ts. Pure state-reader: inspects the engine * state graph, transitions to terminal phases when all nodes are quiescent, * and invokes the optional `onGraphTerminal` callback exactly once per * terminal type (complete / blocked). * * Terminal notifications are deduped at TWO layers (monitor-audit M10 / * F15 exact-once): * 1. The per-instance {@link TerminationContext} (engine-private, reset when * the graph is re-opened via `retryNode` / `resetTerminalDedupe`). * 2. The persisted `EngineState.terminalNotified` flag (survives engine * rebuilds / restarts so a fresh instance never re-delivers a terminal * notification that was already delivered). * * A terminal event fires only when BOTH layers are unclaimed; a fire claims * both and marks the state dirty so the claim persists. Each successful claim * advances the per-instance terminal EPOCH ({@link TerminationContext * .terminalEpoch}), which is stamped on the emitted {@link GraphTerminalEvent}; * the re-open paths (`retryNode` / `resetTerminalDedupe`) bump it again, so a * notification consumer can tell a genuine re-completion after a retry apart * from an idempotent replay of the same terminal event (Y26). * Because only the * per-instance context is reset on re-open (retry / extend), this module also * reconciles the persisted layer: a graph that demonstrably has * scheduler-active (`running` / `ready`) nodes while a terminal flag is * recorded has been re-opened, so its next quiescence is a NEW legitimate * terminal event and the stale cross-restart guard is cleared (mirroring the * context reset). The quiescent re-fire signature of a genuinely fresh * instance over a persisted terminal state — complete or blocked — keeps its * suppression, so F15 is preserved. * * Design reference: `.rolebox/design/engine-state-machine.md` §3.3. */ import type { EngineState } from "../../types.engine-v2.ts"; /** * The terminal-notification seam (contract C5). * * A consumer may be synchronous or async: real notifiers * (`graph-notify.ts::createGraphTerminalNotifier`) return `Promise`. * Typing the seam as `=> void` forced the engine to re-discover the promise * with a runtime duck-type assertion; `void | Promise` states the * contract the consumers actually implement. The engine never awaits the * result — a returned promise is observed only to contain its rejection (see * `fireGraphTerminal`). */ export type GraphTerminalNotifier = (event: GraphTerminalEvent) => void | Promise; /** * A graph-terminal event emitted via the optional onGraphTerminal callback * seam. The engine stays role-agnostic — it packages only the immutable facts; * notification / delivery is the consumer's concern and never lives in the * engine. * * Emitted exactly once per terminal transition per event type (complete / * blocked). A blocked fire followed later by approval-resume and eventual * completion MAY fire the complete event — blocked and complete are separate * dedupe guards. */ export interface GraphTerminalEvent { /** Owning graph id. */ graphId: string; /** * Terminal-notification epoch (Y26), owned by the engine. * * `0` until this engine instance claims its first terminal event; the counter * is incremented on every successful claim ({@link fireGraphTerminal}) and on * every re-open (`AdvanceEngine.retryNode` / `resetTerminalDedupe`), then * stamped on the event. A notification consumer MUST fold it into its dedupe * key: same graphId + terminal type + epoch is an idempotent replay, while a * new epoch is a genuine terminal transition (e.g. the second legitimate * `[GRAPH COMPLETE]` after a retry) that must not be dropped. */ terminalEpoch: number; /** The graph's engine phase at emission time. */ phase: string; /** Counts of nodes in each terminal / notable status at emission time. */ nodeStatusSummaries: { completed: number; /** * Nodes flipped to the terminal `done` state (escalate/timeout/cancelled/ * completed → done). Kept separate from `completed` (monitor-audit M4) so * a failed/cancelled-then-retired node is never reported as a success. */ done: number; /** Nodes cancelled (`cancelled` → `done` predecessors). Visible in the * summary so a cancelled graph is not reported as a clean completion * (monitor-audit L17). */ cancelled: number; escalate: number; timeout: number; blocked: number; running: number; }; /** True when the graph is quiescent-blocked (no active nodes, ≥1 blocked). */ isBlocked: boolean; } /** * Mutable dedupe context for terminal events. Each terminal type (complete / * blocked) fires at most once per engine instance via these flags. Reset when * the graph is re-opened (e.g. `retryNode`). * * This is the per-instance half of the two-layer terminal dedupe (M10); the * other half is the persisted `EngineState.terminalNotified` flag, which * survives engine rebuilds / restarts. `fireGraphTerminal` claims BOTH layers * on a fire and refuses to fire while either is claimed; `checkGraphTermination` * reconciles the persisted layer on re-open (see module header). */ export interface TerminationContext { terminalComplete: boolean; terminalBlocked: boolean; /** * Terminal-notification epoch (Y26). Incremented on every successful claim in * {@link fireGraphTerminal} and on every re-open — `AdvanceEngine.retryNode` * and `AdvanceEngine.resetTerminalDedupe` both bump it while clearing the two * one-shot flags — and stamped on the emitted {@link GraphTerminalEvent}. * * Because a re-open clears `terminalComplete` / `terminalBlocked` and the * persisted `EngineState.terminalNotified`, the next terminal transition is a * NEW epoch. A notifier instance reused across the re-open keys its dedupe on * the epoch, so the retried chain's legitimate terminal notification is not * mistaken for a replay of the pre-retry one. */ terminalEpoch: number; } /** * Check whether the graph has reached a terminal state and advance the phase * / fire the terminal callback accordingly. * * - When no active node remains (no running, ready, pending, or blocked), * transitions `executing → complete` and fires `onGraphTerminal` with * `isBlocked=false`. * - When no scheduler-active node remains (no running, ready, pending) but * ≥1 blocked node exists, fires `onGraphTerminal` with `isBlocked=true` * WITHOUT a phase transition (graph stays `executing`, waiting on human). * * Both terminal-event types use separate dedupe guards — the per-instance * `ctx` AND the persisted `state.terminalNotified` flag (M10 two-layer * exact-once; see module header) — each fires at most once per guard epoch. * * When the runtime deadlock guard synthetically escalates pending node(s), * `onSyntheticEscalate` is invoked once per escalated node with * `(nodeId, reason)` so the caller (e.g. a monitor / notification layer) can * surface the synthetic escalation instead of it being silent (monitor-audit * M1). A no-op when not supplied. * * The optional `isPendingDeadEnded` predicate relaxes the runtime deadlock * guard (F3): a pending node is "dead-ended" when every one of its incoming * edges is provably unable to activate — a never-true `on_condition` edge, an * `on_signal` edge whose filter excludes the source's recorded terminating * signal once the source is terminal, or an edge sourced from a cancelled * node. When supplied and it returns true for EVERY pending node, the guard * fires even when an escalated/timed-out node is present; without this such a * graph would hang in `executing` forever (the strict * `counts.escalate === 0 && counts.timeout === 0` activation is preserved). * The predicate must be a PURE state reader — the AdvanceEngine builds it * from its edge topology + conditionResolver and never mutates state. * * A throwing consumer must not corrupt the advancing critical section * (mirrors _notifyCompletion conventions). A no-op when no callback is * registered. */ export declare function checkGraphTermination(state: EngineState, onGraphTerminal: GraphTerminalNotifier | undefined, ctx: TerminationContext, onSyntheticEscalate?: (nodeId: string, reason: string) => void, isPendingDeadEnded?: (nodeId: string) => boolean): void; //# sourceMappingURL=engine-termination.d.ts.map