/** * Per-op fail-fast ceilings for `tab.*` helpers, shared by every backend. All are kept * strictly under the cell budget (`timeoutMs - CELL_BUDGET_SLACK_MS`) so a stalled helper * rejects with a named, attributable error that leaves recovery budget — never the opaque * whole-cell "Browser code execution timed out" path that consumed the entire run. * * - `QUICK_OP_TIMEOUT_MS`: page-coupled reads that should resolve fast (`observe`, * `screenshot`, `extract`, `ariaSnapshot`). * - `ACTION_OP_TIMEOUT_MS`: interactive point actions (`click`, `fill`, `type`, …) and * the default for wait helpers when no explicit `{ timeout }` is given. Selector ops * additionally fail fast after `ZERO_MATCH_FAIL_FAST_MS` of confirmed zero matches, * so the full ceiling is only spent on elements that exist but are not yet actionable. * * `goto` and `evaluate` stay uncapped: navigation and user code legitimately use the full * cell budget. */ import { untilAborted } from "@oh-my-pi/pi-utils"; import { CELL_BUDGET_SLACK_MS } from "../run-scope"; const QUICK_OP_TIMEOUT_MS = 20_000; const ACTION_OP_TIMEOUT_MS = 8_000; /** * A selector op whose selector has matched nothing for this long fails fast with the * zero-match hint instead of burning the rest of its deadline: a wrong selector or a * wrong page (consent wall, pre-navigation document) is the common agent failure and * should cost ~2s, not the full action ceiling. Explicit `{ timeout }` waits opt out. */ export const ZERO_MATCH_FAIL_FAST_MS = 2_000; /** Poll cadence for the zero-match watchdog. */ export const ZERO_MATCH_POLL_MS = 250; /** Per-op deadlines for one cell budget. */ export interface OpTimeouts { /** Largest per-op deadline allowed — strictly below the cell budget. */ budgetBound: number; /** Ceiling for quick page reads. */ quickOpMs: number; /** Ceiling for interactive actions + default for waits. */ actionOpMs: number; } /** Resolve the per-op fail-fast ceilings for a given cell budget. */ export function resolveOpTimeouts(cellTimeoutMs: number): OpTimeouts { const budgetBound = Math.max(1, cellTimeoutMs - CELL_BUDGET_SLACK_MS); return { budgetBound, quickOpMs: Math.min(budgetBound, QUICK_OP_TIMEOUT_MS), actionOpMs: Math.min(budgetBound, ACTION_OP_TIMEOUT_MS), }; } /** * Effective timeout for a wait helper (`waitFor*`). A positive explicit `{ timeout }` is * honored but clamped to the cell budget so it still fails fast + named; raising the tool * `timeout` raises that cap, so a longer budget stays meaningful. No `{ timeout }` → the * action ceiling. Puppeteer's `{ timeout: 0 }` / `Infinity` ("disable") maps to the largest * bounded wait (`budgetBound`) — the harness never permits an unbounded wait. Garbage input * (negative, `NaN`) falls back to the action ceiling rather than the longest wait. */ export function resolveWaitTimeout(cellTimeoutMs: number, explicit?: number): number { const { budgetBound, actionOpMs } = resolveOpTimeouts(cellTimeoutMs); if (explicit === undefined) return actionOpMs; // Puppeteer "disable" sentinels — still bounded by the budget here. if (explicit === 0 || explicit === Number.POSITIVE_INFINITY) return budgetBound; // Positive finite → honored + clamped. Negative/NaN garbage → default, not the longest wait. if (Number.isFinite(explicit) && explicit > 0) return Math.min(explicit, budgetBound); return actionOpMs; } /** * Wait for `pending` under `signal`. An abort does not stop the call behind it, so a result that arrives after the * abort is handed to `release`. */ export async function untilAbortedOrReleased( signal: AbortSignal | undefined, pending: Promise, release: (late: T) => Promise, ): Promise { return await untilAborted(signal, () => pending).catch(error => { void pending.then(release).catch(() => undefined); throw error; }); }