From 01b24e71b9ce43ef759fca8ed2ed107cb6e123cd Mon Sep 17 00:00:00 2001 From: William Valentin Date: Mon, 16 Feb 2026 18:57:56 -0800 Subject: [PATCH] feat(companion): add heartbeat loop jitter controls --- README.md | 2 +- docs/plans/state.json | 13 +++++++++++++ src/companion/heartbeatLoop.test.ts | 21 +++++++++++++++++++++ src/companion/heartbeatLoop.ts | 24 +++++++++++++++++++++++- 4 files changed, 58 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 9edb5db..b86b949 100644 --- a/README.md +++ b/README.md @@ -1199,7 +1199,7 @@ Companion runtime helper: - shared `publishHeartbeat()` helper for periodic `node.status.set` updates with safe defaults - `createHeartbeatLoop()` convenience helper that returns a bound `CompanionHeartbeatLoop` - optional `defaultSessionId` for canvas helper calls so `sessionId` can be omitted per call -- `src/companion/heartbeatLoop.ts` provides `CompanionHeartbeatLoop` for periodic heartbeat scheduling (`publishHeartbeat`) with start/stop safety, `tickNow()` for manual sends, error hooks, and optional auto-stop after repeated failures. +- `src/companion/heartbeatLoop.ts` provides `CompanionHeartbeatLoop` for periodic heartbeat scheduling (`publishHeartbeat`) with start/stop safety, optional interval jitter (`jitterRatio`) to spread load, `tickNow()` for manual sends, error hooks, and optional auto-stop after repeated failures. ## Canvas / A2UI Foundation diff --git a/docs/plans/state.json b/docs/plans/state.json index 7c7dfc8..bded8f5 100644 --- a/docs/plans/state.json +++ b/docs/plans/state.json @@ -408,6 +408,19 @@ ], "test_status": "pnpm test:run src/companion/runtimeClient.test.ts src/companion/platformClients.test.ts src/companion/platformClients.integration.test.ts src/companion/heartbeatLoop.test.ts + pnpm typecheck passing" }, + "companion-heartbeat-loop-jitter": { + "status": "completed", + "date": "2026-02-17", + "updated": "2026-02-17", + "summary": "Added optional heartbeat interval jitter controls (`jitterRatio`) to `CompanionHeartbeatLoop` so large companion fleets can spread heartbeat load over time.", + "files_modified": [ + "src/companion/heartbeatLoop.ts", + "src/companion/heartbeatLoop.test.ts", + "README.md", + "docs/plans/state.json" + ], + "test_status": "pnpm test:run src/companion/heartbeatLoop.test.ts src/companion/runtimeClient.test.ts src/companion/platformClients.test.ts src/companion/platformClients.integration.test.ts + pnpm typecheck passing" + }, "browser-tools-activation-clarity": { "status": "completed", "date": "2026-02-17", diff --git a/src/companion/heartbeatLoop.test.ts b/src/companion/heartbeatLoop.test.ts index 3f4928d..b5cb36f 100644 --- a/src/companion/heartbeatLoop.test.ts +++ b/src/companion/heartbeatLoop.test.ts @@ -42,6 +42,12 @@ describe('CompanionHeartbeatLoop', () => { expect(() => new CompanionHeartbeatLoop(publisher, { intervalMs: 0 })).toThrow( 'intervalMs must be a positive number', ); + expect(() => new CompanionHeartbeatLoop(publisher, { jitterRatio: -0.1 })).toThrow( + 'jitterRatio must be between 0 and 1', + ); + expect(() => new CompanionHeartbeatLoop(publisher, { jitterRatio: 1.1 })).toThrow( + 'jitterRatio must be between 0 and 1', + ); expect(() => new CompanionHeartbeatLoop(publisher, { maxConsecutiveFailures: 0 })).toThrow( 'maxConsecutiveFailures must be >= 1 when specified', ); @@ -60,6 +66,21 @@ describe('CompanionHeartbeatLoop', () => { loop.stop(); }); + it('applies jitterRatio to scheduling delay bounds', async () => { + const publishHeartbeat = vi.fn(async () => buildStatusResult()); + const loop = new CompanionHeartbeatLoop( + { publishHeartbeat }, + { intervalMs: 1000, jitterRatio: 0.5, randomFn: () => 1 }, + ); + + loop.start(false); + await vi.advanceTimersByTimeAsync(1499); + expect(publishHeartbeat).toHaveBeenCalledTimes(0); + await vi.advanceTimersByTimeAsync(1); + expect(publishHeartbeat).toHaveBeenCalledTimes(1); + loop.stop(); + }); + it('passes buildHeartbeat payload into publishHeartbeat', async () => { const publishHeartbeat = vi.fn(async () => buildStatusResult()); const buildHeartbeat = vi.fn(() => ({ statusText: 'loop', powerSource: 'ac' as const })); diff --git a/src/companion/heartbeatLoop.ts b/src/companion/heartbeatLoop.ts index 04c1469..a8faea4 100644 --- a/src/companion/heartbeatLoop.ts +++ b/src/companion/heartbeatLoop.ts @@ -7,10 +7,12 @@ export interface HeartbeatPublisher { export interface CompanionHeartbeatLoopOptions { intervalMs?: number; + jitterRatio?: number; buildHeartbeat?: () => HeartbeatStatusInput | Promise; onError?: (error: Error) => void; maxConsecutiveFailures?: number; onFailureLimitReached?: (error: Error, consecutiveFailures: number) => void; + randomFn?: () => number; } /** @@ -20,10 +22,12 @@ export interface CompanionHeartbeatLoopOptions { export class CompanionHeartbeatLoop { private readonly publisher: HeartbeatPublisher; private readonly intervalMs: number; + private readonly jitterRatio: number; private readonly buildHeartbeat?: () => HeartbeatStatusInput | Promise; private readonly onError?: (error: Error) => void; private readonly maxConsecutiveFailures: number; private readonly onFailureLimitReached?: (error: Error, consecutiveFailures: number) => void; + private readonly randomFn: () => number; private timer: NodeJS.Timeout | null = null; private started = false; @@ -35,6 +39,10 @@ export class CompanionHeartbeatLoop { if (!Number.isFinite(intervalMs) || intervalMs <= 0) { throw new Error('intervalMs must be a positive number'); } + const jitterRatio = options.jitterRatio ?? 0; + if (!Number.isFinite(jitterRatio) || jitterRatio < 0 || jitterRatio > 1) { + throw new Error('jitterRatio must be between 0 and 1'); + } const maxConsecutiveFailures = options.maxConsecutiveFailures ?? Number.POSITIVE_INFINITY; if (!Number.isFinite(maxConsecutiveFailures) && maxConsecutiveFailures !== Number.POSITIVE_INFINITY) { throw new Error('maxConsecutiveFailures must be a positive number or Infinity'); @@ -45,10 +53,12 @@ export class CompanionHeartbeatLoop { this.publisher = publisher; this.intervalMs = intervalMs; + this.jitterRatio = jitterRatio; this.buildHeartbeat = options.buildHeartbeat; this.onError = options.onError; this.maxConsecutiveFailures = maxConsecutiveFailures; this.onFailureLimitReached = options.onFailureLimitReached; + this.randomFn = options.randomFn ?? Math.random; } get running(): boolean { @@ -85,9 +95,21 @@ export class CompanionHeartbeatLoop { if (!this.started) { return; } + const delay = this.computeDelayMs(); this.timer = setTimeout(() => { void this.tick(); - }, this.intervalMs); + }, delay); + } + + private computeDelayMs(): number { + if (this.jitterRatio === 0) { + return this.intervalMs; + } + const minFactor = 1 - this.jitterRatio; + const maxFactor = 1 + this.jitterRatio; + const random = this.randomFn(); + const factor = minFactor + (maxFactor - minFactor) * random; + return Math.max(1, Math.round(this.intervalMs * factor)); } private async tick(force = false): Promise {