/** * `runBatch` — the single concurrent-interrupt primitive. * * Owns the boilerplate that every concurrent-interrupt site in the runtime * (`runForkAll`, `runRace`, `runPrompt`'s tool loop, the multi-callback hook * fire) used to hand-roll: per-child branch creation, abort-signal * composition, settle/collect, batch checkpoint stamping with shared * `intr.checkpoint`/`intr.checkpointId` overwrite, cost seed/propagate * hooks, cleanup on success. * * What `runBatch` does NOT change: * - The leaf `interruptReturn` template still stamps a per-leaf checkpoint * exactly as today (commit c72b9c1574 deliberately removed the older * `isForked`-bypass approach because it broke nested-fork composition). * `runBatch` reads the leaf's checkpoint off each surfaced `Interrupt` * and writes it onto the BranchState via `setInterruptOnBranch` — the * leaf's checkpoint is the vehicle that carries its pre-pop stack into * `State.toJSON`'s branches walk. * - Handler bookkeeping. Per-branch handler chains stay as today. * * The `intr.checkpoint` overwrite at the bottom is intentional: per commit * c72b9c1574, every interrupt batched together must share a single resume * point so resuming one resumes the whole batch. * * Invoke no-throw contract (audit when migrating call sites): every * `BatchChild.invoke` MUST RETURN `T | Interrupt[]` and never THROW an * `Interrupt[]`. Other JS errors may be thrown — in that case `runBatch` * rethrows the first one it sees and abandons any interrupts that sibling * branches successfully halted with (this matches today's * `runForkAll` / `runRace` behavior; callers that need both must catch * inside `invoke`). PromptBailout-style throws need to be converted to * returns at their call site before that site migrates to `runBatch`. * * Audit findings (2026-05-22, recorded at Task 4 step 1): * - `lib/runtime/interrupts.ts` (`interruptWithHandlers`, * `respondToInterrupts`, `runHandlerChain`): NO `Interrupt[]` throws. * Errors thrown are `Error`s on contract violations or other * bookkeeping bugs, never the interrupt array itself. * - `lib/runtime/agencyFunction.ts` (`AgencyFunction.invoke`): NO * `Interrupt[]` throws. Returns interrupts as values. * - `lib/runtime/prompt.ts` (`runInvokeStep` and the tool loop body): * NO `Interrupt[]` throws. Returns via `b.step`'s collector pattern. * - `lib/runtime/promptRunner.ts`: `PromptBailout` IS thrown (twice). * These are deliberate Error subclasses (not raw `Interrupt[]`) and * are converted to returns in Task 4 before migration. * * Other adopters considering migration should re-run this audit on their * code path (`grep -nE "throw .*[Ii]nterrupt"`). * * Branch-result recording (see `recordBranchOutcomes`): * - Default `true`: runBatch records the per-branch outcome via * `setResultOnBranch` (success) or `setInterruptOnBranch` (interrupt). * This is what fork/race need. * - `false`: the caller's `invoke` is responsible for recording branch * state itself (via `stack.setResultOnBranch` etc. inside the body). * runBatch still stamps the shared checkpoint and overwrites * `intr.checkpoint`/`checkpointId`, but does NOT touch BranchState * fields. This is what runPrompt's tool loop needs because the tool * body already records the real tool result on the branch before * returning — letting runBatch then call `setResultOnBranch(key, * undefined)` would overwrite the meaningful value with undefined. */ import { type Run } from "./asyncContext.js"; import type { DecisionCollector } from "./decision/collector.js"; import { type Interrupt } from "./interrupts.js"; import type { RuntimeContext } from "./state/context.js"; import type { BranchState, State, StateStack } from "./state/stateStack.js"; export type BatchChild = { /** Stable per-child key. Used for `getOrCreateBranch`. Caller is * responsible for uniqueness within `parentFrame.branches`. */ key: string; /** Invoked with the child's own `StateStack` (already seeded with abort * signal composed with parent) and that stack's abort signal. Must * return either a value `T` (success) or an `Interrupt[]` (halted with * interrupts). MUST NOT throw `Interrupt[]`. May throw other errors. */ /** `run` is the branch's own run: its stack, its thread view, and its * copy of the globals. */ invoke: (run: Run, childStack: StateStack, abortSignal: AbortSignal) => Promise; }; export type BatchHooks = { seedBranchCost?: (childStack: StateStack, parentStack: StateStack) => void; /** Used by mode "all" / "sequential" only. The race adapter uses the * asymmetric pair below instead. */ propagateBranchCost?: (branches: BranchState[], parentStack: StateStack) => void; /** Race mode only: propagate loser-branch cost at race-time (before the * losers are deleted). The winner's cost propagates separately on resume * via `propagateWinnerCost`. */ propagateLoserCost?: (loserBranches: BranchState[], parentStack: StateStack) => void; /** Race mode only: propagate the winner's cost when the winner finally * completes (no-interrupt resume). */ propagateWinnerCost?: (winnerBranch: BranchState, parentStack: StateStack) => void; /** Called once per branch start (statelog). */ onBranchStart?: (key: string, index: number) => void; /** Called the moment one branch's body settles, fulfilled or rejected, * and at once for a cached branch that never runs. Unlike * `onBranchEnd`, this fires before the join, which a branch waiting * on a sibling would otherwise hold up forever. */ onBranchSettled?: (key: string, index: number) => void; /** Called once per branch end with its outcome and elapsed time in ms. * On a `success` outcome `value` is the branch's return value; for * every other outcome it is `undefined` (there is no value to report). */ onBranchEnd?: (key: string, index: number, outcome: "success" | "interrupted" | "failure" | "aborted", timeMs: number, value?: unknown) => void; /** Called once immediately before `ctx.checkpoints.create` deep-clones * the parent frame. Use this to flush state that was mutated by * sibling branches during the batch into frame-backed locals that need * to survive the checkpoint. Concretely: runPrompt's tool loop pushes * `tool` messages onto the shared `MessageThread` from inside each * branch's body, but `self.messagesJSON` (the snapshot the checkpoint * captures) isn't refreshed by those pushes — the adapter uses this * hook to do `self.messagesJSON = snapshotMessages()` so the * deep-clone sees the up-to-date thread. Without this hook the * checkpoint would carry the pre-batch messages and successful * sibling tool responses would be silently lost on resume. */ beforeCheckpoint?: () => void; /** Called once when the batch stamps its shared checkpoint, with the * interrupts it was stamped for. */ onCheckpoint?: (checkpointId: number, interrupts: Interrupt[]) => void; }; export type RunBatchOpts = { /** The run the batch was started under. Each branch gets a child of it. */ run: Run; ctx: RuntimeContext; /** The parent's local state stack — used as the capture stack for the * shared batch-level checkpoint. MUST be the local slice (e.g. the * branch stack if `runBatch` is itself called inside a child of an * outer `runBatch`), NOT `ctx.stateStack`. This is the one discipline * the caller of `runBatch` must observe. */ parentStack: StateStack; /** The frame where branch state lives. Usually `parentStack.lastFrame()`. */ parentFrame: State; /** Where the shared checkpoint records its location. Same fields the * existing call sites pass to `ctx.checkpoints.create`. */ checkpointLocation: { moduleId: string; scopeName: string; stepPath: string; }; /** "all" → Promise.allSettled, concurrent; "sequential" → for...of, * each child after the previous (today's callHook semantics); "race" * → first to settle wins, others are aborted. * * IMPORTANT: do not use "all" for hook-callback batching — that would * change today's strictly-sequential `callHook` ordering. Use * "sequential". */ mode: "all" | "sequential" | "race"; children: BatchChild[]; /** Mode "race" only: the `parentFrame.locals` key under which the winner * index is persisted. The caller (race adapter) computes * `__race_winner_${id}` and passes it. */ raceWinnerLocalKey?: string; /** When `true` (default), runBatch records the per-branch outcome on * `parentFrame.branches` via `setResultOnBranch` (success) or * `setInterruptOnBranch` (interrupt). When `false`, the caller's * `invoke` is responsible for managing branch state itself — runBatch * still stamps the shared checkpoint and overwrites * `intr.checkpoint`/`checkpointId`, but does NOT touch BranchState * fields. Used by runPrompt's tool loop where the body manages the * real tool result on the branch. */ recordBranchOutcomes?: boolean; /** When `true`, the branch's ALS frame pointer-shares the parent's * `GlobalStore`. Writes inside the branch land on the parent's store; * siblings see them; the parent observes them after join. User * `fork(..., shared: true)` / `parallel(shared: true)` / * `race(..., shared: true)` opts into this. `runPrompt`'s tool- * dispatch loop also enables it (tool calls are conceptually * sequential function invocations and any global state should * persist across calls). * * When `false` (default), each branch gets a clone of the parent's * `GlobalStore` (via `GlobalStore.clone`). Writes stay branch-local * and are discarded at join. */ shareGlobals?: boolean; /** When `true`, the branch's ALS frame pointer-shares the parent's * `ThreadStore` (and its `activeStack`). Reserved for * implementation-internal batching — most notably `runPrompt`'s * tool-dispatch loop, where multiple tool calls in one LLM round * must all write into the same active thread. * * When `false` (default), the branch gets a `forkBranchView` of the * parent's `ThreadStore` (shared registry, branch-local * `activeStack` seeded with a fresh subthread). User-facing * `shared: true` does NOT set this — concurrent branches pushing/ * popping the same active thread would corrupt the conversation. */ shareThreads?: boolean; /** The block's decision-call collector. Each branch's frame carries it * with the branch's own key as `decisions`. When absent, branches * inherit the outer frame's scope, so a nested batch inside an arm * (a tool-dispatch batch, say) keeps registering under that arm. */ decisionCollector?: DecisionCollector; hooks?: BatchHooks; }; export type RunBatchResult = { kind: "values"; values: T[]; } | { kind: "interrupts"; interrupts: Interrupt[]; }; export declare function runBatch(opts: RunBatchOpts): Promise>;