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

562 lines (482 loc) 17.5 kB
/** * V3 Raft Consensus Implementation * Leader election and log replication for distributed coordination */ import { EventEmitter } from 'events'; import type { ConsensusTransport, ConsensusMessage } from './transport.js'; import { ConsensusProposal, ConsensusVote, ConsensusResult, ConsensusConfig, SWARM_CONSTANTS, } from '../types.js'; export type RaftState = 'follower' | 'candidate' | 'leader'; export interface RaftNode { id: string; state: RaftState; currentTerm: number; votedFor?: string; log: RaftLogEntry[]; commitIndex: number; lastApplied: number; } export interface RaftLogEntry { term: number; index: number; command: unknown; timestamp: Date; } export interface RaftConfig extends Partial<ConsensusConfig> { electionTimeoutMinMs?: number; electionTimeoutMaxMs?: number; heartbeatIntervalMs?: number; /** * ADR-095 G2 — optional pluggable transport. When set, RequestVote and * AppendEntries RPCs go over it (request-response) and this node also * answers inbound RequestVote / AppendEntries from peers using proper * Raft receiver rules (term comparison, vote-once-per-term, log-up-to-date * check). When unset, behavior is unchanged: the legacy in-process path * mutates the local `peers` map directly (single-process). */ transport?: ConsensusTransport; } export class RaftConsensus extends EventEmitter { private config: RaftConfig; private node: RaftNode; private peers: Map<string, RaftNode> = new Map(); private proposals: Map<string, ConsensusProposal> = new Map(); private electionTimeout?: NodeJS.Timeout; private heartbeatInterval?: NodeJS.Timeout; private proposalCounter: number = 0; private readonly transport?: ConsensusTransport; constructor(nodeId: string, config: RaftConfig = {}) { 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 ?? true, electionTimeoutMinMs: config.electionTimeoutMinMs ?? 150, electionTimeoutMaxMs: config.electionTimeoutMaxMs ?? 300, heartbeatIntervalMs: config.heartbeatIntervalMs ?? 50, transport: config.transport, }; this.transport = config.transport; this.node = { id: nodeId, state: 'follower', currentTerm: 0, log: [], commitIndex: 0, lastApplied: 0, }; if (this.transport) { this.transport.onMessage(async (msg: ConsensusMessage) => this.handleInboundRaftMessage(msg)); } } /** Index/term of the last entry in this node's log (Raft "up-to-date" comparison). */ private lastLogInfo(): { index: number; term: number } { const last = this.node.log[this.node.log.length - 1]; return last ? { index: last.index, term: last.term } : { index: 0, term: 0 }; } /** * ADR-095 G2 — Raft receiver. Answers inbound RequestVote / AppendEntries * with proper Raft rules. Returns the RPC response the transport relays * back to the caller (the transport's `send` resolves with it). */ private async handleInboundRaftMessage(msg: ConsensusMessage): Promise<ConsensusMessage | void> { const p = (msg.payload ?? {}) as Record<string, unknown>; const term = typeof p.term === 'number' ? p.term : 0; // §5.1 — any RPC with a higher term makes us a follower and adopts it. if (term > this.node.currentTerm) { this.node.currentTerm = term; this.node.votedFor = undefined; this.node.state = 'follower'; } if (msg.type === 'request-vote') { const candidateId = String(p.candidateId ?? msg.from); const candLastIndex = typeof p.lastLogIndex === 'number' ? p.lastLogIndex : 0; const candLastTerm = typeof p.lastLogTerm === 'number' ? p.lastLogTerm : 0; const my = this.lastLogInfo(); const logOk = candLastTerm > my.term || (candLastTerm === my.term && candLastIndex >= my.index); const termOk = term >= this.node.currentTerm; const notVotedOrSame = this.node.votedFor === undefined || this.node.votedFor === candidateId; const granted = termOk && notVotedOrSame && logOk; if (granted) { this.node.votedFor = candidateId; this.resetElectionTimeout(); } return { type: 'vote-response', from: this.node.id, to: msg.from, payload: { term: this.node.currentTerm, granted }, term: this.node.currentTerm }; } if (msg.type === 'append-entries') { // §5.2/5.3 — reject if leader's term is stale. if (term < this.node.currentTerm) { return { type: 'append-entries-response', from: this.node.id, to: msg.from, payload: { term: this.node.currentTerm, success: false }, term: this.node.currentTerm }; } // Valid leader heartbeat/append → become/stay follower, reset election timer. this.node.state = 'follower'; this.resetElectionTimeout(); const entries = Array.isArray(p.entries) ? (p.entries as RaftLogEntry[]) : []; // (Simplified log matching: append entries not already present by index. // Full prevLogIndex/prevLogTerm conflict resolution is the next refinement.) for (const e of entries) { if (!this.node.log.some(x => x.index === e.index)) this.node.log.push(e); } const leaderCommit = typeof p.leaderCommit === 'number' ? p.leaderCommit : this.node.commitIndex; if (leaderCommit > this.node.commitIndex) { this.node.commitIndex = Math.min(leaderCommit, this.lastLogInfo().index); } return { type: 'append-entries-response', from: this.node.id, to: msg.from, payload: { term: this.node.currentTerm, success: true }, term: this.node.currentTerm }; } return; } async initialize(): Promise<void> { this.resetElectionTimeout(); this.emit('initialized', { nodeId: this.node.id }); } async shutdown(): Promise<void> { if (this.electionTimeout) { clearTimeout(this.electionTimeout); } if (this.heartbeatInterval) { clearInterval(this.heartbeatInterval); } this.emit('shutdown'); } addPeer(peerId: string): void { this.peers.set(peerId, { id: peerId, state: 'follower', currentTerm: 0, log: [], commitIndex: 0, lastApplied: 0, }); } removePeer(peerId: string): void { this.peers.delete(peerId); } async propose(value: unknown): Promise<ConsensusProposal> { if (this.node.state !== 'leader') { throw new Error('Only leader can propose values'); } this.proposalCounter++; const proposalId = `raft_${this.node.id}_${this.proposalCounter}`; const proposal: ConsensusProposal = { id: proposalId, proposerId: this.node.id, value, term: this.node.currentTerm, timestamp: new Date(), votes: new Map(), status: 'pending', }; // Add to local log const logEntry: RaftLogEntry = { term: this.node.currentTerm, index: this.node.log.length + 1, command: { proposalId, value }, timestamp: new Date(), }; this.node.log.push(logEntry); this.proposals.set(proposalId, proposal); // Leader votes for itself proposal.votes.set(this.node.id, { voterId: this.node.id, approve: true, confidence: 1.0, timestamp: new Date(), }); // Replicate to followers await this.replicateToFollowers(logEntry); return proposal; } async vote(proposalId: string, vote: ConsensusVote): Promise<void> { const proposal = this.proposals.get(proposalId); if (!proposal) { throw new Error(`Proposal ${proposalId} not found`); } if (proposal.status !== 'pending') { return; } proposal.votes.set(vote.voterId, vote); // Check if we have consensus await this.checkConsensus(proposalId); } async awaitConsensus(proposalId: string): Promise<ConsensusResult> { const startTime = Date.now(); 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; } if (Date.now() - startTime > (this.config.timeoutMs ?? 30000)) { clearInterval(checkInterval); proposal.status = 'expired'; resolve(this.createResult(proposal, Date.now() - startTime)); } }, 10); }); } getState(): RaftState { return this.node.state; } getTerm(): number { return this.node.currentTerm; } isLeader(): boolean { return this.node.state === 'leader'; } getLeaderId(): string | undefined { if (this.node.state === 'leader') { return this.node.id; } return this.node.votedFor; } // ===== PRIVATE METHODS ===== private resetElectionTimeout(): void { if (this.electionTimeout) { clearTimeout(this.electionTimeout); } const timeout = this.randomElectionTimeout(); this.electionTimeout = setTimeout(() => { this.startElection(); }, timeout); } private randomElectionTimeout(): number { const min = this.config.electionTimeoutMinMs ?? 150; const max = this.config.electionTimeoutMaxMs ?? 300; return Math.floor(Math.random() * (max - min + 1)) + min; } private async startElection(): Promise<void> { this.node.state = 'candidate'; this.node.currentTerm++; this.node.votedFor = this.node.id; this.emit('election.started', { term: this.node.currentTerm, candidateId: this.node.id }); // Vote for self let votesReceived = 1; const votesNeeded = Math.floor((this.peers.size + 1) / 2) + 1; // Request votes from peers for (const [peerId, peer] of this.peers) { const granted = await this.requestVote(peerId); if (granted) { votesReceived++; } if (votesReceived >= votesNeeded) { this.becomeLeader(); return; } } // Election failed, reset to follower this.node.state = 'follower'; this.resetElectionTimeout(); } private async requestVote(peerId: string): Promise<boolean> { // ADR-095 G2 — over the transport when wired: real RequestVote RPC. if (this.transport) { const my = this.lastLogInfo(); try { const reply = await this.transport.send(peerId, { type: 'request-vote', payload: { term: this.node.currentTerm, candidateId: this.node.id, lastLogIndex: my.index, lastLogTerm: my.term }, term: this.node.currentTerm, }); const rp = (reply?.payload ?? {}) as Record<string, unknown>; // §5.1 — if the responder's term is higher, step down. if (typeof rp.term === 'number' && rp.term > this.node.currentTerm) { this.node.currentTerm = rp.term; this.node.votedFor = undefined; this.node.state = 'follower'; return false; } return rp.granted === true; } catch { return false; // unreachable peer / timeout — counts as no vote. } } // Legacy in-process path — mutates the local fake peer state. const peer = this.peers.get(peerId); if (!peer) return false; if (this.node.currentTerm > peer.currentTerm) { peer.votedFor = this.node.id; peer.currentTerm = this.node.currentTerm; return true; } return false; } private becomeLeader(): void { this.node.state = 'leader'; if (this.electionTimeout) { clearTimeout(this.electionTimeout); } // Start sending heartbeats this.heartbeatInterval = setInterval(() => { this.sendHeartbeats(); }, this.config.heartbeatIntervalMs ?? 50); this.emit('leader.elected', { term: this.node.currentTerm, leaderId: this.node.id }); } private async sendHeartbeats(): Promise<void> { for (const [peerId, peer] of this.peers) { await this.appendEntries(peerId, []); } } private async appendEntries(peerId: string, entries: RaftLogEntry[]): Promise<boolean> { // ADR-095 G2 — over the transport when wired: real AppendEntries RPC. if (this.transport) { try { const reply = await this.transport.send(peerId, { type: 'append-entries', payload: { term: this.node.currentTerm, leaderId: this.node.id, entries, leaderCommit: this.node.commitIndex }, term: this.node.currentTerm, }); const rp = (reply?.payload ?? {}) as Record<string, unknown>; if (typeof rp.term === 'number' && rp.term > this.node.currentTerm) { this.node.currentTerm = rp.term; this.node.votedFor = undefined; this.node.state = 'follower'; if (this.heartbeatInterval) { clearInterval(this.heartbeatInterval); this.heartbeatInterval = undefined; } return false; } return rp.success === true; } catch { return false; // unreachable peer / timeout. } } // Legacy in-process path. const peer = this.peers.get(peerId); if (!peer) return false; if (this.node.currentTerm >= peer.currentTerm) { peer.currentTerm = this.node.currentTerm; peer.state = 'follower'; peer.log.push(...entries); return true; } return false; } private async replicateToFollowers(entry: RaftLogEntry): Promise<void> { const replicationPromises = Array.from(this.peers.keys()).map( peerId => this.appendEntries(peerId, [entry]) ); const results = await Promise.allSettled(replicationPromises); const successCount = results.filter( r => r.status === 'fulfilled' && r.value ).length; // Check if majority replicated const majority = Math.floor((this.peers.size + 1) / 2) + 1; if (successCount + 1 >= majority) { this.node.commitIndex = entry.index; this.emit('log.committed', { index: entry.index }); } } private async checkConsensus(proposalId: string): Promise<void> { const proposal = this.proposals.get(proposalId); if (!proposal || proposal.status !== 'pending') { return; } const totalVoters = this.peers.size + 1; const votesReceived = proposal.votes.size; const approvingVotes = Array.from(proposal.votes.values()).filter( v => v.approve ).length; const threshold = this.config.threshold ?? 0.66; const quorum = Math.floor(totalVoters * threshold); if (approvingVotes >= quorum) { proposal.status = 'accepted'; this.emit('consensus.achieved', { proposalId, approved: true }); } else if (votesReceived - approvingVotes > totalVoters - quorum) { proposal.status = 'rejected'; this.emit('consensus.achieved', { proposalId, approved: false }); } } private createResult(proposal: ConsensusProposal, durationMs: number): ConsensusResult { const totalVoters = this.peers.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 / totalVoters, finalValue: proposal.value, rounds: 1, durationMs, }; } // Handle vote request from another candidate handleVoteRequest( candidateId: string, term: number, lastLogIndex: number, lastLogTerm: number ): boolean { if (term < this.node.currentTerm) { return false; } if (term > this.node.currentTerm) { this.node.currentTerm = term; this.node.state = 'follower'; this.node.votedFor = undefined; } if (this.node.votedFor === undefined || this.node.votedFor === candidateId) { // Check log is at least as up-to-date const lastEntry = this.node.log[this.node.log.length - 1]; const myLastTerm = lastEntry?.term ?? 0; const myLastIndex = lastEntry?.index ?? 0; if (lastLogTerm > myLastTerm || (lastLogTerm === myLastTerm && lastLogIndex >= myLastIndex)) { this.node.votedFor = candidateId; this.resetElectionTimeout(); return true; } } return false; } // Handle append entries from leader handleAppendEntries( leaderId: string, term: number, entries: RaftLogEntry[], leaderCommit: number ): boolean { if (term < this.node.currentTerm) { return false; } this.resetElectionTimeout(); if (term > this.node.currentTerm) { this.node.currentTerm = term; this.node.state = 'follower'; } this.node.votedFor = leaderId; // Append entries this.node.log.push(...entries); // Update commit index if (leaderCommit > this.node.commitIndex) { this.node.commitIndex = Math.min( leaderCommit, this.node.log.length ); } return true; } } export function createRaftConsensus(nodeId: string, config?: RaftConfig): RaftConsensus { return new RaftConsensus(nodeId, config); }