From 9316e6962a23d7aaee1fce4660f7891a90f04c59 Mon Sep 17 00:00:00 2001 From: nazteeemba Date: Sat, 27 Jun 2026 10:31:22 +0100 Subject: [PATCH 1/3] feat(daemon): add spec for adaptive polling intervals (#148) Add requirements spec for the adaptive polling intervals feature. - Defines 6 requirements covering interval tier calculation, dynamic rescheduling, backward-compatible configuration, a pure computeEffectiveInterval function, observability logging, and re-entrance safety - TTL tiers expressed in ledgers (720 / 17,280 / 120,960) mapping to 1 min / 5 min / 1 hour polling intervals - IntervalPolicy type allows custom tier boundaries; intervalMs override pins a fixed interval (disabling adaptive mode) - Invalid policies throw before daemon starts - Pure computeEffectiveInterval function enables isolated unit testing Closes #148 --- .../adaptive-polling-intervals/.config.kiro | 1 + .../requirements.md | 107 ++++++++++++++++++ 2 files changed, 108 insertions(+) create mode 100644 .kiro/specs/adaptive-polling-intervals/.config.kiro create mode 100644 .kiro/specs/adaptive-polling-intervals/requirements.md diff --git a/.kiro/specs/adaptive-polling-intervals/.config.kiro b/.kiro/specs/adaptive-polling-intervals/.config.kiro new file mode 100644 index 00000000..31988b67 --- /dev/null +++ b/.kiro/specs/adaptive-polling-intervals/.config.kiro @@ -0,0 +1 @@ +{"specId": "63581d08-c81b-45cd-90ae-8cc18ebd4355", "workflowType": "requirements-first", "specType": "feature"} diff --git a/.kiro/specs/adaptive-polling-intervals/requirements.md b/.kiro/specs/adaptive-polling-intervals/requirements.md new file mode 100644 index 00000000..19dc40ca --- /dev/null +++ b/.kiro/specs/adaptive-polling-intervals/requirements.md @@ -0,0 +1,107 @@ +# Requirements Document + +## Introduction + +The Sorokeep daemon currently polls all watched Soroban contract entries at a fixed interval (default 5 minutes, user-configurable). This is wasteful when all TTLs are comfortably far from expiry, and potentially too slow to catch critical expirations in their final hour. This feature replaces the static interval with an **adaptive polling schedule** that tightens as TTLs approach expiry and relaxes when they are safely distant. + +After every monitor cycle, the Scheduler computes the shortest interval dictated by the lowest remaining TTL across all watched entries and reschedules the next cycle accordingly. No interval may fall below 60 seconds or exceed 3,600,000 ms (1 hour) by default. + +All TTL values in the system are measured in **ledgers**. Stellar closes one ledger approximately every 5 seconds. The polling interval tiers defined in this document use ledger counts derived from those wall-clock approximations: + +| Wall-clock duration | Approximate ledgers | +|---------------------|---------------------| +| 1 hour | 720 ledgers | +| 24 hours | 17,280 ledgers | +| 7 days | 120,960 ledgers | + +--- + +## Glossary + +- **Scheduler**: The component inside `src/daemon/loop.ts` responsible for timing daemon cycles and rescheduling the next cycle after each run. +- **Cycle**: One complete execution of `runMonitorCycle` together with its follow-on steps (alert delivery, introspection rescan, auto-extension, cost snapshot). +- **Remaining TTL**: The number of ledgers a contract entry has left before expiry, computed as `liveUntilLedgerSeq − latestLedger` at the time of the RPC call. Stored as `remainingTTL` on `SorokeepLedgerEntryResult`. +- **Effective Interval**: The wall-clock polling interval in milliseconds that the Scheduler selects for the next cycle, derived from the minimum remaining TTL across all entries seen in the just-completed cycle. +- **Interval Tier**: A mapping rule that translates a remaining TTL range into a specific polling interval. +- **Minimum Interval**: The floor below which the Scheduler will never schedule a cycle, regardless of TTL. Default: 60,000 ms (1 minute). +- **Maximum Interval**: The ceiling above which the Scheduler will never schedule a cycle, regardless of TTL. Default: 3,600,000 ms (1 hour). +- **TTL Tier Boundary**: A ledger count threshold that separates two Interval Tiers. +- **DaemonOptions**: The configuration object passed to `startDaemon` in `src/daemon/loop.ts`. +- **IntervalPolicy**: A configuration object (part of `DaemonOptions`) that holds the tier boundaries and their associated intervals. + +--- + +## Requirements + +### Requirement 1: Interval Tier Calculation + +**User Story:** As a contract operator, I want the daemon to check more frequently as a contract entry approaches expiry, so that I receive timely alerts and have enough time to act before an entry expires. + +#### Acceptance Criteria + +1. WHEN the Scheduler computes the Effective Interval after a cycle, THE Scheduler SHALL classify each entry's remaining TTL into exactly one of the following tiers and map it to the listed interval: + - Remaining TTL ≥ 120,960 ledgers (> 7 days): 3,600,000 ms (1 hour) + - Remaining TTL < 120,960 ledgers and ≥ 17,280 ledgers (≤ 7 days and > 24 hours): 300,000 ms (5 minutes) + - Remaining TTL < 17,280 ledgers and ≥ 720 ledgers (≤ 24 hours and > 1 hour): 300,000 ms (5 minutes) + - Remaining TTL < 720 ledgers (≤ 1 hour): 60,000 ms (1 minute) +2. WHEN the Scheduler computes the Effective Interval, THE Scheduler SHALL select the minimum interval produced across all entries from all watched contracts on the active network. +3. WHEN no watched contracts exist for the active network or all contracts have zero tracked entries, THE Scheduler SHALL use the Maximum Interval as the Effective Interval. +4. THE Scheduler SHALL ensure the Effective Interval is never less than the Minimum Interval (default 60,000 ms). +5. THE Scheduler SHALL ensure the Effective Interval is never greater than the Maximum Interval (default 3,600,000 ms). + +### Requirement 2: Dynamic Rescheduling + +**User Story:** As a contract operator, I want the daemon to reschedule itself based on the most recent TTL data after every cycle, so that the polling frequency always reflects the current urgency of the watched entries. + +#### Acceptance Criteria + +1. WHEN a cycle completes without any errors (zero entries in `MonitorCycleResult.errors` and no thrown exception), THE Scheduler SHALL compute the Effective Interval using the remaining TTL values returned by the most recent `runMonitorCycle` call. +2. WHEN a cycle produces one or more errors (non-empty `MonitorCycleResult.errors` or a thrown exception), THE Scheduler SHALL reschedule the next cycle using the previous Effective Interval without altering the interval. +3. THE Scheduler SHALL cancel the existing timer before scheduling a new one, so that at most one pending cycle exists at any point in time. +4. WHEN `startDaemon` is called, THE Scheduler SHALL run the first cycle immediately without waiting for the initial Effective Interval to elapse. +5. WHEN `stopDaemon` is called, THE Scheduler SHALL cancel the pending timer and prevent any further cycles from being scheduled. + +### Requirement 3: Backward-Compatible Configuration + +**User Story:** As a developer integrating with the daemon, I want to retain the existing `intervalMs` override while also being able to configure tier boundaries, so that existing deployments are unaffected and new deployments can customise the adaptive behaviour. + +#### Acceptance Criteria + +1. WHERE the caller supplies `intervalMs` in `DaemonOptions`, THE Scheduler SHALL use that fixed value as both the Minimum Interval and the Maximum Interval, effectively disabling adaptive behaviour. +2. WHERE the caller supplies an `IntervalPolicy` in `DaemonOptions`, THE Scheduler SHALL use the tier boundaries and interval values from that policy in place of the built-in defaults. +3. WHERE neither `intervalMs` nor `IntervalPolicy` is supplied, THE Scheduler SHALL apply the default tier boundaries and intervals defined in Requirement 1. +4. IF `IntervalPolicy` specifies a tier interval below 10,000 ms, THEN THE Scheduler SHALL reject the configuration and throw an `Error` describing the violation before the daemon starts. WHERE all tier intervals meet the 10,000 ms floor and all other validation rules pass, THE Scheduler SHALL allow the daemon to start without throwing. +5. IF `IntervalPolicy` specifies a Minimum Interval greater than any tier interval, THEN THE Scheduler SHALL reject the configuration and throw an `Error` describing the violation before the daemon starts. + +### Requirement 4: Interval Derivation from Cycle Results + +**User Story:** As a developer, I want the interval calculation to be a pure, testable function that takes TTL data and an optional policy and returns a millisecond interval, so that I can write isolated unit tests without starting the daemon. + +#### Acceptance Criteria + +1. THE Scheduler SHALL expose a pure function `computeEffectiveInterval(remainingTTLs: number[], policy?: IntervalPolicy): number` that accepts an array of remaining TTL values (in ledgers) and an optional policy, and returns the Effective Interval in milliseconds. +2. WHEN `remainingTTLs` is an empty array, THE `computeEffectiveInterval` function SHALL return the Maximum Interval from the supplied policy, or the default Maximum Interval if no policy is supplied. +3. FOR ALL arrays of remaining TTL values, the result of `computeEffectiveInterval` SHALL fall within the range `[MinimumInterval, MaximumInterval]` inclusive. +4. FOR ALL arrays of remaining TTL values `ttls`, `computeEffectiveInterval(ttls)` SHALL equal `computeEffectiveInterval(ttls)` when called a second time with the same input — the function is pure and deterministic. +5. WHEN all values in `remainingTTLs` are equal, THE `computeEffectiveInterval` function SHALL return the same result regardless of array length. + +### Requirement 5: Observability + +**User Story:** As an operator, I want the daemon to log the computed Effective Interval before each scheduled cycle, so that I can confirm the adaptive logic is working correctly without inspecting source code. + +#### Acceptance Criteria + +1. WHEN the Scheduler reschedules a cycle, THE Scheduler SHALL emit a log entry at the `info` level containing the Effective Interval in milliseconds and the minimum remaining TTL (in ledgers) that produced it. +2. WHEN the Effective Interval changes relative to the previous cycle, THE Scheduler SHALL log the previous and new interval values in the same log entry. +3. WHEN no TTL data is available (empty watch list), THE Scheduler SHALL emit a log entry at the `debug` level indicating that the Maximum Interval is being used. +4. IF a cycle completes with one or more contract errors, THEN THE Scheduler SHALL log the count of failed contracts (which may be zero) alongside the Effective Interval so that operators can correlate polling rate changes with monitoring failures. + +### Requirement 6: Re-entrance Safety + +**User Story:** As an operator running the daemon in a resource-constrained environment, I want the daemon to never start a new cycle while the previous one is still running, so that RPC requests do not pile up if a cycle takes longer than the Effective Interval. + +#### Acceptance Criteria + +1. WHILE a cycle is in flight, THE Scheduler SHALL skip any timer tick that fires before the in-flight cycle finishes, and THE Scheduler SHALL emit a log entry at the `debug` level indicating the skip reason. +2. WHEN a timer tick fires during an in-flight cycle, THE Scheduler SHALL always skip it — a second concurrent cycle SHALL never be started. +3. WHEN the in-flight cycle finishes after a skip, THE Scheduler SHALL reschedule the next cycle normally using the freshly computed Effective Interval. From 403829e167ede638bcf4266d5498c206d5983b9c Mon Sep 17 00:00:00 2001 From: nazteeemba Date: Sat, 27 Jun 2026 11:08:37 +0100 Subject: [PATCH 2/3] feat(daemon): add design and tasks for adaptive polling intervals Add design document and implementation tasks for the adaptive polling intervals feature. - Design covers interval.ts pure module, loop.ts refactor from setInterval to chained setTimeout, IntervalPolicy type, and MonitorCycleResult.remainingTTLs extension - Tasks cover 9 steps: fast-check install, interval.ts creation, monitor.ts extension, loop.ts wiring, build checkpoint, unit tests, property-based tests, integration tests, and final checkpoint Closes #148 --- .../adaptive-polling-intervals/design.md | 478 ++++++++++++++++++ .../specs/adaptive-polling-intervals/tasks.md | 209 ++++++++ 2 files changed, 687 insertions(+) create mode 100644 .kiro/specs/adaptive-polling-intervals/design.md create mode 100644 .kiro/specs/adaptive-polling-intervals/tasks.md diff --git a/.kiro/specs/adaptive-polling-intervals/design.md b/.kiro/specs/adaptive-polling-intervals/design.md new file mode 100644 index 00000000..ad02ecb5 --- /dev/null +++ b/.kiro/specs/adaptive-polling-intervals/design.md @@ -0,0 +1,478 @@ +# Design Document — Adaptive Polling Intervals + +## Overview + +The Sorokeep daemon currently schedules monitoring cycles at a fixed interval. This design replaces +that static schedule with an **adaptive schedule**: after every cycle the Scheduler inspects the +minimum remaining TTL across all watched entries and picks the next interval from a four-tier table. +Entries with comfortable TTLs are checked infrequently; entries within their final hour are checked +every minute. + +The change is surgical: all new logic lives inside `src/daemon/loop.ts` (and a new companion module +`src/daemon/interval.ts` for the pure computation). The public API of `startDaemon` / `stopDaemon` +is preserved and extended backward-compatibly. Existing users who pass `intervalMs` get exactly the +same behaviour as today. + +### Key design goals + +1. **Correct under all inputs** — the interval must always respect its floor and ceiling regardless + of what TTL data the RPC returns. +2. **Testable in isolation** — the tier calculation is a pure function that can be unit-tested and + property-tested without starting the daemon. +3. **Zero observable regression** — `intervalMs` as a scalar override continues to disable adaptive + logic entirely. +4. **Observability by default** — every rescheduling decision emits a structured log entry. + +--- + +## Architecture + +The adaptive polling feature introduces one new module and modifies two existing ones. + +``` +src/ + daemon/ + interval.ts ← NEW — pure computeEffectiveInterval function + types + loop.ts ← MOD — Scheduler wires interval.ts into the timing loop + utils/ + config.ts ← MOD — SorokeepConfig picks up optional IntervalPolicy +``` + +### Scheduling flow (after this change) + +```mermaid +sequenceDiagram + participant Caller + participant Scheduler as loop.ts (Scheduler) + participant Interval as interval.ts + participant Monitor as core/monitor.ts + + Caller->>Scheduler: startDaemon(db, network, options) + Scheduler->>Monitor: runMonitorCycle() [immediately] + Monitor-->>Scheduler: MonitorCycleResult (with remainingTTL per entry) + Scheduler->>Interval: computeEffectiveInterval(remainingTTLs, policy?) + Interval-->>Scheduler: effectiveMs + Scheduler->>Scheduler: clearTimeout(pendingHandle) + Scheduler->>Scheduler: setTimeout(tick, effectiveMs) + note over Scheduler: logs: info — scheduled next cycle in Xms (minTTL=Y) + + loop every tick + Scheduler->>Scheduler: if cycleInFlight → skip (debug log) + Scheduler->>Monitor: runMonitorCycle() + Monitor-->>Scheduler: MonitorCycleResult + Scheduler->>Interval: computeEffectiveInterval(remainingTTLs, policy?) + Interval-->>Scheduler: effectiveMs + Scheduler->>Scheduler: clearTimeout(pendingHandle) + Scheduler->>Scheduler: setTimeout(tick, effectiveMs) + end + + Caller->>Scheduler: stopDaemon() + Scheduler->>Scheduler: clearTimeout(pendingHandle) +``` + +### Timer strategy change: `setInterval` → `setTimeout` + +The current code uses `setInterval` for a fixed-period loop. Adaptive intervals require that the +delay be recomputed after each cycle, so the implementation switches to a **chain of `setTimeout` +calls**. After each cycle completes, the Scheduler: + +1. Calls `computeEffectiveInterval` with the TTLs from the just-finished cycle. +2. Clears any existing pending timer. +3. Schedules a single `setTimeout` with the new delay. + +This naturally prevents timer drift and makes the "at most one pending timer" invariant trivially +verifiable. + +--- + +## Components and Interfaces + +### `src/daemon/interval.ts` — new module + +This module owns all interval arithmetic. It is a pure TypeScript module with no side effects, no +imports from `logging/`, and no I/O. + +```typescript +// ─── Tier boundaries (in ledgers) ──────────────────────────────────────────── +// 1 ledger ≈ 5 seconds on Stellar mainnet / testnet +export const TTL_TIER_CRITICAL = 720; // ≤ 1 hour (~720 ledgers) +export const TTL_TIER_DAY = 17_280; // ≤ 24 hrs (~17,280 ledgers) +export const TTL_TIER_WEEK = 120_960; // ≤ 7 days (~120,960 ledgers) + +// ─── Default interval values (ms) ──────────────────────────────────────────── +export const DEFAULT_MIN_INTERVAL_MS = 60_000; // 1 minute +export const DEFAULT_MAX_INTERVAL_MS = 3_600_000; // 1 hour + +export const DEFAULT_INTERVAL_CRITICAL_MS = 60_000; // < 720 ledgers +export const DEFAULT_INTERVAL_DAY_MS = 300_000; // 720–17,279 ledgers +export const DEFAULT_INTERVAL_WEEK_MS = 300_000; // 17,280–120,959 ledgers +export const DEFAULT_INTERVAL_SAFE_MS = 3_600_000; // ≥ 120,960 ledgers + +// ─── IntervalPolicy type ────────────────────────────────────────────────────── + +/** + * Configures the adaptive tier table. + * All interval fields are in milliseconds; all TTL fields are in ledgers. + * Omitted fields fall back to built-in defaults. + */ +export interface IntervalPolicy { + /** Floor below which no interval will ever be scheduled. Default: 60_000 ms. */ + minIntervalMs?: number; + /** Ceiling above which no interval will ever be scheduled. Default: 3_600_000 ms. */ + maxIntervalMs?: number; + + /** Interval when remaining TTL < criticalTtlLedgers. Default: 60_000 ms. */ + criticalIntervalMs?: number; + /** Interval when criticalTtlLedgers ≤ TTL < dayTtlLedgers. Default: 300_000 ms. */ + dayIntervalMs?: number; + /** Interval when dayTtlLedgers ≤ TTL < weekTtlLedgers. Default: 300_000 ms. */ + weekIntervalMs?: number; + /** Interval when TTL ≥ weekTtlLedgers. Default: 3_600_000 ms. */ + safeIntervalMs?: number; + + /** Lower TTL tier boundary (ledgers). Default: 720. */ + criticalTtlLedgers?: number; + /** Middle TTL tier boundary (ledgers). Default: 17_280. */ + dayTtlLedgers?: number; + /** Upper TTL tier boundary (ledgers). Default: 120_960. */ + weekTtlLedgers?: number; +} + +// ─── Public API ─────────────────────────────────────────────────────────────── + +/** + * Compute the effective polling interval in milliseconds from an array of + * remaining TTL values (in ledgers). + * + * Pure function: no side-effects, deterministic, safe to call in tests. + * + * @param remainingTTLs - Remaining TTL in ledgers for each watched entry. + * Empty array → returns maxIntervalMs. + * @param policy - Optional override for tier boundaries and intervals. + * @returns - Millisecond interval clamped to [minIntervalMs, maxIntervalMs]. + */ +export function computeEffectiveInterval( + remainingTTLs: number[], + policy?: IntervalPolicy, +): number; + +/** + * Validate an IntervalPolicy before the daemon starts. + * Throws a descriptive Error for any validation violation. + * No-ops for undefined / null policies. + */ +export function validateIntervalPolicy(policy: IntervalPolicy | undefined): void; + +/** + * Classify a single remaining TTL value into its tier interval (ms). + * Internal helper exposed for testing and documentation purposes. + */ +export function ttlToIntervalMs( + remainingTTL: number, + policy?: IntervalPolicy, +): number; +``` + +#### `computeEffectiveInterval` algorithm + +``` +resolve all tier parameters (merge policy over defaults) + +if remainingTTLs is empty → return maxIntervalMs + +for each ttl in remainingTTLs: + if ttl < criticalTtlLedgers → tier = criticalIntervalMs + else if ttl < dayTtlLedgers → tier = dayIntervalMs + else if ttl < weekTtlLedgers → tier = weekIntervalMs + else → tier = safeIntervalMs + +effectiveMs = min(all tier values) +return clamp(effectiveMs, minIntervalMs, maxIntervalMs) +``` + +#### `validateIntervalPolicy` rules + +| Rule | Error message pattern | +|------|-----------------------| +| Any tier interval < 10,000 ms | `IntervalPolicy: {field} must be ≥ 10,000 ms (got {value})` | +| minIntervalMs > any tier interval | `IntervalPolicy: minIntervalMs ({value}) exceeds tier interval {field} ({tierValue})` | + +Validation is called once inside `startDaemon`, before the first cycle runs. + +--- + +### Changes to `DaemonOptions` in `src/daemon/loop.ts` + +```typescript +export interface DaemonOptions { + /** + * Fixed polling interval in milliseconds. + * When supplied, adaptive behaviour is disabled: this value is used as + * both minIntervalMs and maxIntervalMs, producing a constant interval. + * Preserved for backward compatibility. + */ + intervalMs?: number; + + /** Adaptive interval policy. Ignored when intervalMs is also supplied. */ + intervalPolicy?: IntervalPolicy; + + /** Optional RPC endpoint URL override. */ + rpcUrl?: string; + + /** How frequently to run vacuum maintenance. Defaults to 24 hours. */ + vacuumIntervalMs?: number; + + /** Called after every cycle with the result (or null + error on failure). */ + onCycle?: (result: MonitorCycleResult | null, error?: Error) => void; +} +``` + +#### Backward-compatibility rule + +When `intervalMs` is present, the Scheduler synthesises a degenerate `IntervalPolicy` with +`minIntervalMs = maxIntervalMs = intervalMs`. Because `clamp(x, N, N) = N` for all x, the +adaptive logic always returns `intervalMs`, exactly matching current behaviour. + +--- + +### Module-level state changes in `loop.ts` + +| Current | After change | +|---------|--------------| +| `intervalHandle: ReturnType` | `timeoutHandle: ReturnType` | +| `cycleInFlight: boolean` | unchanged | +| `vacuumIntervalMs`, `lastVacuumAt` | unchanged | +| *(nothing)* | `currentEffectiveMs: number` — last successfully computed interval, used as fallback on error | +| *(nothing)* | `activePolicy: IntervalPolicy \| undefined` — resolved policy stored at `startDaemon` time | + +--- + +## Data Models + +### `IntervalPolicy` (defined in `interval.ts`, re-exported from `loop.ts`) + +All fields optional; unset fields fall back to documented defaults. + +``` +IntervalPolicy { + minIntervalMs?: number // ≥ 10_000 ms enforced; default: 60_000 + maxIntervalMs?: number // ≥ minIntervalMs enforced; default: 3_600_000 + criticalIntervalMs?: number // TTL < criticalTtlLedgers; default: 60_000 + dayIntervalMs?: number // TTL in [critical, day); default: 300_000 + weekIntervalMs?: number // TTL in [day, week); default: 300_000 + safeIntervalMs?: number // TTL ≥ weekTtlLedgers; default: 3_600_000 + criticalTtlLedgers?: number // default: 720 + dayTtlLedgers?: number // default: 17_280 + weekTtlLedgers?: number // default: 120_960 +} +``` + +### `remainingTTLs` extraction + +`runMonitorCycle` already stores `remainingTTL` on each `SorokeepLedgerEntryResult` that is +returned from the RPC client. After the monitor cycle completes, `loop.ts` will collect the TTLs +by iterating the entries it receives back from the cycle result. + +Because `MonitorCycleResult` currently does not carry entry-level TTLs, a minimal extension is +needed: + +```typescript +export interface MonitorCycleResult { + // ... existing fields unchanged ... + + /** + * Remaining TTL values (in ledgers) for all entries successfully updated + * in this cycle. Used by the Scheduler to compute the next interval. + * Empty when no entries were updated or the cycle failed. + */ + remainingTTLs: number[]; +} +``` + +This is a non-breaking addition (new optional field with sensible default `[]`). + +--- + +## Correctness Properties + +*A property is a characteristic or behavior that should hold true across all valid executions of a +system — essentially, a formal statement about what the system should do. Properties serve as the +bridge between human-readable specifications and machine-verifiable correctness guarantees.* + +### Property 1: Tier mapping correctness + +*For any* non-empty array of remaining TTL values (in ledgers) and any valid `IntervalPolicy`, each +individual TTL must be classified into exactly the tier whose boundary it falls within, and the +result must equal the minimum of all per-entry tier intervals before clamping. + +**Validates: Requirements 1.1, 1.2, 3.2** + +### Property 2: Range invariant + +*For any* array of remaining TTL values (including the empty array) and any valid `IntervalPolicy`, +the value returned by `computeEffectiveInterval` must satisfy +`minIntervalMs ≤ result ≤ maxIntervalMs`. + +**Validates: Requirements 1.3, 1.4, 1.5, 4.2, 4.3** + +### Property 3: Purity and determinism + +*For any* array of remaining TTL values and optional policy, calling `computeEffectiveInterval` +twice with identical arguments must return identical results — no hidden state, no side-effects. + +**Validates: Requirements 4.4** + +### Property 4: Uniform-array invariant + +*For any* remaining TTL value `v` and any positive integer `n`, calling +`computeEffectiveInterval([v, v, ..., v])` (n copies of v) must return the same result regardless +of `n`. + +**Validates: Requirements 4.5** + +--- + +## Error Handling + +### Configuration validation errors + +`validateIntervalPolicy` is called synchronously at the top of `startDaemon`, before any async +work begins. If it throws, `startDaemon` re-throws the same error and no timer or cycle is ever +scheduled. The error message identifies the offending field and its value. + +### Cycle errors — interval fallback + +When `runMonitorCycle` throws or returns a non-empty `errors` array, the Scheduler: + +1. Retains `currentEffectiveMs` — the interval computed from the previous successful cycle. +2. Schedules the next timer using `currentEffectiveMs` unchanged. +3. Logs the error count alongside the retained interval (see Observability below). + +On startup, `currentEffectiveMs` is initialised to `maxIntervalMs` so that the first failed +cycle schedules the next cycle at the maximum interval rather than zero. + +### RPC TTL data gaps + +If an entry is not returned by the RPC (possibly archived), `remainingTTL` for that entry is not +included in the array. Missing entries are therefore invisible to the interval computation — they +do not push the interval toward zero, which is the conservative safe choice. The monitor already +logs a debug message for each missing entry. + +### Negative or zero TTL values + +`remainingTTL` can be zero or negative when a ledger entry has already expired ( +`liveUntilLedgerSeq < latestLedger`). Values ≤ 0 fall into the critical tier (< 720 threshold) and +produce the minimum 60-second interval. No special-casing is needed beyond what the tier table +already provides. + +--- + +## Observability + +All scheduler log lines use the `DaemonLoop` component binding already established in `loop.ts`. + +### Rescheduling log (info, every cycle) + +``` +Scheduler — next cycle in {effectiveMs}ms | minTTL={minRemainingTTL} ledgers | errors={errorCount} +``` + +When the interval changed relative to the previous cycle, append: + +``` + | intervalChanged: {previousMs}ms → {effectiveMs}ms +``` + +### Empty watch list log (debug) + +``` +Scheduler — no TTL data available; using maxInterval={maxIntervalMs}ms +``` + +Emitted when `remainingTTLs` is empty (watch list is empty or all entries were missing from RPC). + +### Re-entrance skip log (debug, existing) + +The existing `"Skipping tick — previous cycle still in flight"` message is retained unchanged. + +--- + +## Testing Strategy + +### Dual testing approach + +Unit tests cover specific examples, edge cases, and wiring. Property tests cover universal +invariants of the pure `computeEffectiveInterval` function. + +### Property-based tests (`tests/daemon/interval.property.test.ts`) + +The project uses **Vitest** with **fast-check** as the property-based testing library. + +Install as a dev dependency: +``` +fast-check@^3.x +``` + +Each property test is tagged with a comment referencing the design property: +``` +// Feature: adaptive-polling-intervals, Property N: +``` + +Minimum **100 runs** per property (fast-check default is 100; configure with `{ numRuns: 100 }`). + +#### Property 1 — Tier mapping correctness + +Generate `remainingTTL` values in each tier range. For each generated value: +- TTL in `[0, TTL_TIER_CRITICAL)` → result must equal `criticalIntervalMs` (before clamping) +- TTL in `[TTL_TIER_CRITICAL, TTL_TIER_DAY)` → `dayIntervalMs` +- TTL in `[TTL_TIER_DAY, TTL_TIER_WEEK)` → `weekIntervalMs` +- TTL ≥ `TTL_TIER_WEEK` → `safeIntervalMs` + +Also test: for arrays of mixed-tier TTLs, result equals the minimum of all per-entry tiers +(subject to clamping). + +#### Property 2 — Range invariant + +Use `fc.array(fc.integer({ min: -1000, max: 500_000 }))` to generate arbitrary TTL arrays +(including empty, including negative values). Assert: + +``` +result >= DEFAULT_MIN_INTERVAL_MS && result <= DEFAULT_MAX_INTERVAL_MS +``` + +#### Property 3 — Purity / determinism + +Generate `fc.array(fc.nat())`. Call `computeEffectiveInterval(ttls)` twice. Assert results are +strictly equal (`===`). + +#### Property 4 — Uniform-array invariant + +Generate `fc.nat()` for value and `fc.integer({ min: 1, max: 50 })` for length. Call +`computeEffectiveInterval` with `Array(length).fill(value)`. Assert all results are equal. + +### Unit tests (`tests/daemon/interval.test.ts`) + +Concrete examples covering: +- Default tier boundaries (exactly at each boundary value) +- `intervalMs` scalar override disables adaptive logic +- `validateIntervalPolicy` rejects tier < 10,000 ms +- `validateIntervalPolicy` rejects minInterval > tier interval +- Empty array returns `maxIntervalMs` +- Negative TTL classified as critical tier + +### Integration tests (`tests/daemon/loop.adaptive.test.ts`) + +Extend the existing `loop.test.ts` pattern with mocked `runMonitorCycle`. Cover: +- Successful cycle: next `setTimeout` delay equals `computeEffectiveInterval(cycle.remainingTTLs)` +- Error cycle: next delay retains previous effective interval +- `intervalMs` override: delay is always `intervalMs` regardless of TTL data +- `intervalPolicy` custom tiers are used when supplied +- Interval-changed log entry is emitted when interval changes +- Empty watch list logs at debug level + +### Existing tests + +All existing tests in `tests/daemon/loop.test.ts` must continue to pass without modification. +The `setInterval` → `setTimeout` switch requires updating the loop.test.ts timer-advance tests +to use `vi.advanceTimersByTimeAsync` correctly for chained timeouts — Vitest handles these +identically. diff --git a/.kiro/specs/adaptive-polling-intervals/tasks.md b/.kiro/specs/adaptive-polling-intervals/tasks.md new file mode 100644 index 00000000..a94ecc25 --- /dev/null +++ b/.kiro/specs/adaptive-polling-intervals/tasks.md @@ -0,0 +1,209 @@ +# Implementation Plan: Adaptive Polling Intervals + +## Overview + +Replace the static `setInterval` daemon loop with a `setTimeout`-chained adaptive scheduler that +tightens polling frequency as contract TTLs approach expiry. The change introduces a new pure +module (`src/daemon/interval.ts`) for all interval arithmetic, extends `MonitorCycleResult` with +`remainingTTLs`, and wires the computation into `loop.ts` while preserving full backward +compatibility with the existing `intervalMs` option. + +## Tasks + +- [ ] 1. Install fast-check dev dependency + - Add `fast-check@^3.22.0` (or latest 3.x) as a dev dependency via `npm install --save-dev fast-check@^3.22.0` + - Confirm the version resolves and that `package.json` devDependencies is updated + - _Requirements: (testing infrastructure — supports Requirements 4.1–4.5)_ + +- [ ] 2. Create `src/daemon/interval.ts` — pure interval computation module + - [ ] 2.1 Define exported constants and `IntervalPolicy` interface + - Export tier boundary constants: `TTL_TIER_CRITICAL = 720`, `TTL_TIER_DAY = 17_280`, `TTL_TIER_WEEK = 120_960` + - Export default interval constants: `DEFAULT_MIN_INTERVAL_MS`, `DEFAULT_MAX_INTERVAL_MS`, `DEFAULT_INTERVAL_CRITICAL_MS`, `DEFAULT_INTERVAL_DAY_MS`, `DEFAULT_INTERVAL_WEEK_MS`, `DEFAULT_INTERVAL_SAFE_MS` + - Define and export `IntervalPolicy` interface with all optional fields (`minIntervalMs`, `maxIntervalMs`, `criticalIntervalMs`, `dayIntervalMs`, `weekIntervalMs`, `safeIntervalMs`, `criticalTtlLedgers`, `dayTtlLedgers`, `weekTtlLedgers`) + - No imports from `logging/` or any I/O module — pure module only + - _Requirements: 1.1, 3.2, 3.3, 4.1_ + + - [ ] 2.2 Implement `ttlToIntervalMs(remainingTTL: number, policy?: IntervalPolicy): number` + - Merge supplied policy over defaults to resolve all tier parameters + - Apply four-tier classification: `< criticalTtlLedgers` → `criticalIntervalMs`; `< dayTtlLedgers` → `dayIntervalMs`; `< weekTtlLedgers` → `weekIntervalMs`; else → `safeIntervalMs` + - Return the raw (unclamped) tier interval + - Negative and zero TTL values fall into the critical tier (< 720 threshold) naturally + - _Requirements: 1.1, 3.2_ + + - [ ] 2.3 Implement `computeEffectiveInterval(remainingTTLs: number[], policy?: IntervalPolicy): number` + - Return `maxIntervalMs` when `remainingTTLs` is empty + - Map each TTL through `ttlToIntervalMs`, take the minimum + - Clamp result to `[minIntervalMs, maxIntervalMs]` + - Function must be pure and deterministic (no side-effects) + - _Requirements: 1.1, 1.2, 1.3, 1.4, 1.5, 4.1, 4.2, 4.3, 4.4, 4.5_ + + - [ ] 2.4 Implement `validateIntervalPolicy(policy: IntervalPolicy | undefined): void` + - No-op for `undefined` or `null` + - Throw descriptive `Error` if any tier interval < 10,000 ms: `IntervalPolicy: {field} must be ≥ 10,000 ms (got {value})` + - Throw descriptive `Error` if `minIntervalMs` > any tier interval: `IntervalPolicy: minIntervalMs ({value}) exceeds tier interval {field} ({tierValue})` + - _Requirements: 3.4, 3.5_ + +- [ ] 3. Extend `MonitorCycleResult` in `src/core/monitor.ts` + - Add `remainingTTLs: number[]` field to the `MonitorCycleResult` interface (non-breaking — initialise to `[]` in `runMonitorCycle`) + - Populate `remainingTTLs` inside `processContract` by appending `rpcEntry.remainingTTL` to the result array for each successfully updated entry (where `rpcEntry` is defined) + - _Requirements: 4.1, 2.1_ + +- [ ] 4. Refactor `src/daemon/loop.ts` — Scheduler wiring + - [ ] 4.1 Update `DaemonOptions` interface and module-level state + - Add `intervalPolicy?: IntervalPolicy` field to `DaemonOptions` + - Replace `intervalHandle: ReturnType` with `timeoutHandle: ReturnType | null` + - Add module-level `currentEffectiveMs: number` (initialised to `DEFAULT_MAX_INTERVAL_MS`) and `activePolicy: IntervalPolicy | undefined` + - Import `computeEffectiveInterval`, `validateIntervalPolicy`, `IntervalPolicy`, and `DEFAULT_MAX_INTERVAL_MS` from `./interval.js` + - _Requirements: 3.1, 3.2, 3.3_ + + - [ ] 4.2 Implement backward-compatible policy resolution in `startDaemon` + - Call `validateIntervalPolicy(options?.intervalPolicy)` before any async work; let it throw on invalid config + - When `intervalMs` is present, synthesise `activePolicy = { minIntervalMs: intervalMs, maxIntervalMs: intervalMs }` to disable adaptive behaviour + - When only `intervalPolicy` is present, assign `activePolicy = options.intervalPolicy` + - When neither is present, leave `activePolicy = undefined` (built-in defaults apply) + - Initialise `currentEffectiveMs` to resolved `maxIntervalMs` (or `DEFAULT_MAX_INTERVAL_MS`) + - _Requirements: 3.1, 3.2, 3.3_ + + - [ ] 4.3 Replace `setInterval` with chained `setTimeout` scheduling + - Replace `clearInterval` / `setInterval` calls with `clearTimeout` / `setTimeout` + - After the initial cycle in `startDaemon`, call a new `scheduleNext` helper to compute the interval and set the next timeout + - In `scheduledTick`, after a completed cycle, call `scheduleNext` again (chained pattern) + - `stopDaemon` must call `clearTimeout(timeoutHandle)` and set `timeoutHandle = null` + - _Requirements: 2.3, 2.4, 2.5_ + + - [ ] 4.4 Implement `scheduleNext` helper and interval computation + - On successful cycle (zero errors and no thrown exception): call `computeEffectiveInterval(result.remainingTTLs, activePolicy)` and store in `currentEffectiveMs` + - On error cycle (non-empty `errors` or thrown exception): retain existing `currentEffectiveMs` unchanged + - Clear the existing timeout, then call `setTimeout(tick, currentEffectiveMs)` where `tick` is the `scheduledTick` closure + - _Requirements: 2.1, 2.2, 2.3_ + + - [ ] 4.5 Add re-entrance guard and structured logging + - Re-entrance guard in `scheduledTick`: if `cycleInFlight` is true, log at `debug` level (`"Skipping tick — previous cycle still in flight"`) and return without scheduling a new cycle; reschedule normally when the in-flight cycle finishes + - Emit `info` log after every rescheduling: `Scheduler — next cycle in {effectiveMs}ms | minTTL={minRemainingTTL} ledgers | errors={errorCount}` + - When interval changes vs previous cycle, append: ` | intervalChanged: {previousMs}ms → {effectiveMs}ms` + - When `remainingTTLs` is empty, emit `debug` log: `Scheduler — no TTL data available; using maxInterval={maxIntervalMs}ms` + - _Requirements: 5.1, 5.2, 5.3, 5.4, 6.1, 6.2, 6.3_ + +- [ ] 5. Checkpoint — verify build and existing tests pass + - Run `npm run build` and confirm zero TypeScript errors + - Run `npm test` and confirm all pre-existing tests in `tests/daemon/loop.test.ts` and other suites continue to pass + - Fix any timer-advance tests in `loop.test.ts` that relied on `setInterval` to use `vi.advanceTimersByTimeAsync` with chained `setTimeout` correctly + - Ensure all tests pass, ask the user if questions arise. + +- [ ] 6. Write unit tests — `tests/daemon/interval.test.ts` + - [ ] 6.1 Test `ttlToIntervalMs` tier classification with concrete boundary values + - TTL = 0 → critical interval (60,000 ms) + - TTL = -1 → critical interval (negative falls into critical tier) + - TTL = 719 → critical interval (one below `TTL_TIER_CRITICAL`) + - TTL = 720 → day interval (exactly at `TTL_TIER_CRITICAL`) + - TTL = 17,279 → day interval (one below `TTL_TIER_DAY`) + - TTL = 17,280 → week interval (exactly at `TTL_TIER_DAY`) + - TTL = 120,959 → week interval (one below `TTL_TIER_WEEK`) + - TTL = 120,960 → safe interval (exactly at `TTL_TIER_WEEK`) + - _Requirements: 1.1_ + + - [ ]* 6.2 Test `computeEffectiveInterval` with concrete examples + - Empty array → `DEFAULT_MAX_INTERVAL_MS` (3,600,000 ms) + - Single critical TTL → 60,000 ms + - Mixed-tier array → minimum of the per-entry tier values + - `intervalMs` scalar override → always returns the override value + - Custom `IntervalPolicy` tiers are applied correctly + - _Requirements: 1.2, 1.3, 3.1, 3.2, 4.2_ + + - [ ]* 6.3 Test `validateIntervalPolicy` error paths + - Policy with `criticalIntervalMs: 9999` → throws with message containing `criticalIntervalMs` and `10,000` + - Policy with `dayIntervalMs: 9999` → throws + - Policy with `minIntervalMs: 70_000` and `criticalIntervalMs: 60_000` → throws (minInterval > tier) + - Valid policy → does not throw + - `undefined` → does not throw + - _Requirements: 3.4, 3.5_ + +- [ ] 7. Write property-based tests — `tests/daemon/interval.property.test.ts` + - [ ]* 7.1 Property 1: Tier mapping correctness + - Tag: `// Feature: adaptive-polling-intervals, Property 1: Tier mapping correctness` + - Generate TTL values in each of the four tier ranges using `fc.integer` with appropriate bounds + - Assert each individual TTL is classified into the expected tier interval + - For mixed-tier arrays, assert result equals `clamp(min(per-entry tiers), min, max)` + - Configure `{ numRuns: 100 }` minimum + - _Requirements: 1.1, 1.2, 3.2_ + + - [ ]* 7.2 Property 2: Range invariant + - Tag: `// Feature: adaptive-polling-intervals, Property 2: Range invariant` + - Generate arbitrary TTL arrays including empty, negative values: `fc.array(fc.integer({ min: -1000, max: 500_000 }))` + - Assert `result >= DEFAULT_MIN_INTERVAL_MS && result <= DEFAULT_MAX_INTERVAL_MS` for all inputs + - Configure `{ numRuns: 100 }` minimum + - _Requirements: 1.3, 1.4, 1.5, 4.2, 4.3_ + + - [ ]* 7.3 Property 3: Purity and determinism + - Tag: `// Feature: adaptive-polling-intervals, Property 3: Purity and determinism` + - Generate `fc.array(fc.nat())`; call `computeEffectiveInterval(ttls)` twice; assert strict equality (`===`) + - Configure `{ numRuns: 100 }` minimum + - _Requirements: 4.4_ + + - [ ]* 7.4 Property 4: Uniform-array invariant + - Tag: `// Feature: adaptive-polling-intervals, Property 4: Uniform-array invariant` + - Generate `fc.nat()` for value and `fc.integer({ min: 1, max: 50 })` for length + - Assert `computeEffectiveInterval(Array(n).fill(v))` returns the same value for all `n ≥ 1` + - Configure `{ numRuns: 100 }` minimum + - _Requirements: 4.5_ + +- [ ] 8. Write integration tests — `tests/daemon/loop.adaptive.test.ts` + - [ ]* 8.1 Successful cycle uses computed interval for next `setTimeout` + - Mock `runMonitorCycle` to return a result with specific `remainingTTLs` + - Assert the delay passed to the next `setTimeout` equals `computeEffectiveInterval(remainingTTLs)` + - _Requirements: 2.1_ + + - [ ]* 8.2 Error cycle retains previous effective interval + - Mock `runMonitorCycle` first call succeeds (sets `currentEffectiveMs`), second call returns non-empty `errors` + - Assert next `setTimeout` delay equals the interval from the first successful cycle + - _Requirements: 2.2_ + + - [ ]* 8.3 `intervalMs` override disables adaptive behaviour + - Start daemon with `{ intervalMs: 15_000 }`; mock cycle with low TTL data + - Assert every `setTimeout` delay is exactly 15,000 ms + - _Requirements: 3.1_ + + - [ ]* 8.4 Custom `intervalPolicy` tiers are applied + - Supply a custom policy with a distinct `safeIntervalMs`; mock cycle with high TTL values + - Assert the resulting delay matches the custom policy's `safeIntervalMs` + - _Requirements: 3.2_ + + - [ ]* 8.5 Interval-change log entry emitted + - Spy on logger; drive two consecutive cycles where the computed interval changes + - Assert the `info` log contains both previous and new interval values + - _Requirements: 5.2_ + + - [ ]* 8.6 Empty watch list logs at debug level + - Mock `runMonitorCycle` to return `{ remainingTTLs: [] }` (empty watch list) + - Assert a `debug` log entry containing `maxInterval` is emitted + - _Requirements: 5.3_ + +- [ ] 9. Final checkpoint — Ensure all tests pass + - Run `npm test` and confirm all unit, property, and integration tests pass + - Run `npm run build` for a clean TypeScript compile + - Ensure all tests pass, ask the user if questions arise. + +## Notes + +- Tasks marked with `*` are optional and can be skipped for faster MVP +- Each task references specific requirements for traceability +- The `setInterval` → `setTimeout` chain change requires updating any existing timer tests in `loop.test.ts` that call `vi.advanceTimersByTimeAsync` — Vitest handles chained `setTimeout` identically +- `fast-check` must be installed (task 1) before property tests can be written (task 7) +- `MonitorCycleResult.remainingTTLs` (task 3) must be added before the integration tests in task 8 can work end-to-end +- All existing tests must continue to pass without modification; backward compatibility is enforced by the `intervalMs` degenerate-policy path + +## Task Dependency Graph + +```json +{ + "waves": [ + { "id": 0, "tasks": ["2.1"] }, + { "id": 1, "tasks": ["2.2", "2.3", "2.4"] }, + { "id": 2, "tasks": ["3", "6.1", "7.1", "7.2", "7.3", "7.4"] }, + { "id": 3, "tasks": ["4.1", "6.2", "6.3"] }, + { "id": 4, "tasks": ["4.2", "4.3"] }, + { "id": 5, "tasks": ["4.4", "4.5"] }, + { "id": 6, "tasks": ["8.1", "8.2", "8.3", "8.4", "8.5", "8.6"] } + ] +} +``` From 48bf6b1497e579b393fbcf5af2cb1176af20799b Mon Sep 17 00:00:00 2001 From: AbdulmalikAlayande <114596864+AbdulmalikAlayande@users.noreply.github.com> Date: Mon, 29 Jun 2026 10:23:11 +0100 Subject: [PATCH 3/3] fix(docs): address CodeRabbit review feedback --- .../adaptive-polling-intervals/design.md | 2 +- .../specs/adaptive-polling-intervals/tasks.md | 27 +++++++++---------- 2 files changed, 14 insertions(+), 15 deletions(-) diff --git a/.kiro/specs/adaptive-polling-intervals/design.md b/.kiro/specs/adaptive-polling-intervals/design.md index ad02ecb5..93458ed8 100644 --- a/.kiro/specs/adaptive-polling-intervals/design.md +++ b/.kiro/specs/adaptive-polling-intervals/design.md @@ -285,7 +285,7 @@ export interface MonitorCycleResult { * in this cycle. Used by the Scheduler to compute the next interval. * Empty when no entries were updated or the cycle failed. */ - remainingTTLs: number[]; + remainingTTLs?: number[]; } ``` diff --git a/.kiro/specs/adaptive-polling-intervals/tasks.md b/.kiro/specs/adaptive-polling-intervals/tasks.md index a94ecc25..274ac9f9 100644 --- a/.kiro/specs/adaptive-polling-intervals/tasks.md +++ b/.kiro/specs/adaptive-polling-intervals/tasks.md @@ -80,7 +80,7 @@ compatibility with the existing `intervalMs` option. - [ ] 4.5 Add re-entrance guard and structured logging - Re-entrance guard in `scheduledTick`: if `cycleInFlight` is true, log at `debug` level (`"Skipping tick — previous cycle still in flight"`) and return without scheduling a new cycle; reschedule normally when the in-flight cycle finishes - Emit `info` log after every rescheduling: `Scheduler — next cycle in {effectiveMs}ms | minTTL={minRemainingTTL} ledgers | errors={errorCount}` - - When interval changes vs previous cycle, append: ` | intervalChanged: {previousMs}ms → {effectiveMs}ms` + - When interval changes vs previous cycle, append: | `intervalChanged: {previousMs}ms → {effectiveMs}ms` - When `remainingTTLs` is empty, emit `debug` log: `Scheduler — no TTL data available; using maxInterval={maxIntervalMs}ms` - _Requirements: 5.1, 5.2, 5.3, 5.4, 6.1, 6.2, 6.3_ @@ -102,7 +102,7 @@ compatibility with the existing `intervalMs` option. - TTL = 120,960 → safe interval (exactly at `TTL_TIER_WEEK`) - _Requirements: 1.1_ - - [ ]* 6.2 Test `computeEffectiveInterval` with concrete examples + - [ ] 6.2 Test `computeEffectiveInterval` with concrete examples - Empty array → `DEFAULT_MAX_INTERVAL_MS` (3,600,000 ms) - Single critical TTL → 60,000 ms - Mixed-tier array → minimum of the per-entry tier values @@ -110,7 +110,7 @@ compatibility with the existing `intervalMs` option. - Custom `IntervalPolicy` tiers are applied correctly - _Requirements: 1.2, 1.3, 3.1, 3.2, 4.2_ - - [ ]* 6.3 Test `validateIntervalPolicy` error paths + - [ ] 6.3 Test `validateIntervalPolicy` error paths - Policy with `criticalIntervalMs: 9999` → throws with message containing `criticalIntervalMs` and `10,000` - Policy with `dayIntervalMs: 9999` → throws - Policy with `minIntervalMs: 70_000` and `criticalIntervalMs: 60_000` → throws (minInterval > tier) @@ -119,7 +119,7 @@ compatibility with the existing `intervalMs` option. - _Requirements: 3.4, 3.5_ - [ ] 7. Write property-based tests — `tests/daemon/interval.property.test.ts` - - [ ]* 7.1 Property 1: Tier mapping correctness + - [ ] 7.1 Property 1: Tier mapping correctness - Tag: `// Feature: adaptive-polling-intervals, Property 1: Tier mapping correctness` - Generate TTL values in each of the four tier ranges using `fc.integer` with appropriate bounds - Assert each individual TTL is classified into the expected tier interval @@ -127,20 +127,20 @@ compatibility with the existing `intervalMs` option. - Configure `{ numRuns: 100 }` minimum - _Requirements: 1.1, 1.2, 3.2_ - - [ ]* 7.2 Property 2: Range invariant + - [ ] 7.2 Property 2: Range invariant - Tag: `// Feature: adaptive-polling-intervals, Property 2: Range invariant` - Generate arbitrary TTL arrays including empty, negative values: `fc.array(fc.integer({ min: -1000, max: 500_000 }))` - Assert `result >= DEFAULT_MIN_INTERVAL_MS && result <= DEFAULT_MAX_INTERVAL_MS` for all inputs - Configure `{ numRuns: 100 }` minimum - _Requirements: 1.3, 1.4, 1.5, 4.2, 4.3_ - - [ ]* 7.3 Property 3: Purity and determinism + - [ ] 7.3 Property 3: Purity and determinism - Tag: `// Feature: adaptive-polling-intervals, Property 3: Purity and determinism` - Generate `fc.array(fc.nat())`; call `computeEffectiveInterval(ttls)` twice; assert strict equality (`===`) - Configure `{ numRuns: 100 }` minimum - _Requirements: 4.4_ - - [ ]* 7.4 Property 4: Uniform-array invariant + - [ ] 7.4 Property 4: Uniform-array invariant - Tag: `// Feature: adaptive-polling-intervals, Property 4: Uniform-array invariant` - Generate `fc.nat()` for value and `fc.integer({ min: 1, max: 50 })` for length - Assert `computeEffectiveInterval(Array(n).fill(v))` returns the same value for all `n ≥ 1` @@ -148,32 +148,32 @@ compatibility with the existing `intervalMs` option. - _Requirements: 4.5_ - [ ] 8. Write integration tests — `tests/daemon/loop.adaptive.test.ts` - - [ ]* 8.1 Successful cycle uses computed interval for next `setTimeout` + - [ ] 8.1 Successful cycle uses computed interval for next `setTimeout` - Mock `runMonitorCycle` to return a result with specific `remainingTTLs` - Assert the delay passed to the next `setTimeout` equals `computeEffectiveInterval(remainingTTLs)` - _Requirements: 2.1_ - - [ ]* 8.2 Error cycle retains previous effective interval + - [ ] 8.2 Error cycle retains previous effective interval - Mock `runMonitorCycle` first call succeeds (sets `currentEffectiveMs`), second call returns non-empty `errors` - Assert next `setTimeout` delay equals the interval from the first successful cycle - _Requirements: 2.2_ - - [ ]* 8.3 `intervalMs` override disables adaptive behaviour + - [ ] 8.3 `intervalMs` override disables adaptive behaviour - Start daemon with `{ intervalMs: 15_000 }`; mock cycle with low TTL data - Assert every `setTimeout` delay is exactly 15,000 ms - _Requirements: 3.1_ - - [ ]* 8.4 Custom `intervalPolicy` tiers are applied + - [ ] 8.4 Custom `intervalPolicy` tiers are applied - Supply a custom policy with a distinct `safeIntervalMs`; mock cycle with high TTL values - Assert the resulting delay matches the custom policy's `safeIntervalMs` - _Requirements: 3.2_ - - [ ]* 8.5 Interval-change log entry emitted + - [ ] 8.5 Interval-change log entry emitted - Spy on logger; drive two consecutive cycles where the computed interval changes - Assert the `info` log contains both previous and new interval values - _Requirements: 5.2_ - - [ ]* 8.6 Empty watch list logs at debug level + - [ ] 8.6 Empty watch list logs at debug level - Mock `runMonitorCycle` to return `{ remainingTTLs: [] }` (empty watch list) - Assert a `debug` log entry containing `maxInterval` is emitted - _Requirements: 5.3_ @@ -185,7 +185,6 @@ compatibility with the existing `intervalMs` option. ## Notes -- Tasks marked with `*` are optional and can be skipped for faster MVP - Each task references specific requirements for traceability - The `setInterval` → `setTimeout` chain change requires updating any existing timer tests in `loop.test.ts` that call `vi.advanceTimersByTimeAsync` — Vitest handles chained `setTimeout` identically - `fast-check` must be installed (task 1) before property tests can be written (task 7)