From d63704d436b49e0aebf63f192e88bf7552cd3dc9 Mon Sep 17 00:00:00 2001 From: William Valentin Date: Mon, 16 Feb 2026 18:59:29 -0800 Subject: [PATCH] feat(companion): expose heartbeat loop failure observability --- README.md | 2 +- docs/plans/state.json | 13 +++++++++++++ src/companion/heartbeatLoop.test.ts | 6 ++++++ src/companion/heartbeatLoop.ts | 12 ++++++++++++ 4 files changed, 32 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index c75c29f..1e17a2c 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, optional interval jitter (`jitterRatio`) to spread load, `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, failure observability (`failureCount`, `lastFailure`), and optional auto-stop after repeated failures. ## Canvas / A2UI Foundation diff --git a/docs/plans/state.json b/docs/plans/state.json index 20bd68c..e516740 100644 --- a/docs/plans/state.json +++ b/docs/plans/state.json @@ -434,6 +434,19 @@ ], "test_status": "pnpm test:run src/companion/runtimeClient.test.ts src/companion/heartbeatLoop.test.ts src/companion/platformClients.test.ts src/companion/platformClients.integration.test.ts + pnpm typecheck passing" }, + "companion-heartbeat-loop-observability-counters": { + "status": "completed", + "date": "2026-02-17", + "updated": "2026-02-17", + "summary": "Added heartbeat loop observability counters (`failureCount`, `lastFailure`) so companion runtimes can introspect heartbeat health state between retries and recovery.", + "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 b5cb36f..431dca5 100644 --- a/src/companion/heartbeatLoop.test.ts +++ b/src/companion/heartbeatLoop.test.ts @@ -109,9 +109,13 @@ describe('CompanionHeartbeatLoop', () => { loop.start(); await Promise.resolve(); expect(onError).toHaveBeenCalledTimes(1); + expect(loop.failureCount).toBe(1); + expect(loop.lastFailure?.message).toBe('boom'); await vi.advanceTimersByTimeAsync(400); expect(publishHeartbeat).toHaveBeenCalledTimes(2); + expect(loop.failureCount).toBe(0); + expect(loop.lastFailure).toBeNull(); loop.stop(); }); @@ -154,6 +158,8 @@ describe('CompanionHeartbeatLoop', () => { await vi.advanceTimersByTimeAsync(300); expect(loop.running).toBe(false); + expect(loop.failureCount).toBe(2); + expect(loop.lastFailure?.message).toBe('persistent-failure'); expect(onError).toHaveBeenCalledTimes(2); expect(onFailureLimitReached).toHaveBeenCalledTimes(1); expect(onFailureLimitReached).toHaveBeenCalledWith(expect.any(Error), 2); diff --git a/src/companion/heartbeatLoop.ts b/src/companion/heartbeatLoop.ts index a8faea4..88c186f 100644 --- a/src/companion/heartbeatLoop.ts +++ b/src/companion/heartbeatLoop.ts @@ -33,6 +33,7 @@ export class CompanionHeartbeatLoop { private started = false; private inFlight = false; private consecutiveFailures = 0; + private lastError: Error | null = null; constructor(publisher: HeartbeatPublisher, options: CompanionHeartbeatLoopOptions = {}) { const intervalMs = options.intervalMs ?? 30_000; @@ -65,12 +66,21 @@ export class CompanionHeartbeatLoop { return this.started; } + get failureCount(): number { + return this.consecutiveFailures; + } + + get lastFailure(): Error | null { + return this.lastError; + } + start(runImmediately = true): void { if (this.started) { return; } this.started = true; this.consecutiveFailures = 0; + this.lastError = null; if (runImmediately) { void this.tick(); @@ -121,9 +131,11 @@ export class CompanionHeartbeatLoop { const payload = this.buildHeartbeat ? await this.buildHeartbeat() : undefined; await this.publisher.publishHeartbeat(payload); this.consecutiveFailures = 0; + this.lastError = null; } catch (error) { const err = error instanceof Error ? error : new Error(String(error)); this.consecutiveFailures += 1; + this.lastError = err; this.onError?.(err); if (this.consecutiveFailures >= this.maxConsecutiveFailures) { this.onFailureLimitReached?.(err, this.consecutiveFailures);