feat(companion): add heartbeat success observability state

This commit is contained in:
William Valentin
2026-02-16 19:37:43 -08:00
parent 6dccef94a6
commit 369250077a
4 changed files with 52 additions and 1 deletions
+1 -1
View File
@@ -1201,7 +1201,7 @@ Companion runtime helper:
- 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
- lifecycle passthroughs for connection state/teardown (`connected`, `dispose(code?, reason?)`) - 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`) - 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 ## Canvas / A2UI Foundation
+13
View File
@@ -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" "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": { "browser-tools-activation-clarity": {
"status": "completed", "status": "completed",
"date": "2026-02-17", "date": "2026-02-17",
+20
View File
@@ -96,6 +96,24 @@ describe('CompanionHeartbeatLoop', () => {
loop.stop(); 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 () => { 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 }));
@@ -193,6 +211,8 @@ describe('CompanionHeartbeatLoop', () => {
expect(loop.running).toBe(false); expect(loop.running).toBe(false);
expect(loop.getState()).toEqual({ expect(loop.getState()).toEqual({
running: false, running: false,
successCount: 1,
lastSuccessAt: expect.any(Number),
failureCount: 0, failureCount: 0,
lastFailure: null, lastFailure: null,
}); });
+18
View File
@@ -18,6 +18,8 @@ export interface CompanionHeartbeatLoopOptions {
export interface CompanionHeartbeatLoopState { export interface CompanionHeartbeatLoopState {
running: boolean; running: boolean;
successCount: number;
lastSuccessAt: number | null;
failureCount: number; failureCount: number;
lastFailure: Error | null; lastFailure: Error | null;
} }
@@ -40,6 +42,8 @@ export class CompanionHeartbeatLoop {
private timer: NodeJS.Timeout | null = null; private timer: NodeJS.Timeout | null = null;
private started = false; private started = false;
private inFlight = false; private inFlight = false;
private successfulHeartbeats = 0;
private lastSuccessAtMs: number | null = null;
private consecutiveFailures = 0; private consecutiveFailures = 0;
private lastError: Error | null = null; private lastError: Error | null = null;
@@ -79,6 +83,14 @@ export class CompanionHeartbeatLoop {
return this.consecutiveFailures; return this.consecutiveFailures;
} }
get successCount(): number {
return this.successfulHeartbeats;
}
get lastSuccessAt(): number | null {
return this.lastSuccessAtMs;
}
get lastFailure(): Error | null { get lastFailure(): Error | null {
return this.lastError; return this.lastError;
} }
@@ -86,6 +98,8 @@ export class CompanionHeartbeatLoop {
getState(): CompanionHeartbeatLoopState { getState(): CompanionHeartbeatLoopState {
return { return {
running: this.running, running: this.running,
successCount: this.successCount,
lastSuccessAt: this.lastSuccessAt,
failureCount: this.failureCount, failureCount: this.failureCount,
lastFailure: this.lastFailure, lastFailure: this.lastFailure,
}; };
@@ -96,6 +110,8 @@ export class CompanionHeartbeatLoop {
return; return;
} }
this.started = true; this.started = true;
this.successfulHeartbeats = 0;
this.lastSuccessAtMs = null;
this.consecutiveFailures = 0; this.consecutiveFailures = 0;
this.lastError = null; this.lastError = null;
@@ -147,6 +163,8 @@ export class CompanionHeartbeatLoop {
try { try {
const payload = this.buildHeartbeat ? await this.buildHeartbeat() : undefined; const payload = this.buildHeartbeat ? await this.buildHeartbeat() : undefined;
const result = await this.publisher.publishHeartbeat(payload); const result = await this.publisher.publishHeartbeat(payload);
this.successfulHeartbeats += 1;
this.lastSuccessAtMs = Date.now();
this.consecutiveFailures = 0; this.consecutiveFailures = 0;
this.lastError = null; this.lastError = null;
this.onSuccess?.(result); this.onSuccess?.(result);