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.

468 lines • 19 kB
import WebSocket from "ws"; import { ExponentialBackoff } from "./backoff.js"; import { WsCloseCode } from "./ws-frames.js"; import { logger } from "../logger.js"; import { loadPollCursor, savePollCursor } from "./poll-cursor.js"; /** * WebSocket client with reconnection logic and poll fallback. * * Manages the full connection lifecycle: * - Connects to server WS endpoint with token + peerId auth * - Dispatches all 6 WsServerFrame types to callbacks * - Falls back to HTTP polling on disconnect * - Reconnects with exponential backoff (1s-30s with 10% jitter) * - Supports deferred peerId via setPeerId() for lazy connection * - Supports runtime credential updates via updateToken/updateSessionId */ export class CloudWsClient { ws = null; state = "disconnected"; backoff; reconnectTimer = null; pollTimer = null; /** * DELIVERY-PATH EVIDENCE. Before 3.23.9 a poll failure was caught and only logger.warn'd, so the * bridge had no idea whether its FALLBACK delivery path worked — and `healthy` could not be * computed honestly because the data did not exist. Captured evidence that nothing reads is the * defect shape this codebase keeps hitting. */ lastPollOkAt = null; lastPollError = null; lastWsErrorSticky = null; pingTimer = null; lastMessageId = null; /** * Advance the poll cursor AND persist it. * * Every assignment must go through here. `lastMessageId` was in-memory only, so a restart sent * no cursor and the relay replayed the whole channel ("If lastMessageId is not provided (first * poll), returns all messages") — re-injecting days-old broadcasts on every restart. */ advanceCursor(id) { if (!id) return; this.lastMessageId = id; savePollCursor(this.opts.sessionId, this.opts.peerId, id); } /** * Seed the cursor from disk when this process has not consumed anything yet. * * Only ever fills a NULL cursor: a live in-memory position is always newer than the stored one, * so it must never be overwritten by a restore. */ restoreCursor() { if (this.lastMessageId) return; const stored = loadPollCursor(this.opts.sessionId, this.opts.peerId); if (stored) { this.lastMessageId = stored; logger.info(`CloudWsClient: resuming from stored poll cursor (${stored.slice(0, 8)}…) — the channel ` + `backlog before it has already been consumed and will not be replayed.`); } } lastWsError = null; connectionGeneration = 0; opts; /** * Wall-clock timestamp (ms since epoch) of the last frame received on this * WebSocket. Survives reconnects and disconnects -- intentionally NOT reset * on close, so an operator can see how stale the transport is. * Surfaced to cogent_health_check as transport.wsLastFrameAt (ISO string). */ lastFrameAt = null; constructor(options) { this.opts = { ...options, pollIntervalMs: options.pollIntervalMs ?? 5000, }; this.backoff = new ExponentialBackoff(); } /** * Initiate WebSocket connection to the server. * Guards against empty peerId -- call setPeerId() first. */ connect() { this.restoreCursor(); if (!this.opts.peerId) { logger.warn("CloudWsClient: Cannot connect WebSocket without peerId. Call setPeerId() first."); return; } this.lastWsError = null; this.clearReconnectTimer(); this.clearPingTimer(); const generation = ++this.connectionGeneration; this.setState("connecting"); // Build WS URL: http->ws, https->wss const wsUrl = this.opts.endpoint .replace(/^https:/, "wss:") .replace(/^http:/, "ws:"); const sessionPath = `/ws/${this.opts.sessionId}`; const params = new URLSearchParams({ peerId: this.opts.peerId, }); const url = `${wsUrl}${sessionPath}?${params.toString()}`; const ws = new WebSocket(url, { headers: { Authorization: `Bearer ${this.opts.token}`, }, }); this.ws = ws; ws.on("open", () => { if (generation !== this.connectionGeneration || this.ws !== ws) return; this.lastWsErrorSticky = null; // a successful open clears the standing complaint this.setState("connected"); this.backoff.reset(); this.stopPolling(); // Send WS ping frames every 30s to keep the connection alive // through nginx proxy_read_timeout (prevents idle disconnect) this.clearPingTimer(); this.pingTimer = setInterval(() => { if (this.ws?.readyState === WebSocket.OPEN) { this.ws.ping(); } }, 30_000); this.pingTimer.unref?.(); }); ws.on("message", (data) => { if (generation !== this.connectionGeneration || this.ws !== ws) return; // Stamp arrival time BEFORE attempting to parse: even an unparseable // frame proves the transport is alive. This is what `transport.wsLastFrameAt` // reports -- a "transport-alive" signal, not a "valid-data" signal. this.lastFrameAt = Date.now(); try { const frame = JSON.parse(data.toString()); this.dispatchFrame(frame); } catch { logger.error("CloudWsClient: Failed to parse WebSocket frame"); } }); // Control-frame liveness signals. The "message" listener only fires for // data frames, but for low-traffic observer peers the only traffic on the // wire may be our 30s ws.ping() keepalive (above) and the server's pong // reply -- or a server-initiated ping. Without these listeners the // EventEmitter contract (ws inherits from EventEmitter) means // lastFrameAt never updates and wsHealth reports "stale" even though the // transport is perfectly healthy. Record arrival under the same // generation/instance guard as the message handler so stale-generation // frames cannot mutate state on a superseded connection. ws.on("pong", () => { // Server response to our 30s ws.ping() keepalive — the primary // freshness signal for low-traffic observer peers. if (generation !== this.connectionGeneration || this.ws !== ws) return; this.lastFrameAt = Date.now(); }); ws.on("ping", () => { // Server-initiated keepalive. The ws library auto-replies with a pong, // so we only need to record arrival — no manual ws.pong() call. if (generation !== this.connectionGeneration || this.ws !== ws) return; this.lastFrameAt = Date.now(); }); ws.on("close", (code, _reason) => { if (generation !== this.connectionGeneration || this.ws !== ws) return; this.ws = null; this.clearPingTimer(); // Check if the close was caused by a peer eviction (server returned 404 // on WS upgrade because peer was removed by stale cleanup). The ws library // fires error with the HTTP status before close with code 1006. const evicted = this.lastWsError !== null && (this.lastWsError.includes("404") || this.lastWsError.includes("PEER_NOT_FOUND")); this.lastWsError = null; if (evicted) { // Peer was evicted by server -- don't reconnect (would just 404 again). // Invoke callback so heartbeat/startup can trigger re-registration. logger.warn("CloudWsClient: Peer evicted by server (stale cleanup). Stopping reconnect."); this.setState("disconnected"); this.opts.onPeerEvicted?.(); return; } // Start polling fallback this.setState("polling"); this.startPolling(); // Use longer initial delay for service restart if (code === WsCloseCode.SERVICE_RESTART) { // Create a fresh backoff with 5s base for graceful restarts const restartBackoff = new ExponentialBackoff(5000); const delay = restartBackoff.next(); this.clearReconnectTimer(); this.reconnectTimer = setTimeout(() => this.connect(), delay); this.reconnectTimer.unref?.(); } else { this.scheduleReconnect(); } }); ws.on("error", (err) => { if (generation !== this.connectionGeneration || this.ws !== ws) return; this.lastWsError = err.message; // A 401 here is auth-dead and never self-recovers — categorically different from a socket // that is merely reconnecting. lastWsError is cleared by the close handler's eviction check, // so keep a copy that survives for diagnosis. this.lastWsErrorSticky = err.message; logger.error(`CloudWsClient: WebSocket error: ${err.message}`); // Don't change state here -- the "close" event follows and handles it }); } /** * Disconnect from the WebSocket and stop all timers. */ disconnect() { this.connectionGeneration++; this.clearTimers(); const ws = this.ws; this.ws = null; if (ws && (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING)) { ws.close(WsCloseCode.NORMAL_CLOSURE); } this.setState("disconnected"); } /** * Set or update the peerId. * This is the trigger mechanism for deferred WS connection. * Plan 05 creates CloudWsClient at startup with empty peerId (peerId is * only known after register-peer succeeds). After registration, it calls * this method to initiate the WS connection. */ setPeerId(peerId) { this.opts.peerId = peerId; if (peerId && this.state === "disconnected") { // Trigger initial connection now that we have a peerId this.connect(); } else if (peerId && this.state !== "disconnected") { // Already connected/connecting -- disconnect and reconnect with new peerId this.disconnect(); this.connect(); } } /** * Get the current connection state. */ getState() { return this.state; } /** * True iff the WebSocket is in the "connected" state. * Surfaced to cogent_health_check as transport.wsConnected. */ isConnected() { return this.state === "connected"; } /** * ISO 8601 string of the last frame arrival time, or null if no frame has * been received on this client instance yet. Survives reconnects -- * deliberately NOT reset on close so an operator can see staleness across * reconnect cycles. * Surfaced to cogent_health_check as transport.wsLastFrameAt. */ getLastFrameAt() { return this.lastFrameAt === null ? null : new Date(this.lastFrameAt).toISOString(); } /** * Raw wall-clock timestamp (ms since epoch) of the last frame, or null if * no frame has arrived. Sibling of `getLastFrameAt()` -- same source field, * but returns the underlying number so the consumer can compute deltas * (e.g. transport.wsAgeMs in cogent_health_check) without re-parsing the * ISO string. Survives reconnects, same as `getLastFrameAt()`. * Audit L2. */ /** * What this bridge's DELIVERY situation actually is — the input `healthy` needs in order to * describe THIS BRIDGE rather than the relay's uptime. */ getDeliveryDiagnostics() { return { pollActive: this.pollTimer !== null, lastPollOkAt: this.lastPollOkAt, lastPollError: this.lastPollError, lastWsError: this.lastWsErrorSticky, }; } getLastFrameAtMs() { return this.lastFrameAt; } /** * Update the last seen message ID (e.g., from HttpBackend operations). */ updateLastMessageId(id) { this.advanceCursor(id); } /** * Update the auth token for reconnection. * If currently connected, disconnects and reconnects with the new token. */ updateToken(newToken) { this.opts.token = newToken; if (this.state === "connected" || this.state === "connecting") { this.disconnect(); this.connect(); } } /** * Point the socket at a DIFFERENT relay host. * * 🔴 WHY THIS EXISTS. The two relays are different HOSTS (cogent.tools vs app.cogent.tools), not * just different credentials. `updateToken`/`updateSessionId` existed and `opts.endpoint` did * not — so an agent that joined a FREE channel and then a TEAM channel in the SAME PROCESS kept * dialling the OLD host while presenting the NEW token and session id. The upgrade 401s, the * backoff retries it every 1-4s forever, and the inbound queue never drains — while HTTP looks * perfectly healthy, because reinitCloudBackend builds a fresh HttpClient with the right base. * Reported live by peer marvin-mobile on 2026-09-05 (queueLength stuck at 410). * * No-ops when the host is unchanged: re-joining another channel on the SAME relay is the common * case, and tearing down a healthy socket for it would be a self-inflicted reconnect storm. */ updateEndpoint(newEndpoint) { if (newEndpoint === this.opts.endpoint) return; this.opts.endpoint = newEndpoint; if (this.state === "connected" || this.state === "connecting") { this.disconnect(); this.connect(); } } /** * Update the session ID. * If currently connected, disconnects and reconnects. */ updateSessionId(newSessionId) { this.opts.sessionId = newSessionId; if (this.state === "connected" || this.state === "connecting") { this.disconnect(); this.connect(); } } /** * Replace the HttpClient used for polling. * The next poll cycle will use the new client automatically. */ updateHttp(newHttp) { this.opts.http = newHttp; } /** * Send a delivery acknowledgment frame to the server, which routes it * to the original sender so they know the message was received. */ sendDeliveryAck(messageId, fromPeerId, toPeerId) { if (!this.ws || this.ws.readyState !== WebSocket.OPEN) { logger.warn("CloudWsClient: Cannot send delivery_ack: WebSocket not open"); return; } this.ws.send(JSON.stringify({ type: "delivery_ack", payload: { messageId, fromPeerId, toPeerId }, })); } // ------------------------------------------------------------------------- // Private methods // ------------------------------------------------------------------------- setState(newState) { this.state = newState; this.opts.onStateChange(newState); } dispatchFrame(frame) { switch (frame.type) { case "message": this.opts.onMessage(frame.payload); this.advanceCursor(frame.payload.id); break; case "queued_messages": { const msgs = frame.payload.messages; this.opts.onMessages(msgs); if (msgs.length > 0) { this.advanceCursor(msgs[msgs.length - 1].id); } break; } case "peer_connected": this.opts.onPeerConnected(frame.payload); break; case "peer_disconnected": this.opts.onPeerDisconnected(frame.payload.peerId); break; case "peers_snapshot": this.opts.onPeersSnapshot(frame.payload.peers); break; case "error": this.opts.onError?.(frame.payload); break; case "delivery_ack": this.opts.onDeliveryAck?.(frame.payload); break; } } scheduleReconnect() { this.clearReconnectTimer(); this.setState("reconnecting"); const delay = this.backoff.next(); this.reconnectTimer = setTimeout(() => this.connect(), delay); this.reconnectTimer.unref?.(); } startPolling() { if (this.pollTimer) return; this.restoreCursor(); const poll = async () => { try { const path = `/api/sessions/${this.opts.sessionId}/poll`; const query = { peerId: this.opts.peerId, }; if (this.lastMessageId) { query.lastMessageId = this.lastMessageId; } const result = await this.opts.http.get(path, query); if (result.messages && result.messages.length > 0) { this.opts.onMessages(result.messages); this.advanceCursor(result.messages[result.messages.length - 1].id); } // A COMPLETED poll is the proof that the fallback path delivers, whether or not it // carried messages. Recorded, not just logged. this.lastPollOkAt = Date.now(); this.lastPollError = null; } catch (err) { this.lastPollError = err instanceof Error ? err.message : String(err); logger.warn(`CloudWsClient: Poll failed: ${this.lastPollError}`); } }; // Poll immediately, then at interval void poll(); this.pollTimer = setInterval(() => void poll(), this.opts.pollIntervalMs); this.pollTimer.unref?.(); } stopPolling() { if (this.pollTimer) { clearInterval(this.pollTimer); this.pollTimer = null; } } clearTimers() { this.clearReconnectTimer(); this.clearPingTimer(); this.stopPolling(); } clearReconnectTimer() { if (this.reconnectTimer) { clearTimeout(this.reconnectTimer); this.reconnectTimer = null; } } clearPingTimer() { if (this.pingTimer) { clearInterval(this.pingTimer); this.pingTimer = null; } } } //# sourceMappingURL=ws-client.js.map