import type { ResolvedRoleLoop } from './config.js'; import { type ScheduledLoopsFile, type StateWriter } from './state.js'; import { RoleTurnArbiter } from '../session/arbiter.js'; export interface LoopManagerDeps { now(): number; setTimer(callback: () => void, ms: number): unknown; clearTimer(timer: unknown): void; log(line: string): void; /** Persistence seam; defaults to the atomic replace. Tests inject write faults here. */ writeState?: StateWriter; /** Test seam for the post-cancellation abandon bound; production uses the default. */ cancelAbandonMs?: number; } /** * Recovery delay for the nth consecutive failure: 1s doubling to the poll * ceiling. Bounded at both ends — never a busy retry, never longer than the * normal cadence, so recovery is noticed within a minute of the fault clearing. */ export declare function backoffMs(consecutiveFailures: number): number; export interface LoopActionResult { state: 'started' | 'skipped_busy' | 'disabled' | 'unavailable'; runId?: string; } export interface ScheduledLoopManagerHandle { start(): void; stop(): Promise; status(): ScheduledLoopsFile; runNow(name: string): Promise; disable(name: string): LoopActionResult; enable(name: string): LoopActionResult; reconcile(definitions: ResolvedRoleLoop[]): void; } export declare class ScheduledLoopManager implements ScheduledLoopManagerHandle { private readonly role; private readonly arbiter; private readonly deps; private readonly definitions; private readonly store; private timer?; private readonly runTimeouts; private stopping; /** Consecutive unforeseen poll failures; drives the recovery backoff. */ private pollFailures; constructor(role: string, definitions: ResolvedRoleLoop[], stateDir: string, arbiter: RoleTurnArbiter, deps: LoopManagerDeps); start(): void; stop(): Promise; status(): ScheduledLoopsFile; runNow(name: string): Promise; disable(name: string): LoopActionResult; enable(name: string): LoopActionResult; reconcile(definitions: ResolvedRoleLoop[]): void; /** Public fake-clock seam; timer callbacks call the same transition. */ poll(): Promise; private attempt; /** * Last resort for a run whose cancellation never settles. Without it a single * unsettled turn keeps `activeRunId` set for the life of the process, and * every later tick reports `skipped_busy` forever — the loop looks scheduled * while nothing has run since. Releasing the slot is honest, and it is safe * because `finish` still refuses to double-report a run it no longer owns. * * Releasing the loop's own slot is only half of it. The cancellation that * never settled is still holding the arbiter's admission boundary, so the * generation it belongs to has to be retired in the same step — otherwise the * loop believes it is free while every later producer, scheduled or owner, * queues behind a promise that will never resolve. */ private armAbandon; private finish; private advance; /** * Coalesce a backlog into one skip. The counters alone say how many * occurrences were lost but never when or for how long, so the window is * recorded too and carried on the state until a run is actually told about it * — a dropped pass has to stay visible to the next one, not just to whoever * was reading the log at the time. */ private skipMissed; /** * Restart is not, by itself, a reason to lose an occurrence a running manager * would still have run. `poll` tolerates lateness up to one full interval and * runs the tick late; this path used to drop anything already due however * recently, so a role restarted seconds after its own tick came due lost it * outright. For an oversight role that is precisely the pass which would have * recorded why it restarted, so the failure erased its own witness. * * The tolerance is the only thing shared with `poll`. A backlog at least one * interval deep is still coalesced into a single skip and never replayed — * after a long outage exactly one occurrence survives, and `schedule` then * arms it through the ordinary path rather than firing a burst here. * * Running the survivor late cannot outpace the configured cadence: `advance` * moves the cursor by exactly one `intervalMs` per occurrence from the nominal * time, so a loop that keeps restarting still runs at most once per interval. */ private skipRestartBacklog; /** * A run the store could not record is dropped, not retried: the cursor has * already moved, so this can never become a busy loop, and the outage is * visible as a skip with a failed health rather than as silence. */ private skipUnpersisted; private schedule; private arm; /** * The residual safety net. Persistence failures are absorbed by the store, so * reaching here means something unforeseen threw — and whatever it was, the * manager must stay armed. The previous behaviour left no timer at all, which * turned one transient ENOSPC into every loop on the role being silently gone * until the process was restarted. */ private recover; /** * The envelope is the only channel a scheduled pass has for learning about * the passes that did not happen. A gap stated here is what lets an oversight * role report its own outage instead of resuming as if nothing was missed. */ private envelope; }