UNPKG

@essentialai/cogent-bridge

Version:

Cogent Bridge — cross-agent comms for Claude Code, OpenAI Codex and Slack. Codex users: install with `curl -fsSL https://cogent.tools/install.sh | sh`, then start with `cogent-codex` for real-time peer wake.

196 lines • 8.05 kB
import { getConfig } from "../config.js"; import { getBackend } from "../backend/index.js"; import { BridgeError, BridgeErrorCode } from "../errors.js"; import { logger } from "../logger.js"; import { autoRelay } from "./auto-relay.js"; import { ExponentialBackoff } from "../cloud/backoff.js"; /** * Background heartbeat service that keeps a registered peer alive. * * When running, it periodically: * 1. Sends an MCP debug-level logging notification (keeps stdio pipe active) * 2. Calls backend.updateLastSeen() to refresh the peer's lastSeenAt timestamp * - Local mode: writes to state file via peer-registry * - Cloud mode: POST /api/sessions/:sessionId/heartbeat * * Uses a setTimeout chain (not a fixed-interval timer) so that failure delays can use * exponential backoff. Retryable errors (network, 503, 429) trigger backoff * and retry. Terminal errors (401, INVALID_INPUT) stop immediately. * * Auto-stops after MAX_FAILURES consecutive retryable errors (capped retry). */ export class HeartbeatService { timer = null; peerId = null; mcpServer = null; onPeerEvicted = null; consecutiveFailures = 0; intervalMs = 0; backoff = new ExponentialBackoff(2000, 60000, 0.1); static MAX_FAILURES = 5; /** * Classify whether an error is terminal (should not retry) or retryable. * * Terminal errors: * - INVALID_INPUT: peer ID is wrong, retrying won't help * - Auth errors (401/Unauthorized): credentials invalid * * Note: PEER_NOT_FOUND is NOT classified here because it has its own * special handling path (onPeerEvicted callback) in tick(). */ isTerminalError(err) { if (err instanceof BridgeError) { // INVALID_INPUT — bad peer ID, retrying won't fix it if (err.code === BridgeErrorCode.INVALID_INPUT) return true; // Auth errors — credentials are wrong if (err.message.includes("401") || err.message.includes("Unauthorized")) return true; } return false; } /** * Schedule the next heartbeat tick after a delay. * Every timer is .unref()'d to prevent process hang on SIGTERM. */ scheduleNextTick(delayMs) { this.timer = setTimeout(() => { this.tick().catch(() => { // Error handling done inside tick() }); }, delayMs); this.timer.unref(); // CRITICAL: prevent process hang on SIGTERM } /** * Start the heartbeat for a registered peer. * Idempotent — stops any existing heartbeat before starting a new one. * * @param onPeerEvicted - Optional callback invoked when the server returns * PEER_NOT_FOUND (404), indicating the peer was evicted by stale cleanup. * The callback should trigger re-registration. */ start(peerId, mcpServer, onPeerEvicted) { this.stop(); const config = getConfig(); this.intervalMs = config.COGENT_HEARTBEAT_INTERVAL_MS; if (this.intervalMs <= 0) { logger.info("Heartbeat disabled (COGENT_HEARTBEAT_INTERVAL_MS=0)"); return; } this.peerId = peerId; this.mcpServer = mcpServer; this.onPeerEvicted = onPeerEvicted ?? null; this.consecutiveFailures = 0; this.backoff.reset(); this.scheduleNextTick(this.intervalMs); logger.info(`Heartbeat started for peer '${peerId}' every ${this.intervalMs}ms`); } /** * Stop the heartbeat timer. Safe to call multiple times. */ stop(options = {}) { if (this.timer) { clearTimeout(this.timer); this.timer = null; logger.debug("Heartbeat stopped"); } if (!options.preservePeerInfo) { this.peerId = null; this.mcpServer = null; this.onPeerEvicted = null; } this.consecutiveFailures = 0; this.backoff.reset(); } get isRunning() { return this.timer !== null; } /** * Restart the heartbeat using stored peerId, mcpServer, and onPeerEvicted. * Used after automatic re-registration to resume keepalive ticks. */ restart() { if (this.peerId && this.mcpServer) { this.start(this.peerId, this.mcpServer, this.onPeerEvicted ?? undefined); } } /** * Single heartbeat tick. * On success: schedules next tick at normal interval and resets backoff. * On retryable error: schedules next tick at backoff delay. * On terminal error or MAX_FAILURES: stops heartbeat. */ async tick() { if (!this.peerId) return; try { // 1. MCP log notification. NOT a keepalive: the server declares no `logging` capability // (index.ts), so the SDK drops this without writing to stdio — it neither keeps the pipe // active nor detects a dead client. stdin EOF in index.ts is what ends an orphaned bridge. if (this.mcpServer) { try { await this.mcpServer.server.sendLoggingMessage({ level: "debug", logger: "cogent-heartbeat", data: { type: "heartbeat", peerId: this.peerId, timestamp: new Date().toISOString(), }, }); } catch { // sendLoggingMessage may fail if transport is disconnected -- non-fatal } } // 2. Backend keepalive: update lastSeenAt timestamp const backend = getBackend(); await backend.updateLastSeen(this.peerId); // 3. Proactive session refresh: keep auto-relay session ID fresh // so it's warm when messages arrive after idle periods try { await autoRelay.refreshSessionId(); } catch { // Non-fatal: session refresh failure doesn't affect heartbeat } // Success: reset backoff and schedule next tick at normal interval this.consecutiveFailures = 0; this.backoff.reset(); if (this.timer !== null) { this.scheduleNextTick(this.intervalMs); } } catch (err) { // Detect peer eviction: server returns 404 PEER_NOT_FOUND when peer // was removed by stale cleanup. Trigger immediate re-registration // instead of waiting for MAX_FAILURES. if (err instanceof BridgeError && err.code === BridgeErrorCode.PEER_NOT_FOUND) { logger.warn(`Peer '${this.peerId}' was evicted by server. Triggering re-registration.`); const evictedCb = this.onPeerEvicted; this.stop({ preservePeerInfo: true }); evictedCb?.(); return; } // Check if this is a terminal error (not retryable) if (this.isTerminalError(err)) { logger.error(`Heartbeat stopped: terminal error: ${err}`); this.stop(); return; } // Retryable error: increment counter, apply backoff this.consecutiveFailures++; const delay = this.backoff.next(); logger.warn(`Heartbeat failed (${this.consecutiveFailures}/${HeartbeatService.MAX_FAILURES}): ${err}. Retry in ${Math.round(delay)}ms`); if (this.consecutiveFailures >= HeartbeatService.MAX_FAILURES) { logger.error("Heartbeat has too many consecutive failures; continuing with capped backoff"); } // Schedule retry with backoff delay if (this.timer !== null) { this.scheduleNextTick(delay); } } } } export const heartbeat = new HeartbeatService(); //# sourceMappingURL=heartbeat.js.map