@claude-flow/swarm 3.0.0-alpha.1 → 3.0.0-alpha.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/.claude-flow/data/pending-insights.jsonl +12 -0
  2. package/__tests__/byzantine-transport.test.ts +147 -0
  3. package/__tests__/consensus-failure-injection.test.ts +161 -0
  4. package/__tests__/consensus-transport.test.ts +200 -0
  5. package/__tests__/federation-transport.test.ts +145 -0
  6. package/__tests__/gossip-transport.test.ts +81 -0
  7. package/__tests__/queen-coordinator.test.ts +2 -2
  8. package/__tests__/raft-transport.test.ts +121 -0
  9. package/dist/attention-coordinator.js +2 -2
  10. package/dist/attention-coordinator.js.map +1 -1
  11. package/dist/consensus/byzantine.d.ts +26 -0
  12. package/dist/consensus/byzantine.d.ts.map +1 -1
  13. package/dist/consensus/byzantine.js +92 -14
  14. package/dist/consensus/byzantine.js.map +1 -1
  15. package/dist/consensus/federation-transport.d.ts +82 -0
  16. package/dist/consensus/federation-transport.d.ts.map +1 -0
  17. package/dist/consensus/federation-transport.js +144 -0
  18. package/dist/consensus/federation-transport.js.map +1 -0
  19. package/dist/consensus/gossip.d.ts +31 -1
  20. package/dist/consensus/gossip.d.ts.map +1 -1
  21. package/dist/consensus/gossip.js +89 -12
  22. package/dist/consensus/gossip.js.map +1 -1
  23. package/dist/consensus/index.d.ts +4 -0
  24. package/dist/consensus/index.d.ts.map +1 -1
  25. package/dist/consensus/index.js +9 -0
  26. package/dist/consensus/index.js.map +1 -1
  27. package/dist/consensus/raft.d.ts +19 -0
  28. package/dist/consensus/raft.d.ts.map +1 -1
  29. package/dist/consensus/raft.js +113 -3
  30. package/dist/consensus/raft.js.map +1 -1
  31. package/dist/consensus/transport.d.ts +141 -0
  32. package/dist/consensus/transport.d.ts.map +1 -0
  33. package/dist/consensus/transport.js +205 -0
  34. package/dist/consensus/transport.js.map +1 -0
  35. package/dist/federation-hub.js +1 -1
  36. package/dist/federation-hub.js.map +1 -1
  37. package/dist/queen-coordinator.d.ts +2 -1
  38. package/dist/queen-coordinator.d.ts.map +1 -1
  39. package/dist/queen-coordinator.js +4 -3
  40. package/dist/queen-coordinator.js.map +1 -1
  41. package/dist/workers/worker-dispatch.d.ts +6 -1
  42. package/dist/workers/worker-dispatch.d.ts.map +1 -1
  43. package/dist/workers/worker-dispatch.js +45 -41
  44. package/dist/workers/worker-dispatch.js.map +1 -1
  45. package/package.json +2 -3
  46. package/ruvector.db +0 -0
  47. package/src/attention-coordinator.ts +2 -2
  48. package/src/consensus/byzantine.ts +98 -15
  49. package/src/consensus/federation-transport.ts +185 -0
  50. package/src/consensus/gossip.ts +102 -16
  51. package/src/consensus/index.ts +29 -0
  52. package/src/consensus/raft.ts +125 -7
  53. package/src/consensus/transport.ts +284 -0
  54. package/src/federation-hub.ts +1 -1
  55. package/src/queen-coordinator.ts +4 -3
  56. package/src/workers/worker-dispatch.d.ts +1 -1
  57. package/src/workers/worker-dispatch.ts +45 -41
  58. package/tmp.json +0 -0
  59. package/tsconfig.tsbuildinfo +1 -1
  60. package/.agentic-flow/intelligence.json +0 -16
@@ -4,6 +4,7 @@
4
4
  */
5
5
 
6
6
  import { EventEmitter } from 'events';
7
+ import type { ConsensusTransport, ConsensusMessage } from './transport.js';
7
8
  import {
8
9
  ConsensusProposal,
9
10
  ConsensusVote,
@@ -24,12 +25,50 @@ export interface GossipMessage {
24
25
  path: string[];
25
26
  }
26
27
 
28
+ /**
29
+ * Bounded set that evicts oldest entries when capacity is reached.
30
+ * Uses Map insertion-order for O(1) FIFO eviction. (PERF-01)
31
+ */
32
+ export class BoundedSet<T> {
33
+ private map = new Map<T, true>();
34
+ private readonly maxSize: number;
35
+
36
+ constructor(maxSize: number) {
37
+ this.maxSize = maxSize;
38
+ }
39
+
40
+ has(value: T): boolean {
41
+ return this.map.has(value);
42
+ }
43
+
44
+ add(value: T): void {
45
+ if (this.map.has(value)) return;
46
+
47
+ if (this.map.size >= this.maxSize) {
48
+ // Evict oldest (first inserted)
49
+ const oldest = this.map.keys().next().value;
50
+ if (oldest !== undefined) {
51
+ this.map.delete(oldest);
52
+ }
53
+ }
54
+ this.map.set(value, true);
55
+ }
56
+
57
+ get size(): number {
58
+ return this.map.size;
59
+ }
60
+
61
+ clear(): void {
62
+ this.map.clear();
63
+ }
64
+ }
65
+
27
66
  export interface GossipNode {
28
67
  id: string;
29
68
  state: Map<string, unknown>;
30
69
  version: number;
31
70
  neighbors: Set<string>;
32
- seenMessages: Set<string>;
71
+ seenMessages: BoundedSet<string>;
33
72
  lastSync: Date;
34
73
  }
35
74
 
@@ -38,6 +77,14 @@ export interface GossipConfig extends Partial<ConsensusConfig> {
38
77
  gossipIntervalMs?: number;
39
78
  maxHops?: number;
40
79
  convergenceThreshold?: number;
80
+ /**
81
+ * ADR-095 G2 — optional pluggable transport. When set, gossip messages
82
+ * to neighbors actually go over it (signed if the transport has a
83
+ * keypair) and inbound gossip is routed back into the merge logic.
84
+ * When unset, behavior is unchanged: the legacy in-process path mutates
85
+ * the local `nodes` map directly (single-process).
86
+ */
87
+ transport?: ConsensusTransport;
41
88
  }
42
89
 
43
90
  export class GossipConsensus extends EventEmitter {
@@ -48,6 +95,7 @@ export class GossipConsensus extends EventEmitter {
48
95
  private messageQueue: GossipMessage[] = [];
49
96
  private gossipInterval?: NodeJS.Timeout;
50
97
  private proposalCounter: number = 0;
98
+ private readonly transport?: ConsensusTransport;
51
99
 
52
100
  constructor(nodeId: string, config: GossipConfig = {}) {
53
101
  super();
@@ -60,16 +108,46 @@ export class GossipConsensus extends EventEmitter {
60
108
  gossipIntervalMs: config.gossipIntervalMs ?? 100,
61
109
  maxHops: config.maxHops ?? 10,
62
110
  convergenceThreshold: config.convergenceThreshold ?? 0.9,
111
+ transport: config.transport,
63
112
  };
113
+ this.transport = config.transport;
64
114
 
65
115
  this.node = {
66
116
  id: nodeId,
67
117
  state: new Map(),
68
118
  version: 0,
69
119
  neighbors: new Set(),
70
- seenMessages: new Set(),
120
+ seenMessages: new BoundedSet(100_000), // PERF-01: ~4MB cap (100K × ~40B IDs)
71
121
  lastSync: new Date(),
72
122
  };
123
+
124
+ if (this.transport) {
125
+ this.transport.onMessage(async (msg: ConsensusMessage) => this.handleInboundGossipMessage(msg));
126
+ }
127
+ }
128
+
129
+ /**
130
+ * ADR-095 G2 — route an inbound transport message into the gossip merge
131
+ * logic. The transport handles signature verification (if enabled). We
132
+ * dedupe by message id (the BoundedSet `seenMessages`) and process it as
133
+ * though it arrived from a neighbor.
134
+ */
135
+ private async handleInboundGossipMessage(msg: ConsensusMessage): Promise<void> {
136
+ if (msg.type !== 'gossip') return;
137
+ const gm = msg.payload as GossipMessage | undefined;
138
+ if (!gm || typeof gm.id !== 'string') return;
139
+ if (this.node.seenMessages.has(gm.id)) return;
140
+ // Ensure the sender is known as a neighbor so processReceivedMessage works.
141
+ if (!this.nodes.has(msg.from)) {
142
+ this.nodes.set(msg.from, { id: msg.from, state: new Map(), version: 0, neighbors: new Set(), seenMessages: new BoundedSet(100_000), lastSync: new Date() });
143
+ }
144
+ this.node.neighbors.add(msg.from);
145
+ // Process against THIS node's state (the message reached us).
146
+ await this.processReceivedMessage(this.node, {
147
+ ...gm,
148
+ timestamp: gm.timestamp ? new Date(gm.timestamp as unknown as string) : new Date(),
149
+ path: Array.isArray(gm.path) ? gm.path : [],
150
+ });
73
151
  }
74
152
 
75
153
  async initialize(): Promise<void> {
@@ -90,7 +168,7 @@ export class GossipConsensus extends EventEmitter {
90
168
  state: new Map(),
91
169
  version: 0,
92
170
  neighbors: new Set(),
93
- seenMessages: new Set(),
171
+ seenMessages: new BoundedSet(100_000), // PERF-01: bounded to prevent memory leak (~4MB cap)
94
172
  lastSync: new Date(),
95
173
  });
96
174
 
@@ -276,26 +354,34 @@ export class GossipConsensus extends EventEmitter {
276
354
  }
277
355
 
278
356
  private async sendToNeighbor(neighborId: string, message: GossipMessage): Promise<void> {
279
- const neighbor = this.nodes.get(neighborId);
280
- if (!neighbor) {
281
- return;
282
- }
283
-
284
- // Check if already seen
285
- if (neighbor.seenMessages.has(message.id)) {
286
- return;
287
- }
288
-
289
- // Simulate network delivery
290
357
  const deliveredMessage: GossipMessage = {
291
358
  ...message,
292
359
  hops: message.hops + 1,
293
360
  path: [...message.path, neighborId],
294
361
  };
295
362
 
296
- // Process at neighbor
297
- await this.processReceivedMessage(neighbor, deliveredMessage);
363
+ // ADR-095 G2 — over the transport when wired: actually send the gossip
364
+ // message to the neighbor (signed by the transport if signing is on).
365
+ // The emit stays for observability.
366
+ if (this.transport) {
367
+ this.emit('message.sent', { to: neighborId, message: deliveredMessage });
368
+ try {
369
+ await this.transport.send(neighborId, {
370
+ type: 'gossip',
371
+ payload: { ...deliveredMessage, timestamp: deliveredMessage.timestamp.toISOString() },
372
+ });
373
+ } catch {
374
+ // Unreachable neighbor — gossip tolerates this; it'll converge via
375
+ // other paths or the next gossip round.
376
+ }
377
+ return;
378
+ }
298
379
 
380
+ // Legacy in-process path — deliver to the fake neighbor state.
381
+ const neighbor = this.nodes.get(neighborId);
382
+ if (!neighbor) return;
383
+ if (neighbor.seenMessages.has(message.id)) return;
384
+ await this.processReceivedMessage(neighbor, deliveredMessage);
299
385
  this.emit('message.sent', { to: neighborId, message: deliveredMessage });
300
386
  }
301
387
 
@@ -20,6 +20,35 @@ import { GossipConsensus, createGossipConsensus, GossipConfig } from './gossip.j
20
20
  export { RaftConsensus, ByzantineConsensus, GossipConsensus };
21
21
  export type { RaftConfig, ByzantineConfig, GossipConfig };
22
22
 
23
+ // ADR-095 G2 — pluggable consensus transport. Replaces the implicit
24
+ // single-process EventEmitter messaging in the consensus protocols.
25
+ // LocalTransport is the default (matches current behavior); FederationTransport
26
+ // (separate file, ADR-104 wire) is the multi-host one.
27
+ export {
28
+ LocalTransport,
29
+ LocalTransportRegistry,
30
+ defaultLocalRegistry,
31
+ generateNodeKeyPair,
32
+ signMessage,
33
+ verifyMessage,
34
+ canonicalizeForSigning,
35
+ messageDigest,
36
+ } from './transport.js';
37
+ export type {
38
+ ConsensusTransport,
39
+ ConsensusMessage,
40
+ ConsensusReply,
41
+ ConsensusMessageHandler,
42
+ NodeKeyPair,
43
+ LocalTransportOptions,
44
+ } from './transport.js';
45
+
46
+ // ADR-095 G2 — FederationTransport: ConsensusTransport over the federation
47
+ // plugin's ADR-104 WS wire (agentic-flow/transport/loader). Structural —
48
+ // swarm doesn't import agentic-flow; the caller passes a transport instance.
49
+ export { FederationTransport } from './federation-transport.js';
50
+ export type { AgenticFlowTransportLike, FederationTransportOptions } from './federation-transport.js';
51
+
23
52
  type ConsensusImplementation = RaftConsensus | ByzantineConsensus | GossipConsensus;
24
53
 
25
54
  export class ConsensusEngine extends EventEmitter implements IConsensusEngine {
@@ -4,6 +4,7 @@
4
4
  */
5
5
 
6
6
  import { EventEmitter } from 'events';
7
+ import type { ConsensusTransport, ConsensusMessage } from './transport.js';
7
8
  import {
8
9
  ConsensusProposal,
9
10
  ConsensusVote,
@@ -35,6 +36,15 @@ export interface RaftConfig extends Partial<ConsensusConfig> {
35
36
  electionTimeoutMinMs?: number;
36
37
  electionTimeoutMaxMs?: number;
37
38
  heartbeatIntervalMs?: number;
39
+ /**
40
+ * ADR-095 G2 — optional pluggable transport. When set, RequestVote and
41
+ * AppendEntries RPCs go over it (request-response) and this node also
42
+ * answers inbound RequestVote / AppendEntries from peers using proper
43
+ * Raft receiver rules (term comparison, vote-once-per-term, log-up-to-date
44
+ * check). When unset, behavior is unchanged: the legacy in-process path
45
+ * mutates the local `peers` map directly (single-process).
46
+ */
47
+ transport?: ConsensusTransport;
38
48
  }
39
49
 
40
50
  export class RaftConsensus extends EventEmitter {
@@ -45,6 +55,7 @@ export class RaftConsensus extends EventEmitter {
45
55
  private electionTimeout?: NodeJS.Timeout;
46
56
  private heartbeatInterval?: NodeJS.Timeout;
47
57
  private proposalCounter: number = 0;
58
+ private readonly transport?: ConsensusTransport;
48
59
 
49
60
  constructor(nodeId: string, config: RaftConfig = {}) {
50
61
  super();
@@ -56,7 +67,9 @@ export class RaftConsensus extends EventEmitter {
56
67
  electionTimeoutMinMs: config.electionTimeoutMinMs ?? 150,
57
68
  electionTimeoutMaxMs: config.electionTimeoutMaxMs ?? 300,
58
69
  heartbeatIntervalMs: config.heartbeatIntervalMs ?? 50,
70
+ transport: config.transport,
59
71
  };
72
+ this.transport = config.transport;
60
73
 
61
74
  this.node = {
62
75
  id: nodeId,
@@ -66,6 +79,71 @@ export class RaftConsensus extends EventEmitter {
66
79
  commitIndex: 0,
67
80
  lastApplied: 0,
68
81
  };
82
+
83
+ if (this.transport) {
84
+ this.transport.onMessage(async (msg: ConsensusMessage) => this.handleInboundRaftMessage(msg));
85
+ }
86
+ }
87
+
88
+ /** Index/term of the last entry in this node's log (Raft "up-to-date" comparison). */
89
+ private lastLogInfo(): { index: number; term: number } {
90
+ const last = this.node.log[this.node.log.length - 1];
91
+ return last ? { index: last.index, term: last.term } : { index: 0, term: 0 };
92
+ }
93
+
94
+ /**
95
+ * ADR-095 G2 — Raft receiver. Answers inbound RequestVote / AppendEntries
96
+ * with proper Raft rules. Returns the RPC response the transport relays
97
+ * back to the caller (the transport's `send` resolves with it).
98
+ */
99
+ private async handleInboundRaftMessage(msg: ConsensusMessage): Promise<ConsensusMessage | void> {
100
+ const p = (msg.payload ?? {}) as Record<string, unknown>;
101
+ const term = typeof p.term === 'number' ? p.term : 0;
102
+
103
+ // §5.1 — any RPC with a higher term makes us a follower and adopts it.
104
+ if (term > this.node.currentTerm) {
105
+ this.node.currentTerm = term;
106
+ this.node.votedFor = undefined;
107
+ this.node.state = 'follower';
108
+ }
109
+
110
+ if (msg.type === 'request-vote') {
111
+ const candidateId = String(p.candidateId ?? msg.from);
112
+ const candLastIndex = typeof p.lastLogIndex === 'number' ? p.lastLogIndex : 0;
113
+ const candLastTerm = typeof p.lastLogTerm === 'number' ? p.lastLogTerm : 0;
114
+ const my = this.lastLogInfo();
115
+ const logOk = candLastTerm > my.term || (candLastTerm === my.term && candLastIndex >= my.index);
116
+ const termOk = term >= this.node.currentTerm;
117
+ const notVotedOrSame = this.node.votedFor === undefined || this.node.votedFor === candidateId;
118
+ const granted = termOk && notVotedOrSame && logOk;
119
+ if (granted) {
120
+ this.node.votedFor = candidateId;
121
+ this.resetElectionTimeout();
122
+ }
123
+ return { type: 'vote-response', from: this.node.id, to: msg.from, payload: { term: this.node.currentTerm, granted }, term: this.node.currentTerm };
124
+ }
125
+
126
+ if (msg.type === 'append-entries') {
127
+ // §5.2/5.3 — reject if leader's term is stale.
128
+ if (term < this.node.currentTerm) {
129
+ return { type: 'append-entries-response', from: this.node.id, to: msg.from, payload: { term: this.node.currentTerm, success: false }, term: this.node.currentTerm };
130
+ }
131
+ // Valid leader heartbeat/append → become/stay follower, reset election timer.
132
+ this.node.state = 'follower';
133
+ this.resetElectionTimeout();
134
+ const entries = Array.isArray(p.entries) ? (p.entries as RaftLogEntry[]) : [];
135
+ // (Simplified log matching: append entries not already present by index.
136
+ // Full prevLogIndex/prevLogTerm conflict resolution is the next refinement.)
137
+ for (const e of entries) {
138
+ if (!this.node.log.some(x => x.index === e.index)) this.node.log.push(e);
139
+ }
140
+ const leaderCommit = typeof p.leaderCommit === 'number' ? p.leaderCommit : this.node.commitIndex;
141
+ if (leaderCommit > this.node.commitIndex) {
142
+ this.node.commitIndex = Math.min(leaderCommit, this.lastLogInfo().index);
143
+ }
144
+ return { type: 'append-entries-response', from: this.node.id, to: msg.from, payload: { term: this.node.currentTerm, success: true }, term: this.node.currentTerm };
145
+ }
146
+ return;
69
147
  }
70
148
 
71
149
  async initialize(): Promise<void> {
@@ -255,17 +333,37 @@ export class RaftConsensus extends EventEmitter {
255
333
  }
256
334
 
257
335
  private async requestVote(peerId: string): Promise<boolean> {
336
+ // ADR-095 G2 — over the transport when wired: real RequestVote RPC.
337
+ if (this.transport) {
338
+ const my = this.lastLogInfo();
339
+ try {
340
+ const reply = await this.transport.send(peerId, {
341
+ type: 'request-vote',
342
+ payload: { term: this.node.currentTerm, candidateId: this.node.id, lastLogIndex: my.index, lastLogTerm: my.term },
343
+ term: this.node.currentTerm,
344
+ });
345
+ const rp = (reply?.payload ?? {}) as Record<string, unknown>;
346
+ // §5.1 — if the responder's term is higher, step down.
347
+ if (typeof rp.term === 'number' && rp.term > this.node.currentTerm) {
348
+ this.node.currentTerm = rp.term;
349
+ this.node.votedFor = undefined;
350
+ this.node.state = 'follower';
351
+ return false;
352
+ }
353
+ return rp.granted === true;
354
+ } catch {
355
+ return false; // unreachable peer / timeout — counts as no vote.
356
+ }
357
+ }
358
+
359
+ // Legacy in-process path — mutates the local fake peer state.
258
360
  const peer = this.peers.get(peerId);
259
361
  if (!peer) return false;
260
-
261
- // Simulate vote request (in real implementation, this would be RPC)
262
- // For now, grant vote if candidate's term is higher
263
362
  if (this.node.currentTerm > peer.currentTerm) {
264
363
  peer.votedFor = this.node.id;
265
364
  peer.currentTerm = this.node.currentTerm;
266
365
  return true;
267
366
  }
268
-
269
367
  return false;
270
368
  }
271
369
 
@@ -294,17 +392,37 @@ export class RaftConsensus extends EventEmitter {
294
392
  }
295
393
 
296
394
  private async appendEntries(peerId: string, entries: RaftLogEntry[]): Promise<boolean> {
395
+ // ADR-095 G2 — over the transport when wired: real AppendEntries RPC.
396
+ if (this.transport) {
397
+ try {
398
+ const reply = await this.transport.send(peerId, {
399
+ type: 'append-entries',
400
+ payload: { term: this.node.currentTerm, leaderId: this.node.id, entries, leaderCommit: this.node.commitIndex },
401
+ term: this.node.currentTerm,
402
+ });
403
+ const rp = (reply?.payload ?? {}) as Record<string, unknown>;
404
+ if (typeof rp.term === 'number' && rp.term > this.node.currentTerm) {
405
+ this.node.currentTerm = rp.term;
406
+ this.node.votedFor = undefined;
407
+ this.node.state = 'follower';
408
+ if (this.heartbeatInterval) { clearInterval(this.heartbeatInterval); this.heartbeatInterval = undefined; }
409
+ return false;
410
+ }
411
+ return rp.success === true;
412
+ } catch {
413
+ return false; // unreachable peer / timeout.
414
+ }
415
+ }
416
+
417
+ // Legacy in-process path.
297
418
  const peer = this.peers.get(peerId);
298
419
  if (!peer) return false;
299
-
300
- // Simulate AppendEntries RPC
301
420
  if (this.node.currentTerm >= peer.currentTerm) {
302
421
  peer.currentTerm = this.node.currentTerm;
303
422
  peer.state = 'follower';
304
423
  peer.log.push(...entries);
305
424
  return true;
306
425
  }
307
-
308
426
  return false;
309
427
  }
310
428