/** * March Agent SDK - Heartbeat Manager * Port of Python march_agent/heartbeat.py * * Enhanced with failure tracking to detect stale connections. */ import type { GatewayClient } from './gateway-client.js' import { AI_INVENTORY_PATHS, SERVICES } from './api-paths.js' /** * Callback type for when consecutive heartbeat failures exceed threshold. */ export type HeartbeatFailureCallback = (consecutiveFailures: number, lastError?: Error) => void /** * Options for HeartbeatManager. */ export interface HeartbeatOptions { /** Number of consecutive failures before triggering callback. Default: 3 */ failureThreshold?: number /** Callback when failures exceed threshold */ onFailureThresholdExceeded?: HeartbeatFailureCallback } /** * Manages periodic heartbeats to keep the agent status active. * Sends heartbeats via HTTP to the AI Inventory service. * Tracks consecutive failures to detect connection issues. */ export class HeartbeatManager { private readonly gatewayClient: GatewayClient private readonly agentName: string private readonly intervalMs: number private readonly failureThreshold: number private readonly onFailureThresholdExceeded?: HeartbeatFailureCallback private timer?: ReturnType private running: boolean = false private paused: boolean = false private consecutiveFailures: number = 0 private lastError?: Error private thresholdExceededNotified: boolean = false constructor( gatewayClient: GatewayClient, agentName: string, intervalSeconds: number = 60, options?: HeartbeatOptions ) { this.gatewayClient = gatewayClient this.agentName = agentName this.intervalMs = intervalSeconds * 1000 this.failureThreshold = options?.failureThreshold ?? 3 this.onFailureThresholdExceeded = options?.onFailureThresholdExceeded } /** * Start sending heartbeats. */ start(): void { if (this.running) { return } this.running = true this.paused = false this.consecutiveFailures = 0 this.thresholdExceededNotified = false this.timer = setInterval(() => { this.sendHeartbeat() }, this.intervalMs) // Send first heartbeat immediately this.sendHeartbeat() } /** * Stop sending heartbeats. */ stop(): void { this.running = false this.paused = false if (this.timer) { clearInterval(this.timer) this.timer = undefined } } /** * Pause heartbeat sending (e.g., during reconnection). * Heartbeats will not be sent while paused but the timer keeps running. */ pause(): void { this.paused = true } /** * Resume heartbeat sending. * Resets failure counter on resume. */ resume(): void { this.paused = false this.consecutiveFailures = 0 this.thresholdExceededNotified = false } /** * Send a single heartbeat via HTTP to AI Inventory. */ private async sendHeartbeat(): Promise { if (!this.running || this.paused) { return } // Don't send heartbeats if gateway is reconnecting if (this.gatewayClient.isReconnectingNow()) { return } try { const response = await this.gatewayClient.httpPost( SERVICES.AI_INVENTORY, AI_INVENTORY_PATHS.HEALTH_HEARTBEAT, { name: this.agentName } ) if (response.ok) { // Reset failure counter on success this.consecutiveFailures = 0 this.thresholdExceededNotified = false } else if (response.status === 404) { console.warn(`Agent '${this.agentName}' not found. Re-registration may be needed.`) this.recordFailure(new Error(`Agent not found (404)`)) } else { console.warn(`Heartbeat returned status ${response.status}`) this.recordFailure(new Error(`HTTP ${response.status}`)) } } catch (error) { const err = error instanceof Error ? error : new Error(String(error)) console.error('Heartbeat failed:', err.message) this.recordFailure(err) } } /** * Record a heartbeat failure and check threshold. */ private recordFailure(error: Error): void { this.consecutiveFailures++ this.lastError = error console.warn( `Heartbeat failure ${this.consecutiveFailures}/${this.failureThreshold} for agent '${this.agentName}'` ) // Notify callback if threshold exceeded and not already notified if ( this.consecutiveFailures >= this.failureThreshold && !this.thresholdExceededNotified && this.onFailureThresholdExceeded ) { this.thresholdExceededNotified = true console.warn( `Heartbeat failure threshold exceeded for agent '${this.agentName}' - notifying callback` ) try { this.onFailureThresholdExceeded(this.consecutiveFailures, this.lastError) } catch (e) { console.error('Error in heartbeat failure callback:', e) } } } /** * Check if heartbeat is running. */ isRunning(): boolean { return this.running } /** * Check if heartbeat is paused. */ isPaused(): boolean { return this.paused } /** * Get the current consecutive failure count. */ getConsecutiveFailures(): number { return this.consecutiveFailures } /** * Get the last error that occurred. */ getLastError(): Error | undefined { return this.lastError } }