feat(companion): add heartbeat loop jitter controls

This commit is contained in:
William Valentin
2026-02-16 18:57:56 -08:00
parent fd59d88c0c
commit 01b24e71b9
4 changed files with 58 additions and 2 deletions
+1 -1
View File
@@ -1199,7 +1199,7 @@ Companion runtime helper:
- shared `publishHeartbeat()` helper for periodic `node.status.set` updates with safe defaults - shared `publishHeartbeat()` helper for periodic `node.status.set` updates with safe defaults
- `createHeartbeatLoop()` convenience helper that returns a bound `CompanionHeartbeatLoop` - `createHeartbeatLoop()` convenience helper that returns a bound `CompanionHeartbeatLoop`
- optional `defaultSessionId` for canvas helper calls so `sessionId` can be omitted per call - 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 ## Canvas / A2UI Foundation
+13
View File
@@ -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" "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": { "browser-tools-activation-clarity": {
"status": "completed", "status": "completed",
"date": "2026-02-17", "date": "2026-02-17",
+21
View File
@@ -42,6 +42,12 @@ describe('CompanionHeartbeatLoop', () => {
expect(() => new CompanionHeartbeatLoop(publisher, { intervalMs: 0 })).toThrow( expect(() => new CompanionHeartbeatLoop(publisher, { intervalMs: 0 })).toThrow(
'intervalMs must be a positive number', '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( expect(() => new CompanionHeartbeatLoop(publisher, { maxConsecutiveFailures: 0 })).toThrow(
'maxConsecutiveFailures must be >= 1 when specified', 'maxConsecutiveFailures must be >= 1 when specified',
); );
@@ -60,6 +66,21 @@ describe('CompanionHeartbeatLoop', () => {
loop.stop(); 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 () => { it('passes buildHeartbeat payload into publishHeartbeat', async () => {
const publishHeartbeat = vi.fn(async () => buildStatusResult()); const publishHeartbeat = vi.fn(async () => buildStatusResult());
const buildHeartbeat = vi.fn(() => ({ statusText: 'loop', powerSource: 'ac' as const })); const buildHeartbeat = vi.fn(() => ({ statusText: 'loop', powerSource: 'ac' as const }));
+23 -1
View File
@@ -7,10 +7,12 @@ export interface HeartbeatPublisher {
export interface CompanionHeartbeatLoopOptions { export interface CompanionHeartbeatLoopOptions {
intervalMs?: number; intervalMs?: number;
jitterRatio?: number;
buildHeartbeat?: () => HeartbeatStatusInput | Promise<HeartbeatStatusInput>; buildHeartbeat?: () => HeartbeatStatusInput | Promise<HeartbeatStatusInput>;
onError?: (error: Error) => void; onError?: (error: Error) => void;
maxConsecutiveFailures?: number; maxConsecutiveFailures?: number;
onFailureLimitReached?: (error: Error, consecutiveFailures: number) => void; onFailureLimitReached?: (error: Error, consecutiveFailures: number) => void;
randomFn?: () => number;
} }
/** /**
@@ -20,10 +22,12 @@ export interface CompanionHeartbeatLoopOptions {
export class CompanionHeartbeatLoop { export class CompanionHeartbeatLoop {
private readonly publisher: HeartbeatPublisher; private readonly publisher: HeartbeatPublisher;
private readonly intervalMs: number; private readonly intervalMs: number;
private readonly jitterRatio: number;
private readonly buildHeartbeat?: () => HeartbeatStatusInput | Promise<HeartbeatStatusInput>; private readonly buildHeartbeat?: () => HeartbeatStatusInput | Promise<HeartbeatStatusInput>;
private readonly onError?: (error: Error) => void; private readonly onError?: (error: Error) => void;
private readonly maxConsecutiveFailures: number; private readonly maxConsecutiveFailures: number;
private readonly onFailureLimitReached?: (error: Error, consecutiveFailures: number) => void; private readonly onFailureLimitReached?: (error: Error, consecutiveFailures: number) => void;
private readonly randomFn: () => number;
private timer: NodeJS.Timeout | null = null; private timer: NodeJS.Timeout | null = null;
private started = false; private started = false;
@@ -35,6 +39,10 @@ export class CompanionHeartbeatLoop {
if (!Number.isFinite(intervalMs) || intervalMs <= 0) { if (!Number.isFinite(intervalMs) || intervalMs <= 0) {
throw new Error('intervalMs must be a positive number'); 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; const maxConsecutiveFailures = options.maxConsecutiveFailures ?? Number.POSITIVE_INFINITY;
if (!Number.isFinite(maxConsecutiveFailures) && maxConsecutiveFailures !== Number.POSITIVE_INFINITY) { if (!Number.isFinite(maxConsecutiveFailures) && maxConsecutiveFailures !== Number.POSITIVE_INFINITY) {
throw new Error('maxConsecutiveFailures must be a positive number or Infinity'); throw new Error('maxConsecutiveFailures must be a positive number or Infinity');
@@ -45,10 +53,12 @@ export class CompanionHeartbeatLoop {
this.publisher = publisher; this.publisher = publisher;
this.intervalMs = intervalMs; this.intervalMs = intervalMs;
this.jitterRatio = jitterRatio;
this.buildHeartbeat = options.buildHeartbeat; this.buildHeartbeat = options.buildHeartbeat;
this.onError = options.onError; this.onError = options.onError;
this.maxConsecutiveFailures = maxConsecutiveFailures; this.maxConsecutiveFailures = maxConsecutiveFailures;
this.onFailureLimitReached = options.onFailureLimitReached; this.onFailureLimitReached = options.onFailureLimitReached;
this.randomFn = options.randomFn ?? Math.random;
} }
get running(): boolean { get running(): boolean {
@@ -85,9 +95,21 @@ export class CompanionHeartbeatLoop {
if (!this.started) { if (!this.started) {
return; return;
} }
const delay = this.computeDelayMs();
this.timer = setTimeout(() => { this.timer = setTimeout(() => {
void this.tick(); 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<void> { private async tick(force = false): Promise<void> {