/** * Self-healing poll scheduler for the resident daemon. * * WHY THIS EXISTS (a real production incident, 2026-08-22) * The daemon's loops schedule the next tick only AFTER the current attempt * settles — deliberate, so a slow control plane can never stack overlapping * polls on a developer's laptop. The hidden cost: an attempt that NEVER * settles kills the loop for the lifetime of the process. * * That is not theoretical. `fetch` can hang indefinitely despite its * `AbortSignal.timeout` when the underlying socket dies without a FIN — * laptop sleep, VPN/Wi-Fi change, or a corporate proxy silently dropping an * idle connection. On the incident machine the bundle poll hung at 15:59 and * never ran again, while heartbeats, hooks, and the watchdog all kept working * for three hours. The machine therefore looked perfectly healthy in the * console — but the bundle poll is the ONLY delivery path for remote actions, * so every admin fix (upgrade, repair protection, collect diagnostics) * silently expired unacknowledged. The worst possible failure mode: a machine * we cannot reach that reports itself fine. * * The fix is a hard per-attempt deadline. When it expires we abandon the * attempt (logging + distress so the fleet SEES it) and schedule the next * tick anyway. The orphaned promise gets a permanent catch so a late * rejection can never surface as an unhandled rejection and take the daemon * down with it. * * Pure and injectable (timers + clock) so the "loop survives a poll that * never settles" property is unit-tested without waiting on real minutes. */ export interface PollLoopHandle { stop(): void; /** Attempts abandoned at the deadline — exposed for tests/diagnostics. */ stalledCount(): number; } export interface PollLoopInput { /** One attempt. May reject, and may never settle — both are survivable. */ run: () => Promise; /** * Base cadence, read fresh before every tick so the server can retune a * whole fleet (bundle `pollIntervalMs`) without a new CLI. */ intervalMs: () => number; /** * Hard ceiling for a single attempt. Keep it below the interval so an * abandoned attempt cannot overlap the next one for long. */ deadlineMs: number; /** Called once per abandoned attempt (log + distress at the call site). */ onStalled?: (deadlineMs: number) => void; /** Called when an attempt rejects. Never rethrow from here. */ onError?: (error: unknown) => void; /** Cadence jitter fraction (0..1, default 0.2) — a proxied fleet must not poll in lockstep. */ jitter?: number; /** Injectable timers for tests. */ timers?: { setTimeout: (fn: () => void, ms: number) => unknown; clearTimeout: (handle: unknown) => void; }; /** Injectable randomness for deterministic tests. */ random?: () => number; } /** * Await `run()` but never longer than `deadlineMs`. Resolves 'settled' when the * attempt finished (fulfilled or rejected) and 'abandoned' when the deadline * won the race. */ export declare function runWithDeadline(input: Pick): Promise<'settled' | 'abandoned'>; /** * Start a loop that runs `run()` forever on a jittered cadence and CANNOT be * killed by an attempt that hangs or throws. */ export declare function startPollLoop(input: PollLoopInput): PollLoopHandle;