import type { PiTeamsConfig } from "../../config/config.ts"; import { loadConfig } from "../../config/config.ts"; import { isSessionSwitchInFlight } from "../../ui/inline-panel/view-session-store.ts"; import { safeAbort } from "../../utils/safe-abort.ts"; import type { TeamContext } from "./context.ts"; /** * Resolved run deadline — a shared AbortController, its signal, and the * computed deadline in milliseconds. */ export interface RunDeadline { /** AbortSignal to pass to run executors (executeTeamRun, runDynamicWorkflow). */ signal: AbortSignal; /** Computed deadline in milliseconds (used for `waitForRun` timeoutMs). */ deadlineMs: number; /** Underlying controller — callers may link additional parent signals. */ controller: AbortController; /** RC-02: the deadline timer handle — callers clear it on normal completion. */ timer: NodeJS.Timeout | undefined; } /** Fallback deadline when no config or param override is available: 1 hour. */ export const DEFAULT_RUN_DEADLINE_MS = 3_600_000; /** Options for resolveRunDeadline. */ export interface RunDeadlineOptions { /** "abort" (default): arm the deadline timer that ABORTS the controller — * deadline expiry cancels the run (direct-await paths: DWF, goal-loop, * inline scaffold — the deadline IS the execution bound). * * "watch-only": do NOT arm the abort timer. The deadline bounds only the * caller's WATCH (waitForRun timeoutMs). Genuine caller cancels * (ctx.signal abort outside a session switch, startForegroundRun callback * abort) STILL abort the controller and cancel the run — deadline expiry * alone never does. RELIABILITY FIX 2026-10-10 (wave-2 chain incident, run * team_20261010045718): the foreground lane's armed timer was aborting * executeTeamRun's signal at the 60-minute watch boundary — chain steps * reported "partial" AND the still-healthy child run was cancelled. The * watch is a timer; the cancel must be a caller decision. */ timer?: "abort" | "watch-only"; } /** * Resolve a unified run deadline with a shared AbortController. * * CORE-8 fix: previously three code paths in run.ts used inconsistent timeout * policies — the DWF path used `AbortSignal.timeout(3_600_000)`, the foreground * async path used `waitForRun(timeoutMs: 3_600_000)`, and the inline scaffold * path passed `ctx.signal` with **zero** deadline fallback (could hang * indefinitely if the caller signal never fires). * * This helper unifies all three into a single resolution strategy: * * Priority: `params.timeoutMs` → `config.limits.maxRunMinutes * 60_000` → * `DEFAULT_RUN_DEADLINE_MS` (1 hour). * * The returned AbortController is linked to `ctx.signal` (caller abort * propagates immediately) and armed with a deadline timer (`setTimeout` + * `unref` so it never keeps the event loop alive). Callers may link additional * parent signals to `controller` — e.g. a `startForegroundRun` callback signal. * * The `params` generic accepts the full `TeamToolParamsValue` (or any object * with an optional `timeoutMs`); the value is read defensively via cast so the * weak-type TS2559 check does not fire. */ export function resolveRunDeadline( ctx: Pick, params: T, config?: PiTeamsConfig, options?: RunDeadlineOptions, ): RunDeadline { const timeoutMs = (params as { timeoutMs?: number }).timeoutMs; const effectiveConfig = config ?? loadConfig(ctx.cwd).config; const maxRunMinutes = effectiveConfig?.limits?.maxRunMinutes; const deadlineMs = timeoutMs ?? (maxRunMinutes ? maxRunMinutes * 60_000 : DEFAULT_RUN_DEADLINE_MS); const controller = new AbortController(); // Propagate caller abort (ctx.signal) to the shared controller. // R6-F2 (W2/RC-03 consistency): named handler instead of an anonymous // { once: true } closure. resolveRunDeadline returns immediately (there is // no run-lifetime finally scope inside this module — callers in run.ts only // clearTimeout the timer), so removal is wired to the deadline controller's // own abort: once the run controller aborts for ANY cause (deadline timer // fire, caller-signal propagation, or an explicit controller.abort() from a // linked path such as startForegroundRun), the propagation listener is dead // weight on ctx.signal and is removed. Behavior-neutral: if the caller's // signal fired afterwards, the listener would only call abort() on an // already-aborted controller — a no-op. if (ctx.signal) { if (ctx.signal.aborted) safeAbort(controller, "run-deadline.caller-preaborted"); else { const callerSignal = ctx.signal; // The deadline signal is passed down to run executors that may spawn // children with it; aborting after a child exited throws AbortError // from Node's child_process listener — never let that escape a // pi event handler (safe-abort). const onCallerAbort = () => { // Regression: switching sessions (resume/new/fork — including // /crew-view) tears the current turn down via session.abort(), // firing the tool-call abort while the run is still forming. A // foreground run must survive session switches (P0 — it shares // the process), so a teardown abort must NOT cancel it. Only a // genuine caller abort OUTSIDE a session switch propagates. if (isSessionSwitchInFlight()) return; safeAbort(controller, "run-deadline.caller-abort"); }; callerSignal.addEventListener("abort", onCallerAbort, { once: true }); controller.signal.addEventListener("abort", () => callerSignal.removeEventListener("abort", onCallerAbort), { once: true }); } } // Arm the deadline timer (unref'd so it never blocks process exit). // RC-02: expose the timer so callers can clearTimeout on normal completion — // otherwise every run leaves a dangling 1h timer retaining ctx/params in closure. // Same abort-after-exit guard as above: the timer may fire after workers // already exited and their spawn-signal listeners are gone — Node can throw. // "watch-only" (bug #2 fix): the timer is NOT armed — the deadline bounds the // watch only, so the controller can only be aborted by a real caller cancel. let timer: NodeJS.Timeout | undefined; if (deadlineMs > 0 && options?.timer !== "watch-only") { timer = setTimeout(() => safeAbort(controller, "run-deadline.timer"), deadlineMs); timer.unref?.(); } return { signal: controller.signal, deadlineMs, controller, timer }; }