/** * IPC-mode interrupt handling for subprocess execution. * When AGENCY_IPC=1 is set, interrupts are sent to the parent process * over Node's built-in IPC channel instead of being returned as Interrupt[]. */ import { fork } from "child_process"; import type { ForkOptions } from "child_process"; import type { AgencyConfig } from "../config.js"; import { type HandlerChainOutcome } from "./interrupts.js"; import type { State } from "./state/stateStack.js"; import { getSubprocessRunInfo, setSubprocessRunInfo, isIpcMode, type SubprocessRunInfo } from "./subprocessRunInfo.js"; import { type IpcTelemetryMessage } from "./costTelemetry.js"; import { type IpcCallbackMessage } from "./callbackForwarding.js"; export { isIpcMode }; export { getSubprocessRunInfo, setSubprocessRunInfo, type SubprocessRunInfo }; import { type LockRelease, type WithLockOptions } from "./lock.js"; import { type ResultFailure } from "./result.js"; /** Resolved filesystem path to the subprocess bootstrap script. */ export declare const subprocessBootstrapPath: string; export declare const DEFAULT_MAX_SUBPROCESS_DEPTH = 5; export declare const SUBPROCESS_DEPTH_CEILING = 10; /** The effective depth cap for a run() call: the caller's maxDepth param, * tightened by the tightest ancestor cap (inherited via the run/resume * instruction) and the hard ceiling. */ export declare function resolveDepthCap(paramMaxDepth: number): number; export type RunLimits = { wallClock: number; memory: number; ipcPayload: number; stdout: number; }; export declare function clampLimits(input: RunLimits): RunLimits; /** * Build a structured limit-exceeded Result.failure that the Agency-side * `try _run(...)` will pass through (without converting to an Error string). * `limit` is the canonical name (e.g. "wall_clock", "memory", "ipc_payload", * "stdout"). `threshold` is the configured cap; `value` is what was observed * when the violation was detected. `extras` carries optional fields like * `samplePrefix` for IPC payload violations. */ export declare function makeLimitFailure(limit: string, threshold: number, value: number, extras?: Record): ResultFailure; export declare function setSubprocessIpcPayloadLimit(limit: number): void; export declare function ipcLog(direction: "send" | "recv", msg: any): void; export type IpcInterruptMessage = { type: "interrupt"; /** The child's interrupt-level id, preserved verbatim end-to-end: it keys * the decision reply and both processes' statelog events, and — when the * interrupt ultimately surfaces to the user — the resume response. */ interruptId: string; interrupt: { effect: string; message: string; data: any; origin: string; expectsValue?: boolean; }; }; export type IpcResultMessage = { type: "result"; value: any; }; /** An interrupt as it travels over IPC: the per-interrupt checkpoint fields * are stripped — the batch-level checkpoint travels once, at the message * level (see IpcInterruptedMessage). */ export type SerializedInterrupt = { type: "interrupt"; interruptId: string; runId: string; effect: string; message: string; data: any; origin: string; }; /** Terminal message for a child that paused itself: its unresolved * interrupts plus the shared checkpoint they all resume from. A third * terminal outcome alongside `result` and `error`. */ export type IpcInterruptedMessage = { type: "interrupted"; interrupts: SerializedInterrupt[]; checkpoint: any; subprocessSessionId: string; }; /** Convert a child's final Interrupt[] into the `interrupted` terminal * message: strip each interrupt's checkpoint fields and hoist the shared * batch checkpoint (every interrupt in a batch carries the same one). */ export declare function serializeInterruptsForIpc(interrupts: any[]): IpcInterruptedMessage; export type IpcErrorMessage = { type: "error"; error: string; }; /** The parent's reply to a relayed interrupt: its handler chain OUTCOME, * not a verdict. The child merges this with its own local outcome and * decides (see `mergeChainOutcomes` in interrupts.ts). */ export type IpcDecisionMessage = { type: "decision"; interruptId: string; outcome: HandlerChainOutcome; }; export type IpcLockAcquireMessage = { type: "lockAcquire"; requestId: string; name: string; ownerId?: string; timeoutMs?: number; warnAfterMs?: number; }; export type IpcLockGrantedMessage = { type: "lockGranted"; requestId: string; error?: string; }; export type IpcLockReleaseMessage = { type: "lockRelease"; requestId: string; name: string; ownerId?: string; }; export type SubprocessToParent = IpcInterruptMessage | IpcResultMessage | IpcInterruptedMessage | IpcErrorMessage | IpcLockAcquireMessage | IpcLockReleaseMessage | IpcTelemetryMessage | IpcCallbackMessage; export type ParentToSubprocess = IpcDecisionMessage | IpcLockGrantedMessage; export declare function registerSessionLock(ctx: { lockReleasers: Record; }, sessionId: string, releaserKey: string, release: LockRelease): void; export declare function cleanupSessionLocks(ctx: { lockReleasers: Record; }, sessionId: string): void; /** * Send an interrupt to the parent process and await the parent's handler * chain OUTCOME (not a verdict — the child merges and decides). The parent * always replies explicitly; the child never infers from silence. * `interruptId` is the child's interrupt-level id, used verbatim as the * message id so decision routing and statelog correlation share one key. */ export declare function sendInterruptToParent(interruptData: { effect: string; message: string; data: any; origin: string; expectsValue?: boolean; }, interruptId: string): Promise; export declare function sendLockAcquireToParent(name: string, opts?: WithLockOptions): Promise; /** * Mutable per-call state shared by all the subprocess event handlers. * Bundled into one object so helpers can be extracted to module scope * without each one needing 8 closure parameters. */ export type RunSession = { sessionId: string; child: ReturnType; limits: RunLimits; ctx: any; stateStack: any; /** The parent's full ALS store frame captured at run() time. Forwarded * callbacks (handleCallbackMessage) fire from the event-loop message handler, * OUTSIDE any agencyStore frame; re-establishing this frame lets an * AgencyFunction callback body resolve __globals()/__threads() against the * parent's real globals, exactly as an in-process callback would. */ parentStore?: any; resolvePromise: (v: SessionOutcome) => void; rejectPromise: (v: any) => void; settled: boolean; startedAt: number; wallClockTimer: NodeJS.Timeout | null; stdoutBytes: number; stoppedForwarding: boolean; /** Detaches the abort listener from the (possibly long-lived) parent * signal. Run at settle: a composed AbortSignal pins its unremoved * listeners — and everything they close over (session, ctx, child) — * for the lifetime of the parent signal, leaking once per execution * segment under fork/race or a TimeGuard. */ detachAbortListener: (() => void) | null; }; /** How one subprocess execution segment ended: a value (including limit * failures, which are ordinary Result failures), or a self-checkpointed * pause. Session errors reject instead. */ type SessionOutcome = { type: "result"; value: any; } | { type: "interrupted"; msg: IpcInterruptedMessage; }; /** Everything `_run` persists across a pause so the replayed call can * re-fork and resume: the child's checkpoint (OPAQUE — its frames belong * to another process and must never be spliced into the parent's replay), * the surfaced interrupts in order (their ids key the user's responses), * and the node to resume. */ export type SubprocessResumePayload = { childCheckpoint: any; interrupts: SerializedInterrupt[]; node: string; subprocessSessionId: string; }; export declare function loadSubprocessPayload(frame: State): SubprocessResumePayload | undefined; /** Identity the child adopts, carried on both startup instructions: the * parent's runId (one trace across processes and pause/resume cycles), a * stable per-logical-child session id, the parent's subprocessRun span id * for span nesting, the child's nesting depth, and the tightest ancestor * depth cap. */ type SubprocessIdentity = { runId?: string; subprocessSessionId?: string; spanContext?: string; depth?: number; maxDepth?: number; }; export type RunInstruction = SubprocessIdentity & { type: "run"; scriptPath: string; node: string; args: Record; ipcPayload?: number; configOverrides?: Partial; }; export declare function buildRunInstruction(args: { scriptPath: string; node: string; args: Record; limits: RunLimits; configOverrides?: Partial; identity?: SubprocessIdentity; }): RunInstruction; /** Startup instruction for resuming a previously-paused subprocess: the * child restores the checkpoint, pairs `interrupts` with `responses` * positionally (via the compiled module's own respondToInterrupts export), * and continues from exactly where it left off. */ export type ResumeInstruction = SubprocessIdentity & { type: "resume"; scriptPath: string; node: string; checkpoint: any; interrupts: SerializedInterrupt[]; responses: any[]; ipcPayload?: number; configOverrides?: Partial; }; export declare function buildResumeInstruction(args: { scriptPath: string; saved: SubprocessResumePayload; responses: any[]; limits: RunLimits; configOverrides?: Partial; identity?: SubprocessIdentity; }): ResumeInstruction; /** Pull the user's responses for this subprocess's pending interrupts, in * the exact order of the saved interrupts array (the child pairs them * positionally). Note: `ctx.getInterruptResponse` returns the response * ALREADY UNWRAPPED (context.ts does `?.response` internally). */ export declare function collectSubprocessResponses(ctx: any, saved: SubprocessResumePayload): any[]; export declare function buildForkOptions(args: { limits: RunLimits; cwd?: string; }): ForkOptions; /** Write the compiled JS to a fresh .agency-tmp// dir (under cwd so * Node resolves agency-lang package imports via the project's node_modules) * and return the script path. Called at every fork — initial run and resume * alike — and paired with cleanupTempDir when the session settles. */ export declare function materializeCompiledScript(compiled: { moduleId: string; code: string; }): string; /** Child (or descendant, via relay) reported a paid call. Bill it to the * run() call-site stack via the same billCharge every in-process paid * site uses: localCost (parent getCost() reflects child spend live) plus * the guards — and billCharge re-emits upward when THIS process is * itself a subprocess, which is what relays grandchild spend to the root * with no explicit plumbing. Billing is unconditional (the spend already * happened, even post-settle); enforcement only runs on a live session: * a trip kills the child and REJECTS the session with the guard-trip * abort, which propagates through invokeSubprocess → runBatch (errors * win over interrupts) → the stdlib run() plain `try` re-throws trips → * the user's owning guard(cost:) boundary converts it to the standard * cost-limit Failure. * * MUST STAY SYNCHRONOUS: handleChildMessage void-invokes its async * dispatch, so arrival-order processing (all telemetry before the * child's own terminal message, per IPC FIFO) holds only while this * path contains no awaits — an await before enforcement would let a * fast child's result settle the session before the trip fires. * * Known getCost() edge: post-settle billing (possible only on kill * paths — FIFO rules it out on normal completion) charges the shared * guard REFERENCES correctly, but the localCost increment can land * after a fork branch's cost delta has already propagated at join, so * getCost() may slightly undercount on abnormal termination. Budgets * never undercount; do not "fix" this by skipping post-settle billing. */ export declare function handleTelemetryMessage(s: RunSession, msg: IpcTelemetryMessage): void; /** * Fire the PARENT's registered callbacks for an event forwarded from a child. * * MUST STAY SYNCHRONOUS (like handleTelemetryMessage): handleChildMessage * void-invokes its async dispatch, so FIFO arrival-order processing holds only * while this path has no awaits. Parent callbacks fire fire-and-forget (void * invokeCallbacks) so a slow or throwing parent callback cannot wedge the pump. * * invokeCallbacks re-emits upward when THIS process is itself a subprocess, so a * grandchild's event relays to the root with no explicit relay code. * * Fires on the parent's FULL stack (ctx.stateStack), NOT the run() call-site * slice s.stateStack: a parent callback registered on an ancestor frame (e.g. a * node-level `callback("onNodeStart")` above the run() call) is only reachable by * walking the full stack, exactly as an in-process event does via callHook (which * omits stateStack and defaults to ctx.stateStack). Passing s.stateStack here * would silently miss those ancestor-frame callbacks. */ export declare function handleCallbackMessage(s: RunSession, msg: IpcCallbackMessage): void; export declare function handleChildMessage(s: RunSession, msg: any): Promise; /** * Wire up all event handlers on the child process and kick off execution by * sending the (prebuilt) startup instruction over IPC. * Exported for unit tests: the per-segment wall-clock property (timer armed * per session, cleared at settle, never firing across a pause) is pinned * with fake timers here rather than by a timing-arithmetic execution test. */ export declare function attachSessionHandlers(s: RunSession, instruction: RunInstruction | ResumeInstruction): void; /** * Fork a compiled Agency program as a subprocess and manage the IPC protocol. * A runBatch adopter with a single child: relays subprocess interrupts * through the parent's handler chain, and — when the child pauses itself — * surfaces the child's interrupts to this process's caller with a * parent-side shared checkpoint stamped by runBatch. */ export declare function _run(compiled: { moduleId: string; code: string; }, node: string, args: Record, wallClock: number, memory: number, ipcPayload: number, stdout: number, configOverrides?: Partial, cwd?: string, maxDepth?: number): Promise; /** * Forward the parent's statelog sink to a subprocess so child events land in * the SAME log the parent writes — nested under the parent's subprocessRun * span via the inherited runId + adopted span root. Without this, a child * compiled at runtime has observability baked OFF and its execution is * invisible in the parent's logs. * * Precedence: an explicit child logFile from the caller (run(logFile:)) * always wins; a parent with observability disabled forwards nothing. The * parent's logFile is absolutized against the parent's cwd so a child * launched with a different `cwd` still appends to the same file. */ export declare function withParentStatelog(overrides: Partial | undefined, parentConfig: { observability?: boolean; logFile?: string; }): Partial | undefined; /** * Merge the parent's provider-module paths into the config overrides sent to a * subprocess, resolving relative paths to absolute against the parent's cwd so * they still resolve in a child launched with a different `cwd`. Returns the * overrides unchanged when the parent has no configured provider modules. */ export declare function withParentProviderModules(overrides: Partial | undefined, parentModules: string[]): Partial | undefined;