# OpenSpec Change: retry-injectable-timers - **Change ID**: retry-injectable-timers - **Packages touched**: `@suaveplan/types`, `@suaveplan/retry`, `@suaveplan/cron`, `@suaveplan/job-scheduler` - **Category**: control-flow / foundation - **Status**: PROPOSED - **Date**: 2026-05-12 - **Motivation**: When `@suaveplan/job-scheduler` was implemented (archived 2026-05-12), `executeJob` could not delegate to `@suaveplan/retry.retry()` because the retry primitive's `sleep()` and `withTimeout()` use raw `setTimeout`/`clearTimeout`. Under `FakeClock`-driven tests those timers don't advance, so the retry loop hangs and the integration test would never deterministically progress. The job-scheduler shipped with an inline retry loop carrying the comment "switch over when retry's timers become injectable". This change does that. --- ## Summary Add an injectable `TimersLike` contract (`{ setTimer, clearTimer }`) to `@suaveplan/types` so every package needing test-controllable scheduling can accept it through one shared interface. Extend `@suaveplan/retry`'s `RetryConfig` with an optional `timers?: TimersLike` field. Thread the pair through `sleep()` and `withTimeout()`. Update `@suaveplan/cron`'s pre-existing `SchedulerTimers` interface to be a type alias of `TimersLike` (preserving its public name for backwards compatibility). Once retry's timers are injectable, replace `@suaveplan/job-scheduler`'s inline retry loop with `retry.retry()` — removing duplicated retry semantics from the scheduler and letting the proven retry primitive own that concern. --- ## Current-state audit - `@suaveplan/types` exposes `Clock`, `MonotonicClock`, `Rng`, etc. — but no timer-pair contract. - `@suaveplan/cron` defines `SchedulerTimers` locally in `src/scheduler/cron-scheduler.types.ts`. This is the de-facto contract that should move to `@suaveplan/types`. - `@suaveplan/retry` calls `setTimeout`/`clearTimeout` directly in `src/retry/retry.ts` (`sleep` and `withTimeout`). `RetryConfig` has `clock?` and `rng?` but no `timers?`. - `@suaveplan/job-scheduler` has an inline retry loop in `src/execution/job-execution.ts` driven by the scheduler's injected `timers`. The `@suaveplan/retry` workspace dep is declared but only as a future hook. --- ## Public API changes ### `@suaveplan/types` ```ts // New, exported from the package root. export interface TimersLike { /** Schedule `callback` to run after `delayMs`. Returns a handle. */ setTimer(callback: () => void, delayMs: number): number; /** Cancel a pending timer by handle. Idempotent for unknown handles. */ clearTimer(handle: number): void; } ``` The shape mirrors `globalThis.setTimeout`/`clearTimeout` minus variadic args and Node's `Timeout` object — the lowest-common-denominator that works across `Date.now()`-driven production code and `FakeClock`-driven test code. ### `@suaveplan/cron` ```ts // Was: locally-defined interface. // Now: type alias of the shared contract; existing import names remain valid. import type { TimersLike } from "@suaveplan/types"; export type SchedulerTimers = TimersLike; ``` No behavioral change. The local name is preserved so existing internal call sites compile unchanged. ### `@suaveplan/retry` ```ts import type { Clock, Rng, TimersLike } from "@suaveplan/types"; export interface RetryConfig<_T = unknown> { maxAttempts: number; delay: RetryStrategy; shouldRetry?: (error: unknown, attempt: number) => boolean; signal?: AbortSignal; clock?: Clock; rng?: Rng; /** Pluggable timer pair. Defaults to `globalThis.setTimeout`/`clearTimeout`. */ timers?: TimersLike; } // sleep + withTimeout gain an optional final `timers?: TimersLike` argument. export function sleep( ms: number, signal?: AbortSignal, timers?: TimersLike ): Promise; export function withTimeout( fn: (signal?: AbortSignal) => Promise, ms: number, attemptNumber: number, signal?: AbortSignal, timers?: TimersLike ): Promise; ``` When `timers` is omitted the implementation falls back to the platform `setTimeout`/`clearTimeout` — every existing caller keeps the current behavior. ### `@suaveplan/job-scheduler` No public-API change. The inline retry loop in `src/execution/job-execution.ts` is replaced with a single `retry()` call, threading the scheduler's injected `timers` and `clock` through. `JobSchedulerOptions.timers` stays exactly the same. --- ## Why a shared `TimersLike` Per `openspec/AGENTS.md §0 step 6`: any type shared across ≥2 packages belongs in `@suaveplan/types` to keep the public contract in one place and prevent circular deps. Two packages (`cron` shipped, `retry` after this change) already need it; a third (`job-scheduler`) consumes it transitively. The alternative — three copies of the same interface — is the kind of drift the rule explicitly exists to prevent. --- ## Backwards compatibility - `retry`: every existing call site works unchanged (new field is optional; new function parameters are optional). - `cron`: `SchedulerTimers` keeps its name as a type alias; consumers importing it from `@suaveplan/cron` see no behavioral or type change. - `job-scheduler`: `JobScheduler` public surface unchanged. Internally swapping retry implementations is a refactor, not an API break. --- ## Impact - Removes ~40 lines of duplicated retry logic from `job-scheduler/src/execution/job-execution.ts`. - Unifies the timers contract for future packages (e.g. a future `@suaveplan/circuit-breaker` extracted from retry, or workflow-tier scheduler peers). - Slightly increases `@suaveplan/types` payload (one tiny interface, zero runtime impact — types-only). **Prerequisites on Verdaccio**: `@suaveplan/types@0.0.0+`, `@suaveplan/retry@0.0.0+`, `@suaveplan/cron@0.0.0+`, `@suaveplan/job-scheduler@0.0.0+`. All already published.