/** * awaiter. * * Pre-S4, `awaitClaudeCodeBatch` and `pollDispatchRecords` were two * near-identical poll loops with two subtle differences: * - awaitClaudeCodeBatch clamped with `Math.min(deadline, 120_000)` * - pollDispatchRecords clamped with * `Math.min(Math.max(deadline, 0), 120_000)` * and different default-fallback sources. The PRD R4 records the * resulting failure shape: a fix applied to one path did not * propagate to the other (per * .peaks/memory/2026-07-26-peaks-code-concurrent-subagent-coordination.md). * * This file is the single implementation. Both `awaitClaudeCodeBatch` * and `pollDispatchRecords` (kept exported for back-compat with the * existing tests) thin-wrap this. * * S4 also closes the silent-timeout return: * - the result shape now includes a typed `outcome` field that is * one of 'completed' | 'timed-out' | 'clamped' | 'no-progress'. * A caller that asks for `timeoutMs: 600_000` and the loop clamps * to the hard cap sees `outcome: 'clamped'` and the requested / * effective budgets on the result. * - the loop no longer returns a success-shaped result on full * timeout. A timed-out slot still appears in the per-dispatch * results array, but the *batch* outcome is `timed-out`. * - the no-progress watchdog (a new option) raises the `no-progress` * outcome before the full deadline elapses if a bounded window * passes with no observable progress (e.g. a slot's `lastBeatAt` * has not advanced). * * The de-escalation flag is `PEAKS_DISPATCH_DISABLE_FAILFAST` (read * from process.env at module load). When set, the loop falls back to * the pre-S4 silent return so a stuck session can be unblocked by * flipping the env var rather than rebuilding. * * Slice `strict-remediation-abc` (C wave 1, leaf `c1w1-await-batch`): the * per-slot half of the loop — record reader, slot table, one-tick observation * pass, no-progress predicate, poll sleep, result/note rendering — moved * VERBATIM into `./await-batch-helpers.ts`. `awaitBatch` was 155 lines at * complexity 40, so it held a `max-lines-per-function` error AND a `complexity` * warning; that pairing is why no declaration hoist could shorten this file. * The bodies did not change. `awaitBatch` keeps this export path because * `tests/unit/services/dispatch/sub-agent-dispatcher-timeouts.test.ts` stubs * `~/src/services/dispatch/await-batch.js` by path; the budget and outcome * rules stay here because they are what the public types above describe. */ import { type AwaitBatchSlotResult } from './await-batch-helpers.js'; export type AwaitBatchOutcome = 'completed' | 'timed-out' | 'clamped' | 'no-progress'; export interface AwaitBatchOptions { /** Caller-supplied per-IDE default when no timeoutMs is provided. */ readonly defaultTimeoutMs: number; /** Hard cap on the effective wait, in ms. Default: 120_000. */ readonly hardCapMs?: number; /** Optional per-IDE label written into result `note` for attribution. */ readonly notePrefix?: string; /** * No-progress watchdog. If a slot's progress is unchanged for * `noProgressMs`, the batch escalates with outcome `no-progress` * (and the slot's status is left as the caller would observe at * that moment — typically still `running` or `queued`). */ readonly noProgressMs?: number; /** * Test seam: override Date.now() and setTimeout's clock. Production * callers leave this undefined. */ readonly now?: () => number; /** * Test seam: override setTimeout / setInterval. Production callers * leave this undefined. */ readonly schedule?: (cb: () => void, ms: number) => void; /** * Test seam: how to read a record's status. Production callers * leave this undefined (the default reads `status` from the file). * Returning a plain string is treated as the record's status; the * default reader also exposes the `outcome` field via the optional * second tuple element (see `defaultReadOutcome`). * * Test-only escape hatch: callers that need to return both status * and outcome can implement `readRecord` instead. */ readonly readOutcome?: (recordPath: string) => string | null; /** * Test seam: read a record's status AND outcome together. When * defined, this overrides `readOutcome`. Implementations should * return `{ status: null, outcome: null }` when the file is missing * or unreadable, and `{ status }` / `{ status, outcome }` otherwise. */ readonly readRecord?: (recordPath: string) => { status: string | null; outcome: string | null; }; } export interface AwaitBatchResult { /** Per-dispatch slot, in dispatchIndex order. */ readonly results: ReadonlyArray; /** Batch-level outcome — distinct, machine-readable. */ readonly outcome: AwaitBatchOutcome; /** What the caller asked for (or the IDE default, when omitted). */ readonly requestedTimeoutMs: number; /** What the loop actually waited (≤ requestedTimeoutMs after clamp). */ readonly effectiveTimeoutMs: number; /** The hard cap applied; equals `requestedTimeoutMs` if no clamp. */ readonly hardCapMs: number; } /** * Run one awaitBatch. Pure (no module-level state); the two * back-compat wrappers in sub-agent-dispatcher.ts call this. */ export declare function awaitBatch(dispatchCount: number, recordPaths: readonly string[], timeoutMs: number | undefined, options?: AwaitBatchOptions): Promise;