UNPKG

claude-flow

Version:

Ruflo - Enterprise AI agent orchestration for Claude Code. Deploy 60+ specialized agents in coordinated swarms with self-learning, fault-tolerant consensus, vector memory, and MCP integration

600 lines (511 loc) 17.1 kB
/** * V3 Gossip Protocol Consensus * Eventually consistent consensus for large-scale distributed systems */ import { EventEmitter } from 'events'; import type { ConsensusTransport, ConsensusMessage } from './transport.js'; import { ConsensusProposal, ConsensusVote, ConsensusResult, ConsensusConfig, SWARM_CONSTANTS, } from '../types.js'; export interface GossipMessage { id: string; type: 'proposal' | 'vote' | 'state' | 'ack'; senderId: string; version: number; payload: unknown; timestamp: Date; ttl: number; hops: number; path: string[]; } /** * Bounded set that evicts oldest entries when capacity is reached. * Uses Map insertion-order for O(1) FIFO eviction. (PERF-01) */ export class BoundedSet<T> { private map = new Map<T, true>(); private readonly maxSize: number; constructor(maxSize: number) { this.maxSize = maxSize; } has(value: T): boolean { return this.map.has(value); } add(value: T): void { if (this.map.has(value)) return; if (this.map.size >= this.maxSize) { // Evict oldest (first inserted) const oldest = this.map.keys().next().value; if (oldest !== undefined) { this.map.delete(oldest); } } this.map.set(value, true); } get size(): number { return this.map.size; } clear(): void { this.map.clear(); } } export interface GossipNode { id: string; state: Map<string, unknown>; version: number; neighbors: Set<string>; seenMessages: BoundedSet<string>; lastSync: Date; } export interface GossipConfig extends Partial<ConsensusConfig> { fanout?: number; gossipIntervalMs?: number; maxHops?: number; convergenceThreshold?: number; /** * ADR-095 G2 — optional pluggable transport. When set, gossip messages * to neighbors actually go over it (signed if the transport has a * keypair) and inbound gossip is routed back into the merge logic. * When unset, behavior is unchanged: the legacy in-process path mutates * the local `nodes` map directly (single-process). */ transport?: ConsensusTransport; } export class GossipConsensus extends EventEmitter { private config: GossipConfig; private node: GossipNode; private nodes: Map<string, GossipNode> = new Map(); private proposals: Map<string, ConsensusProposal> = new Map(); private messageQueue: GossipMessage[] = []; private gossipInterval?: NodeJS.Timeout; private proposalCounter: number = 0; private readonly transport?: ConsensusTransport; constructor(nodeId: string, config: GossipConfig = {}) { super(); this.config = { threshold: config.threshold ?? SWARM_CONSTANTS.DEFAULT_CONSENSUS_THRESHOLD, timeoutMs: config.timeoutMs ?? SWARM_CONSTANTS.DEFAULT_CONSENSUS_TIMEOUT_MS, maxRounds: config.maxRounds ?? 10, requireQuorum: config.requireQuorum ?? false, // Gossip is eventually consistent fanout: config.fanout ?? 3, gossipIntervalMs: config.gossipIntervalMs ?? 100, maxHops: config.maxHops ?? 10, convergenceThreshold: config.convergenceThreshold ?? 0.9, transport: config.transport, }; this.transport = config.transport; this.node = { id: nodeId, state: new Map(), version: 0, neighbors: new Set(), seenMessages: new BoundedSet(100_000), // PERF-01: ~4MB cap (100K × ~40B IDs) lastSync: new Date(), }; if (this.transport) { this.transport.onMessage(async (msg: ConsensusMessage) => this.handleInboundGossipMessage(msg)); } } /** * ADR-095 G2 — route an inbound transport message into the gossip merge * logic. The transport handles signature verification (if enabled). We * dedupe by message id (the BoundedSet `seenMessages`) and process it as * though it arrived from a neighbor. */ private async handleInboundGossipMessage(msg: ConsensusMessage): Promise<void> { if (msg.type !== 'gossip') return; const gm = msg.payload as GossipMessage | undefined; if (!gm || typeof gm.id !== 'string') return; if (this.node.seenMessages.has(gm.id)) return; // Ensure the sender is known as a neighbor so processReceivedMessage works. if (!this.nodes.has(msg.from)) { this.nodes.set(msg.from, { id: msg.from, state: new Map(), version: 0, neighbors: new Set(), seenMessages: new BoundedSet(100_000), lastSync: new Date() }); } this.node.neighbors.add(msg.from); // Process against THIS node's state (the message reached us). await this.processReceivedMessage(this.node, { ...gm, timestamp: gm.timestamp ? new Date(gm.timestamp as unknown as string) : new Date(), path: Array.isArray(gm.path) ? gm.path : [], }); } async initialize(): Promise<void> { this.startGossipLoop(); this.emit('initialized', { nodeId: this.node.id }); } async shutdown(): Promise<void> { if (this.gossipInterval) { clearInterval(this.gossipInterval); } this.emit('shutdown'); } addNode(nodeId: string): void { this.nodes.set(nodeId, { id: nodeId, state: new Map(), version: 0, neighbors: new Set(), seenMessages: new BoundedSet(100_000), // PERF-01: bounded to prevent memory leak (~4MB cap) lastSync: new Date(), }); // Add as neighbor with some probability (random mesh) if (Math.random() < 0.5) { this.node.neighbors.add(nodeId); this.nodes.get(nodeId)!.neighbors.add(this.node.id); } } removeNode(nodeId: string): void { this.nodes.delete(nodeId); this.node.neighbors.delete(nodeId); for (const node of this.nodes.values()) { node.neighbors.delete(nodeId); } } addNeighbor(nodeId: string): void { if (this.nodes.has(nodeId)) { this.node.neighbors.add(nodeId); } } removeNeighbor(nodeId: string): void { this.node.neighbors.delete(nodeId); } async propose(value: unknown): Promise<ConsensusProposal> { this.proposalCounter++; const proposalId = `gossip_${this.node.id}_${this.proposalCounter}`; const proposal: ConsensusProposal = { id: proposalId, proposerId: this.node.id, value, term: this.node.version, timestamp: new Date(), votes: new Map(), status: 'pending', }; this.proposals.set(proposalId, proposal); // Self-vote proposal.votes.set(this.node.id, { voterId: this.node.id, approve: true, confidence: 1.0, timestamp: new Date(), }); // Create gossip message const message: GossipMessage = { id: `msg_${proposalId}`, type: 'proposal', senderId: this.node.id, version: ++this.node.version, payload: { proposalId, value }, timestamp: new Date(), ttl: this.config.maxHops ?? 10, hops: 0, path: [this.node.id], }; // Queue for gossip this.queueMessage(message); return proposal; } async vote(proposalId: string, vote: ConsensusVote): Promise<void> { const proposal = this.proposals.get(proposalId); if (!proposal) { return; } proposal.votes.set(vote.voterId, vote); // Create vote gossip message const message: GossipMessage = { id: `vote_${proposalId}_${vote.voterId}`, type: 'vote', senderId: this.node.id, version: ++this.node.version, payload: { proposalId, vote }, timestamp: new Date(), ttl: this.config.maxHops ?? 10, hops: 0, path: [this.node.id], }; this.queueMessage(message); // Check convergence await this.checkConvergence(proposalId); } async awaitConsensus(proposalId: string): Promise<ConsensusResult> { const startTime = Date.now(); const maxWait = this.config.timeoutMs ?? 30000; return new Promise((resolve, reject) => { const checkInterval = setInterval(() => { const proposal = this.proposals.get(proposalId); if (!proposal) { clearInterval(checkInterval); reject(new Error(`Proposal ${proposalId} not found`)); return; } if (proposal.status !== 'pending') { clearInterval(checkInterval); resolve(this.createResult(proposal, Date.now() - startTime)); return; } // Check convergence this.checkConvergence(proposalId); if (Date.now() - startTime > maxWait) { clearInterval(checkInterval); // Gossip is eventually consistent, so mark as accepted if threshold met const totalNodes = this.nodes.size + 1; const votes = proposal.votes.size; const threshold = this.config.convergenceThreshold ?? 0.9; if (votes / totalNodes >= threshold) { proposal.status = 'accepted'; } else { proposal.status = 'expired'; } resolve(this.createResult(proposal, Date.now() - startTime)); } }, 50); }); } // ===== GOSSIP PROTOCOL ===== private startGossipLoop(): void { this.gossipInterval = setInterval(() => { this.gossipRound(); }, this.config.gossipIntervalMs ?? 100); } private async gossipRound(): Promise<void> { if (this.messageQueue.length === 0) { return; } // Select random neighbors (fanout) const fanout = Math.min( this.config.fanout ?? 3, this.node.neighbors.size ); const neighbors = this.selectRandomNeighbors(fanout); // Send queued messages to selected neighbors const messages = this.messageQueue.splice(0, 10); // Process up to 10 per round for (const message of messages) { for (const neighborId of neighbors) { await this.sendToNeighbor(neighborId, message); } } this.node.lastSync = new Date(); } private selectRandomNeighbors(count: number): string[] { const neighbors = Array.from(this.node.neighbors); const selected: string[] = []; while (selected.length < count && neighbors.length > 0) { const idx = Math.floor(Math.random() * neighbors.length); selected.push(neighbors.splice(idx, 1)[0]); } return selected; } private async sendToNeighbor(neighborId: string, message: GossipMessage): Promise<void> { const deliveredMessage: GossipMessage = { ...message, hops: message.hops + 1, path: [...message.path, neighborId], }; // ADR-095 G2 — over the transport when wired: actually send the gossip // message to the neighbor (signed by the transport if signing is on). // The emit stays for observability. if (this.transport) { this.emit('message.sent', { to: neighborId, message: deliveredMessage }); try { await this.transport.send(neighborId, { type: 'gossip', payload: { ...deliveredMessage, timestamp: deliveredMessage.timestamp.toISOString() }, }); } catch { // Unreachable neighbor — gossip tolerates this; it'll converge via // other paths or the next gossip round. } return; } // Legacy in-process path — deliver to the fake neighbor state. const neighbor = this.nodes.get(neighborId); if (!neighbor) return; if (neighbor.seenMessages.has(message.id)) return; await this.processReceivedMessage(neighbor, deliveredMessage); this.emit('message.sent', { to: neighborId, message: deliveredMessage }); } private async processReceivedMessage( node: GossipNode, message: GossipMessage ): Promise<void> { // Mark as seen node.seenMessages.add(message.id); // Check TTL if (message.ttl <= 0 || message.hops >= (this.config.maxHops ?? 10)) { return; } switch (message.type) { case 'proposal': await this.handleProposalMessage(node, message); break; case 'vote': await this.handleVoteMessage(node, message); break; case 'state': await this.handleStateMessage(node, message); break; } // Propagate to neighbors (gossip) if (message.hops < (this.config.maxHops ?? 10)) { const propagateMessage: GossipMessage = { ...message, ttl: message.ttl - 1, }; // Add to queue if this is our node if (node.id === this.node.id) { this.queueMessage(propagateMessage); } } } private async handleProposalMessage( node: GossipNode, message: GossipMessage ): Promise<void> { const { proposalId, value } = message.payload as { proposalId: string; value: unknown; }; if (!this.proposals.has(proposalId)) { const proposal: ConsensusProposal = { id: proposalId, proposerId: message.senderId, value, term: message.version, timestamp: message.timestamp, votes: new Map(), status: 'pending', }; this.proposals.set(proposalId, proposal); // Auto-vote (simplified) if (node.id === this.node.id) { await this.vote(proposalId, { voterId: this.node.id, approve: true, confidence: 0.9, timestamp: new Date(), }); } } } private async handleVoteMessage( node: GossipNode, message: GossipMessage ): Promise<void> { const { proposalId, vote } = message.payload as { proposalId: string; vote: ConsensusVote; }; const proposal = this.proposals.get(proposalId); if (proposal && !proposal.votes.has(vote.voterId)) { proposal.votes.set(vote.voterId, vote); await this.checkConvergence(proposalId); } } private async handleStateMessage( node: GossipNode, message: GossipMessage ): Promise<void> { const state = message.payload as Record<string, unknown>; // Merge state (last-writer-wins) if (message.version > node.version) { for (const [key, value] of Object.entries(state)) { node.state.set(key, value); } node.version = message.version; } } private queueMessage(message: GossipMessage): void { // Avoid duplicates if (!this.node.seenMessages.has(message.id)) { this.node.seenMessages.add(message.id); this.messageQueue.push(message); } } private async checkConvergence(proposalId: string): Promise<void> { const proposal = this.proposals.get(proposalId); if (!proposal || proposal.status !== 'pending') { return; } const totalNodes = this.nodes.size + 1; const votes = proposal.votes.size; const threshold = this.config.convergenceThreshold ?? 0.9; const approvalThreshold = this.config.threshold ?? 0.66; // Check if we've converged (enough nodes have voted) if (votes / totalNodes >= threshold) { const approvingVotes = Array.from(proposal.votes.values()).filter( v => v.approve ).length; if (approvingVotes / votes >= approvalThreshold) { proposal.status = 'accepted'; this.emit('consensus.achieved', { proposalId, approved: true }); } else { proposal.status = 'rejected'; this.emit('consensus.achieved', { proposalId, approved: false }); } } } private createResult(proposal: ConsensusProposal, durationMs: number): ConsensusResult { const totalNodes = this.nodes.size + 1; const approvingVotes = Array.from(proposal.votes.values()).filter( v => v.approve ).length; return { proposalId: proposal.id, approved: proposal.status === 'accepted', approvalRate: proposal.votes.size > 0 ? approvingVotes / proposal.votes.size : 0, participationRate: proposal.votes.size / totalNodes, finalValue: proposal.value, rounds: this.node.version, durationMs, }; } // ===== STATE QUERIES ===== getConvergence(proposalId: string): number { const proposal = this.proposals.get(proposalId); if (!proposal) return 0; const totalNodes = this.nodes.size + 1; return proposal.votes.size / totalNodes; } getVersion(): number { return this.node.version; } getNeighborCount(): number { return this.node.neighbors.size; } getSeenMessageCount(): number { return this.node.seenMessages.size; } getQueueDepth(): number { return this.messageQueue.length; } // Anti-entropy: sync full state with a random neighbor async antiEntropy(): Promise<void> { if (this.node.neighbors.size === 0) return; const neighbors = Array.from(this.node.neighbors); const randomNeighbor = neighbors[Math.floor(Math.random() * neighbors.length)]; const stateMessage: GossipMessage = { id: `state_${this.node.id}_${Date.now()}`, type: 'state', senderId: this.node.id, version: this.node.version, payload: Object.fromEntries(this.node.state), timestamp: new Date(), ttl: 1, hops: 0, path: [this.node.id], }; await this.sendToNeighbor(randomNeighbor, stateMessage); } } export function createGossipConsensus( nodeId: string, config?: GossipConfig ): GossipConsensus { return new GossipConsensus(nodeId, config); }