@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
JavaScript
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