/** * Graph Engine — Node-Completion Notifier (subtask 2) * * A notifier factory that plugs into the engine's `onNodeCompletion` DI seam * (see {@link AdvanceEngineOptions.onNodeCompletion} in `engine-advance.ts`). * When a graph node reaches a terminating / notable transition, the notifier * injects a `` into the emperor's session so the orchestrator * can perceive node completion. * * Background — why not `notifyParent`: a graph node's dispatch carries no * emperor-scoped {@link DispatchTask}. `graphParentContext` * (`dispatch-bridge.ts`) deliberately places `sessionID = graphId` to scope * request-budget tracking per graph, so `notifyParent` would try to inject into * a nonexistent graph-ID session. This notifier therefore sends directly via * `ISessionClient.prompt(emperorSessionId, …)` and reuses the dispatch * notification *discipline* (markers + per-session send queue + dedupe) rather * than `notifyParent`. * * The reminder uses {@link GRAPH_COMPLETION_MARKER}, which is a member of * {@link DISPATCH_NOTIFICATION_MARKERS}, so the chat.message hook classifies the * injected text as a non-user turn — it must NOT reset the auto-continue counter. * * Dedupe: per notifier-run epoch, keyed by `graphId::nodeId::signalType`, with * the node's `startedAt` folded in so a genuine loop re-entry (fresh `startedAt`) * legally re-notifies while an idempotent replay of the same transition is * dropped. The per-run scope means a freshly created notifier starts a clean * epoch. * * Notification logic never lives in the engine — this is a pure consumer of the * role-agnostic completion seam, exactly like {@link NodeDispatchPort}. */ import type { ISessionClient } from "../../platform/ports/session-client.ts"; import type { NodeCompletionEvent, GraphTerminalEvent } from "./engine-advance.ts"; import type { NodeStallEvent } from "./engine-recovery.ts"; export declare const log: import("tslog").Logger; export interface GraphNotifierOptions { /** * Master switch. Default `true`. When `false`, the notifier is a strict * no-op (opt-out without rewiring the engine seam). */ enabled?: boolean; /** * Emperor session id to inject reminders into. When absent, the notifier is * a no-op (safe default when no orchestrator session is available). */ emperorSessionId?: string; /** Optional agent tag for the injected prompts (forwarded to prompt). */ agent?: string; /** * Max total `client.prompt` attempts before the failure path is taken. * Default `GRAPH_NOTIFY_MAX_ATTEMPTS` (3 = 1 initial + up to 2 retries). * Mirrors `notifyParent`'s bounded-retry discipline — inject a small value * in tests to avoid real backoff sleeps. */ maxAttempts?: number; /** Backoff base delay in ms (delay = `min(baseDelayMs * 2^attempt, maxDelayMs)`). Default 500. */ baseDelayMs?: number; /** Backoff cap in ms. Default 5000. */ maxDelayMs?: number; } export declare const GRAPH_NOTIFY_MAX_ATTEMPTS = 3; export declare const GRAPH_NOTIFY_BASE_DELAY_MS = 500; export declare const GRAPH_NOTIFY_MAX_DELAY_MS = 5000; /** * The notifier seam shape shared by all three factories (contract C5 / Y2). * * A consumer implementation may be synchronous or async, and the engine only * ever observes the return value through thenable isolation * (``) — it never awaits it. Declaring * that explicitly removes the old "declared `=> void`, implemented as * `Promise`" mismatch that forced the engine to re-discover the * promise at runtime with a duck-typed assertion. * * The concrete factories resolve a `boolean` (whether a notification was * actually dispatched) so tests can await them, but the seam type deliberately * does not promise that value: no engine call site consumes it. */ export type GraphNotifyHandler = (event: Event) => void | Promise; /** * Completion seam handler shape (see {@link GraphNotifyHandler}). The factory * resolves `true` when a reminder was dispatched and `false` when it was * suppressed by opt-out / dedupe / a missing session. */ export type GraphCompletionHandler = GraphNotifyHandler; /** * Upper bound on a notifier run's dedupe epoch (Y26). The completion key * carries a per-execution `startedAt`, so a long-lived notifier observing a * long-running loop would otherwise grow its `notified` set without bound. * Once the limit is crossed the OLDEST key is evicted (a `Set` preserves * insertion order), which can only matter for a replay of a very old event — * the epoch is a best-effort idempotence guard, not a durable ledger. */ export declare const GRAPH_NOTIFY_DEDUPE_LIMIT = 512; /** * Format an epoch-ms duration as a compact human-readable string. * * The `undefined` guard stays local; the rendering is a one-line delegation to * `formatDuration(…, "stall")`. Negative clock skew (a `completedAt` before * `startedAt`) now renders the ? sentinel like every other invalid stall input. */ export declare function formatGraphDuration(startedAt?: number, completedAt?: number): string; /** * Build the `` text for a completed graph node via * {@link buildReminder}. Contains graph id, node id, agent, status, signal, * and duration. */ export declare function buildGraphCompletionText(event: NodeCompletionEvent): string; /** * Create a graph node-completion notifier wired to the `onNodeCompletion` seam. * * ``` * const engine = createEngine(decl, graphId, { * ...opts, * onNodeCompletion: createGraphNotifier(sessionClient, { * emperorSessionId: "emperor-session-id", * }), * }); * ``` * * @returns a handler that is a no-op (resolves `false`) when disabled or when * no emperor session is configured, drops idempotent replays (resolves * `false`), and otherwise enqueues the reminder to the emperor session via * the per-session serialized send queue (resolves the queue's result). * `client.prompt` failures are retried with bounded exponential backoff * (max `maxAttempts` total attempts); only exhaustion resolves `false`. */ export declare function createGraphNotifier(client: ISessionClient, opts?: GraphNotifierOptions): GraphCompletionHandler; /** * Graph-terminal seam handler shape (see {@link GraphNotifyHandler}). The * factory resolves `true` when a reminder was dispatched and `false` when it * was suppressed. */ export type GraphTerminalHandler = GraphNotifyHandler; /** * Build the `` text for a graph-terminal event via * {@link buildReminder}. * * Uses {@link GRAPH_COMPLETE_MARKER} when the graph reached COMPLETE phase, and * {@link GRAPH_BLOCKED_MARKER} when the graph is quiescent-blocked. Both markers * are members of {@link DISPATCH_NOTIFICATION_MARKERS} so the chat.message hook * classifies them as non-user turns. * * Content: graph id, phase, compact node-status summary (zero counts omitted * except completed always present), and next-step actions. * BLOCKED adds full approval instruction set as multi-line action. */ export declare function buildGraphTerminalText(event: GraphTerminalEvent): string; /** * Build the `` text for a blocked gate that is being * propagated UP the session chain from a nested graph to the outermost live * (orchestrator) session. * * Unlike {@link buildGraphTerminalText} — which targets the graph's own * invoking session — this reminder names the graph, the blocked * `needs_approval` node(s), AND the parent session chain, so the human can see * WHICH nested graph is waiting and then approve it from the top level. It uses * {@link GRAPH_BLOCKED_MARKER} (a member of * {@link DISPATCH_NOTIFICATION_MARKERS}), so the re-entering chat.message hook * still classifies it as a non-user turn. * * @param args.graphId The blocked (nested) graph. * @param args.phase The graph's engine phase at emission time. * @param args.blockedNodeIds The `needs_approval` node ids currently blocked. * @param args.chain Ordered session chain from the graph's invoking * session to the outermost live session. */ export declare function buildPropagatedBlockedText(args: { graphId: string; phase: string; blockedNodeIds: string[]; chain: string[]; }): string; /** * Create a graph-terminal notifier wired to the engine's `onGraphTerminal` seam. * * Follows the same session-client injection + dedupe + failure-logging pattern * as {@link createGraphNotifier}, EXCEPT it injects with `noReply: false` so the * terminal reminder wakes the orchestrator (per-node completions stay silent). * Dedupe is per terminal type (complete / blocked) * per graph, per engine terminal epoch (Y26) — a blocked-then-resumed-then-completed * graph fires two distinct messages, and a retry / re-open that legitimately * reaches a terminal phase again re-notifies (new epoch), while a second * idempotent fire of the same type within one epoch is dropped. Per-loop-graph * re-entry (separate `graph_run`) creates a fresh notifier with a clean dedupe * epoch. * * @returns a handler that is a no-op when disabled (silent), a no-op that logs * an explicit warning when no emperor session is configured (F6), drops * idempotent replays, and otherwise enqueues the reminder (`client.prompt` * failures retried with bounded exponential backoff, max `maxAttempts` total * attempts). */ export declare function createGraphTerminalNotifier(client: ISessionClient, opts?: GraphNotifierOptions): GraphTerminalHandler; /** * Node-stall seam handler shape (see {@link GraphNotifyHandler}). The factory * resolves `true` when a reminder was dispatched and `false` when it was * suppressed by opt-out / dedupe / a missing session. */ export type GraphStallHandler = GraphNotifyHandler; /** * Format an idle duration (ms) as a compact human-readable string — the same * style as {@link formatGraphDuration}: `<60s → "X.Xs"`, else `"Xm Ys"`; * a negative value (clock skew) renders the ? sentinel. * * One-line delegation to `formatDuration(…, "stall")`. */ export declare function formatStallIdle(idleMs: number): string; /** * Build the `` text for a stalling graph node via * {@link buildReminder}. Contains graph id, node id, agent, idle duration, and * the warn threshold. Uses {@link GRAPH_STALL_MARKER}, a member of * {@link DISPATCH_NOTIFICATION_MARKERS}, so the chat.message hook classifies * the injected text as a non-user turn. */ export declare function buildGraphStallText(event: NodeStallEvent): string; /** * Create a graph node-stall notifier wired to the engine's `onNodeStall` seam. * * Follows the same session-client injection + dedupe + bounded-retry pattern as * {@link createGraphNotifier}: the dedupe key is claimed BEFORE the first * attempt, failures retry with bounded exponential backoff (max `maxAttempts` * total attempts, `GRAPH_NOTIFY_MAX_ATTEMPTS` / `GRAPH_NOTIFY_BASE_DELAY_MS` / * `GRAPH_NOTIFY_MAX_DELAY_MS`), and only exhaustion resolves `false` after a * warn log naming the graph::node. Unlike the terminal notifier it injects * with `noReply: true` — a stall is informational and silent (the orchestrator * observes it without being woken), exactly like per-node completion. * * @returns a handler that is a no-op (resolves `false`) when disabled or when * no emperor session is configured, drops idempotent replays of the same * stall episode (resolves `false`), and otherwise enqueues the reminder to * the emperor session via the per-session serialized send queue. */ export declare function createGraphStallNotifier(client: ISessionClient, opts?: GraphNotifierOptions): GraphStallHandler; //# sourceMappingURL=graph-notify.d.ts.map