From 369250077a8f9ef5114eca47d13d8b446cecfceb Mon Sep 17 00:00:00 2001 From: William Valentin Date: Mon, 16 Feb 2026 19:37:43 -0800 Subject: [PATCH] feat(companion): add heartbeat success observability state --- README.md | 2 +- docs/plans/state.json | 13 +++++++++++++ src/companion/heartbeatLoop.test.ts | 20 ++++++++++++++++++++ src/companion/heartbeatLoop.ts | 18 ++++++++++++++++++ 4 files changed, 52 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index a649f51..4404e24 100644 --- a/README.md +++ b/README.md @@ -1201,7 +1201,7 @@ Companion runtime helper: - optional `defaultSessionId` for canvas helper calls so `sessionId` can be omitted per call - lifecycle passthroughs for connection state/teardown (`connected`, `dispose(code?, reason?)`) - stream passthrough helpers (`subscribeEvents`, `subscribeEvent`, `clearEventSubscriptions`, `listKnownEventNames`, `eventSubscriptionCount`, `subscribeAgentStream/Typing/ContextWarning`, `waitForEvent`, `waitForAnyEvent`, `waitForAgentStream/Typing/ContextWarning`) -- `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, success/error hooks, failure observability (`failureCount`, `lastFailure`, `getState()`), 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, success/error hooks, loop observability (`successCount`, `lastSuccessAt`, `failureCount`, `lastFailure`, `getState()`), and optional auto-stop after repeated failures. ## Canvas / A2UI Foundation diff --git a/docs/plans/state.json b/docs/plans/state.json index 716c599..1b6b902 100644 --- a/docs/plans/state.json +++ b/docs/plans/state.json @@ -725,6 +725,19 @@ ], "test_status": "pnpm test:run src/companion/platformClients.test.ts src/companion/runtimeClient.test.ts src/companion/heartbeatLoop.test.ts src/companion/platformClients.integration.test.ts + pnpm typecheck passing" }, + "companion-heartbeat-loop-success-observability": { + "status": "completed", + "date": "2026-02-17", + "updated": "2026-02-17", + "summary": "Extended `CompanionHeartbeatLoop` state observability with success metrics (`successCount`, `lastSuccessAt`) so callers can track successful heartbeat progression in addition to failure state.", + "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/platformClients.test.ts src/companion/runtimeClient.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 43d8dcc..8e9931b 100644 --- a/src/companion/heartbeatLoop.test.ts +++ b/src/companion/heartbeatLoop.test.ts @@ -96,6 +96,24 @@ describe('CompanionHeartbeatLoop', () => { loop.stop(); }); + it('tracks successCount and lastSuccessAt in loop state', async () => { + const publishHeartbeat = vi.fn(async () => buildStatusResult()); + const loop = new CompanionHeartbeatLoop({ publishHeartbeat }, { intervalMs: 300 }); + + expect(loop.successCount).toBe(0); + expect(loop.lastSuccessAt).toBeNull(); + + loop.start(); + await Promise.resolve(); + + expect(loop.successCount).toBe(1); + expect(loop.lastSuccessAt).not.toBeNull(); + expect(loop.getState().successCount).toBe(1); + expect(loop.getState().lastSuccessAt).toBe(loop.lastSuccessAt); + + 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 })); @@ -193,6 +211,8 @@ describe('CompanionHeartbeatLoop', () => { expect(loop.running).toBe(false); expect(loop.getState()).toEqual({ running: false, + successCount: 1, + lastSuccessAt: expect.any(Number), failureCount: 0, lastFailure: null, }); diff --git a/src/companion/heartbeatLoop.ts b/src/companion/heartbeatLoop.ts index 54983ad..68dd7bb 100644 --- a/src/companion/heartbeatLoop.ts +++ b/src/companion/heartbeatLoop.ts @@ -18,6 +18,8 @@ export interface CompanionHeartbeatLoopOptions { export interface CompanionHeartbeatLoopState { running: boolean; + successCount: number; + lastSuccessAt: number | null; failureCount: number; lastFailure: Error | null; } @@ -40,6 +42,8 @@ export class CompanionHeartbeatLoop { private timer: NodeJS.Timeout | null = null; private started = false; private inFlight = false; + private successfulHeartbeats = 0; + private lastSuccessAtMs: number | null = null; private consecutiveFailures = 0; private lastError: Error | null = null; @@ -79,6 +83,14 @@ export class CompanionHeartbeatLoop { return this.consecutiveFailures; } + get successCount(): number { + return this.successfulHeartbeats; + } + + get lastSuccessAt(): number | null { + return this.lastSuccessAtMs; + } + get lastFailure(): Error | null { return this.lastError; } @@ -86,6 +98,8 @@ export class CompanionHeartbeatLoop { getState(): CompanionHeartbeatLoopState { return { running: this.running, + successCount: this.successCount, + lastSuccessAt: this.lastSuccessAt, failureCount: this.failureCount, lastFailure: this.lastFailure, }; @@ -96,6 +110,8 @@ export class CompanionHeartbeatLoop { return; } this.started = true; + this.successfulHeartbeats = 0; + this.lastSuccessAtMs = null; this.consecutiveFailures = 0; this.lastError = null; @@ -147,6 +163,8 @@ export class CompanionHeartbeatLoop { try { const payload = this.buildHeartbeat ? await this.buildHeartbeat() : undefined; const result = await this.publisher.publishHeartbeat(payload); + this.successfulHeartbeats += 1; + this.lastSuccessAtMs = Date.now(); this.consecutiveFailures = 0; this.lastError = null; this.onSuccess?.(result);