// framework/run-budget.ts — what wall-clock budget (item.timeoutMs) a spawned // child actually runs under, and what the operator sees when none was set. // // CORRECTED BY AUTOPILOT-53 (superseding AUTOPILOT-47/AUTOPILOT-52's model — // see docs/AUTOPILOT-53-budget-ceiling-correction.md). AUTOPILOT-47 traced the // installed runtime and concluded every spawned child was silently truncated // to a fixed 30-minute per-step ceiling no matter what `timeoutMs` was // requested. AUTOPILOT-53 re-examined that conclusion end to end and found it // wrong: an item's `timeoutMs`, when SET at dispatch time, IS honoured — // dispatch, auto-dispatch, auto-recovery and auto-review all put it on // `backend.spawn` (src/tools/queue-ops.ts, src/framework/auto-dispatch.ts, // src/framework/auto-recovery.ts) and the runtime runs the child under it. The // 30-minute figure (`RUNTIME_DEFAULT_BUDGET_MS`, mirroring the runtime's // `DEFAULT_ASYNC_TIMEOUT_MS`) is what the runtime falls back to ONLY when NO // budget is requested at all — a default, not a ceiling that clamps a larger // explicit request. This module no longer asserts or enforces a universal // ceiling: it does not truncate a requested budget, and nothing here warns // that an explicit request "WILL be cut off". // // THE REMAINING GAP AUTOPILOT-53 CLOSES: an item with NO recorded `timeoutMs` // used to dispatch with `timeoutMs: undefined`, silently inheriting the // runtime's internal 30-minute default — invisible to the operator, and too // short for most work. `resolveDispatchBudget` is the one seam every spawn // site uses to guarantee that never happens again: it applies an EXPLICIT, // VISIBLE default (`DEFAULT_DISPATCH_BUDGET_MS`) instead, so the budget an // item actually runs under is always a number the operator can see on the // item (`queue_list`) and override (`queue_update({timeoutMs})`). /** The runtime's own fallback when a spawned child is given no budget at all * (mirrors pi-subagents' `DEFAULT_ASYNC_TIMEOUT_MS`, 30 minutes). This is * NOT a ceiling — an explicit, larger `timeoutMs` is honoured, not clamped * to this number. It exists here only so `workerFailCause`'s deterministic * cap backstop (src/core.ts) has the right number to compare against for an * item that was (incorrectly, pre-AUTOPILOT-53-fix) dispatched with none. */ export const RUNTIME_DEFAULT_BUDGET_MS = 30 * 60 * 1000; /** The explicit, visible default `resolveDispatchBudget` applies at dispatch * when an item carries no `timeoutMs`. Chosen larger than the runtime's own * silent fallback (`RUNTIME_DEFAULT_BUDGET_MS`) because AUTOPILOT-53 confirms * an explicit budget is honoured end to end, so there is no reason to leave * an unbudgeted item at the runtime's minimal default — that default is what * produced the "too short for most work" gap this constant closes. Always * overridable per item via `queue_update({timeoutMs})` before it is spawned. */ export const DEFAULT_DISPATCH_BUDGET_MS = 60 * 60 * 1000; /** What a requested budget resolves to. `effectiveMs` is always the number * the runtime will actually be told to honour: the request itself when one * was made, or `RUNTIME_DEFAULT_BUDGET_MS` when none was. */ export type BudgetAssessment = { /** The budget as requested (ms), or null when none was requested. */ requestedMs: number | null; /** What the runtime is told to honour (ms) — never null. */ effectiveMs: number; /** True when no budget was requested and the runtime default was used. */ defaulted: boolean; }; /** Assess a requested budget. An explicit, positive, finite request is * honoured as-is (AUTOPILOT-53 — there is no ceiling to clamp it to); an * absent/invalid request resolves to `defaultMs` (the runtime's own * fallback by default), flagged so callers can tell the two apart. */ export function assessRequestedBudget( requestedMs: number | null | undefined, defaultMs: number = RUNTIME_DEFAULT_BUDGET_MS, ): BudgetAssessment { if (typeof requestedMs !== "number" || !Number.isFinite(requestedMs) || requestedMs <= 0) { return { requestedMs: null, effectiveMs: defaultMs, defaulted: true }; } return { requestedMs, effectiveMs: requestedMs, defaulted: false }; } /** What `resolveDispatchBudget` decided for one spawn. */ export type DispatchBudget = { /** The budget to pass to `backend.spawn` — never null/undefined. */ timeoutMs: number; /** True when the item had no recorded `timeoutMs` and this call supplied * `DEFAULT_DISPATCH_BUDGET_MS` in its place. */ appliedDefault: boolean; }; /** * THE seam every spawn site (`queue_dispatch`, `autoDispatchEligible`, * `autoRedispatch`, `autoReview`, auto-recovery) must call before * `backend.spawn` — never pass `item.timeoutMs ?? undefined` directly. An * item with no recorded budget gets an EXPLICIT, VISIBLE default instead of * silently falling through to the runtime's own internal fallback: the * caller persists `timeoutMs` back onto the item when `appliedDefault` is * true, so it shows up in `queue_list` and rides every later lane exactly * like an operator-set budget would. */ export function resolveDispatchBudget(recordedTimeoutMs: number | null | undefined): DispatchBudget { if (typeof recordedTimeoutMs === "number" && Number.isFinite(recordedTimeoutMs) && recordedTimeoutMs > 0) { return { timeoutMs: recordedTimeoutMs, appliedDefault: false }; } return { timeoutMs: DEFAULT_DISPATCH_BUDGET_MS, appliedDefault: true }; } /** * The note for a `queue_add`/`queue_update` receipt when NO budget was * requested — empty string when one was (so callers can concatenate freely). * Names the runtime's own default and points at the override, so an absent * budget is a decision the operator SAW, not a silent one made for them. * * `formatMs` renders a human duration (src/duration.ts formatDurationMs). */ export function noBudgetNote(assessment: BudgetAssessment, formatMs: (ms: number) => string): string { if (!assessment.defaulted) return ""; return ( ` NOTE: no timeoutMs was set — this item will dispatch with the default budget of ${formatMs(DEFAULT_DISPATCH_BUDGET_MS)} ` + `(${DEFAULT_DISPATCH_BUDGET_MS}ms), recorded on the item at dispatch. An explicit timeoutMs IS honoured end to end (AUTOPILOT-53) — ` + `set one now if this task needs more or less time.` ); }