@claude-flow/swarm 3.0.0-alpha.1 → 3.0.0-alpha.8
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.
- package/.claude-flow/data/pending-insights.jsonl +15 -0
- package/.claude-flow/hive-mind/state.json +18 -0
- package/__tests__/byzantine-transport.test.ts +147 -0
- package/__tests__/consensus-failure-injection.test.ts +161 -0
- package/__tests__/consensus-transport.test.ts +200 -0
- package/__tests__/federation-transport.test.ts +145 -0
- package/__tests__/gossip-transport.test.ts +81 -0
- package/__tests__/queen-coordinator.test.ts +2 -2
- package/__tests__/raft-transport.test.ts +121 -0
- package/dist/attention-coordinator.js +2 -2
- package/dist/attention-coordinator.js.map +1 -1
- package/dist/consensus/byzantine.d.ts +26 -0
- package/dist/consensus/byzantine.d.ts.map +1 -1
- package/dist/consensus/byzantine.js +92 -14
- package/dist/consensus/byzantine.js.map +1 -1
- package/dist/consensus/federation-transport.d.ts +82 -0
- package/dist/consensus/federation-transport.d.ts.map +1 -0
- package/dist/consensus/federation-transport.js +144 -0
- package/dist/consensus/federation-transport.js.map +1 -0
- package/dist/consensus/gossip.d.ts +31 -1
- package/dist/consensus/gossip.d.ts.map +1 -1
- package/dist/consensus/gossip.js +89 -12
- package/dist/consensus/gossip.js.map +1 -1
- package/dist/consensus/index.d.ts +4 -0
- package/dist/consensus/index.d.ts.map +1 -1
- package/dist/consensus/index.js +37 -0
- package/dist/consensus/index.js.map +1 -1
- package/dist/consensus/raft.d.ts +19 -0
- package/dist/consensus/raft.d.ts.map +1 -1
- package/dist/consensus/raft.js +113 -3
- package/dist/consensus/raft.js.map +1 -1
- package/dist/consensus/transport.d.ts +141 -0
- package/dist/consensus/transport.d.ts.map +1 -0
- package/dist/consensus/transport.js +205 -0
- package/dist/consensus/transport.js.map +1 -0
- package/dist/coordination/agent-registry.d.ts +2 -2
- package/dist/coordination/agent-registry.d.ts.map +1 -1
- package/dist/coordination/agent-registry.js +1 -1
- package/dist/coordination/agent-registry.js.map +1 -1
- package/dist/coordination/swarm-hub.d.ts +5 -5
- package/dist/coordination/swarm-hub.d.ts.map +1 -1
- package/dist/coordination/swarm-hub.js +5 -5
- package/dist/coordination/swarm-hub.js.map +1 -1
- package/dist/coordination/task-orchestrator.d.ts +3 -3
- package/dist/coordination/task-orchestrator.d.ts.map +1 -1
- package/dist/coordination/task-orchestrator.js +1 -1
- package/dist/coordination/task-orchestrator.js.map +1 -1
- package/dist/federation-hub.js +1 -1
- package/dist/federation-hub.js.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -0
- package/dist/index.js.map +1 -1
- package/dist/queen-coordinator.d.ts +2 -1
- package/dist/queen-coordinator.d.ts.map +1 -1
- package/dist/queen-coordinator.js +4 -3
- package/dist/queen-coordinator.js.map +1 -1
- package/dist/types.d.ts +10 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js.map +1 -1
- package/dist/workers/worker-dispatch.d.ts +6 -1
- package/dist/workers/worker-dispatch.d.ts.map +1 -1
- package/dist/workers/worker-dispatch.js +45 -41
- package/dist/workers/worker-dispatch.js.map +1 -1
- package/package.json +2 -3
- package/ruvector.db +0 -0
- package/src/attention-coordinator.ts +2 -2
- package/src/consensus/byzantine.ts +98 -15
- package/src/consensus/federation-transport.ts +185 -0
- package/src/consensus/gossip.ts +102 -16
- package/src/consensus/index.ts +59 -0
- package/src/consensus/raft.ts +125 -7
- package/src/consensus/transport.ts +284 -0
- package/src/coordination/agent-registry.ts +2 -2
- package/src/coordination/swarm-hub.ts +5 -5
- package/src/coordination/task-orchestrator.ts +3 -3
- package/src/federation-hub.ts +1 -1
- package/src/index.ts +26 -0
- package/src/queen-coordinator.ts +4 -3
- package/src/types.ts +10 -0
- package/src/workers/worker-dispatch.d.ts +1 -1
- package/src/workers/worker-dispatch.ts +45 -41
- package/tmp.json +0 -0
- package/tsconfig.tsbuildinfo +1 -1
- package/.agentic-flow/intelligence.json +0 -16
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ADR-095 G2 — FederationTransport: a ConsensusTransport backed by the
|
|
3
|
+
* federation plugin's ADR-104 WS wire (agentic-flow/transport/loader).
|
|
4
|
+
*
|
|
5
|
+
* @claude-flow/swarm has zero runtime dependencies and shouldn't take a
|
|
6
|
+
* hard dep on @claude-flow/plugin-agent-federation (the wiring would also
|
|
7
|
+
* be circular-ish). So this adapter takes its WS primitives by injection:
|
|
8
|
+
* pass an object that satisfies `AgenticFlowTransportLike` (which the
|
|
9
|
+
* federation plugin's `loadQuicTransport` result does) plus a small amount
|
|
10
|
+
* of consensus-level metadata (this node's id, how to address peers, who
|
|
11
|
+
* the peers are).
|
|
12
|
+
*
|
|
13
|
+
* The agentic-flow transport is fire-and-forget message delivery; this
|
|
14
|
+
* adapter layers request-response on top via per-message correlation ids
|
|
15
|
+
* (`corr`) + a pending-replies map + per-call timeout. Stream multiplexing
|
|
16
|
+
* (ADR-104 stream-mux) is used for the `streamId` so consensus traffic
|
|
17
|
+
* doesn't interleave with application traffic on the same connection.
|
|
18
|
+
*
|
|
19
|
+
* Optional Ed25519 signing: when a keypair is supplied, outbound messages
|
|
20
|
+
* are signed and inbound are verified against the sender's published
|
|
21
|
+
* public key (resolved via `resolvePeerPublicKey`) before reaching the
|
|
22
|
+
* handler. Fail-closed — an unverifiable inbound message is dropped.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import { randomBytes } from 'node:crypto';
|
|
26
|
+
import type {
|
|
27
|
+
ConsensusTransport,
|
|
28
|
+
ConsensusMessage,
|
|
29
|
+
ConsensusReply,
|
|
30
|
+
ConsensusMessageHandler,
|
|
31
|
+
NodeKeyPair,
|
|
32
|
+
} from './transport.js';
|
|
33
|
+
import { signMessage, verifyMessage } from './transport.js';
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Minimal shape of the agentic-flow transport (the `loadQuicTransport`
|
|
37
|
+
* result). We only need send + onMessage + (optional) close. Keeping this
|
|
38
|
+
* structural means swarm doesn't import agentic-flow.
|
|
39
|
+
*/
|
|
40
|
+
export interface AgenticFlowTransportLike {
|
|
41
|
+
send(address: string, message: { type?: string; payload: unknown; streamId?: string }): Promise<void>;
|
|
42
|
+
onMessage(handler: (msg: { from?: string; address?: string; type?: string; payload: unknown }) => void | Promise<void>): void;
|
|
43
|
+
close?(): Promise<void> | void;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** What we put on the wire — a ConsensusMessage plus correlation metadata. */
|
|
47
|
+
interface WireEnvelope {
|
|
48
|
+
/** Correlation id — present on requests; the reply echoes it. */
|
|
49
|
+
readonly corr?: string;
|
|
50
|
+
/** True if this envelope is a reply to a `corr` request. */
|
|
51
|
+
readonly isReply?: boolean;
|
|
52
|
+
/** The consensus message. `from` is filled by us. */
|
|
53
|
+
readonly msg: ConsensusMessage;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface FederationTransportOptions {
|
|
57
|
+
/** This node's consensus id (the `from` on outbound messages). */
|
|
58
|
+
readonly nodeId: string;
|
|
59
|
+
/** Map a consensus nodeId → the WS address agentic-flow uses to reach it. Return undefined for unknown peers. */
|
|
60
|
+
readonly addressOf: (nodeId: string) => string | undefined;
|
|
61
|
+
/** Currently-known peer consensus node ids (excludes self — or includes; we filter). */
|
|
62
|
+
readonly peerIds: () => readonly string[];
|
|
63
|
+
/** Stream id for consensus traffic (ADR-104 stream-mux). Defaults to 'ruflo-consensus'. */
|
|
64
|
+
readonly streamId?: string;
|
|
65
|
+
/** Default per-`send` timeout in ms. Defaults to 5000. */
|
|
66
|
+
readonly defaultTimeoutMs?: number;
|
|
67
|
+
/** Optional Ed25519 keypair — signs outbound, verifies inbound. */
|
|
68
|
+
readonly keyPair?: NodeKeyPair;
|
|
69
|
+
/** Resolve a peer consensus nodeId → its Ed25519 public key PEM. Required if `keyPair` is set and you want verification. */
|
|
70
|
+
readonly resolvePeerPublicKey?: (nodeId: string) => string | undefined;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export class FederationTransport implements ConsensusTransport {
|
|
74
|
+
readonly nodeId: string;
|
|
75
|
+
private readonly wire: AgenticFlowTransportLike;
|
|
76
|
+
private readonly addressOf: (nodeId: string) => string | undefined;
|
|
77
|
+
private readonly peerIdsFn: () => readonly string[];
|
|
78
|
+
private readonly streamId: string;
|
|
79
|
+
private readonly defaultTimeoutMs: number;
|
|
80
|
+
private readonly keyPair?: NodeKeyPair;
|
|
81
|
+
private readonly resolvePeerPublicKey?: (nodeId: string) => string | undefined;
|
|
82
|
+
private handler: ConsensusMessageHandler | null = null;
|
|
83
|
+
private closed = false;
|
|
84
|
+
private seqCounter = 0;
|
|
85
|
+
private readonly pending = new Map<string, { resolve: (r: ConsensusReply) => void; reject: (e: Error) => void; timer: ReturnType<typeof setTimeout> }>();
|
|
86
|
+
private readonly lastSeenSeq = new Map<string, number>();
|
|
87
|
+
|
|
88
|
+
constructor(wire: AgenticFlowTransportLike, opts: FederationTransportOptions) {
|
|
89
|
+
this.wire = wire;
|
|
90
|
+
this.nodeId = opts.nodeId;
|
|
91
|
+
this.addressOf = opts.addressOf;
|
|
92
|
+
this.peerIdsFn = opts.peerIds;
|
|
93
|
+
this.streamId = opts.streamId ?? 'ruflo-consensus';
|
|
94
|
+
this.defaultTimeoutMs = opts.defaultTimeoutMs ?? 5_000;
|
|
95
|
+
this.keyPair = opts.keyPair;
|
|
96
|
+
this.resolvePeerPublicKey = opts.resolvePeerPublicKey;
|
|
97
|
+
|
|
98
|
+
this.wire.onMessage(async (raw) => {
|
|
99
|
+
if (this.closed) return;
|
|
100
|
+
const env = raw.payload as WireEnvelope | undefined;
|
|
101
|
+
if (!env || !env.msg) return;
|
|
102
|
+
const msg = env.msg;
|
|
103
|
+
|
|
104
|
+
// Signature verification (if this node expects signed messages).
|
|
105
|
+
if (this.keyPair && this.resolvePeerPublicKey) {
|
|
106
|
+
const pub = this.resolvePeerPublicKey(msg.from);
|
|
107
|
+
if (!pub || !verifyMessage(msg, pub)) return; // fail-closed: drop
|
|
108
|
+
if (typeof msg.seq === 'number') {
|
|
109
|
+
const last = this.lastSeenSeq.get(msg.from) ?? 0;
|
|
110
|
+
if (msg.seq <= last) return; // replayed / out of order: drop
|
|
111
|
+
this.lastSeenSeq.set(msg.from, msg.seq);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Reply to a pending `send`?
|
|
116
|
+
if (env.isReply && env.corr) {
|
|
117
|
+
const p = this.pending.get(env.corr);
|
|
118
|
+
if (p) { clearTimeout(p.timer); this.pending.delete(env.corr); p.resolve(msg); }
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Inbound request → run the handler; if it returns a reply, send it back.
|
|
123
|
+
if (this.handler) {
|
|
124
|
+
const reply = await this.handler(msg);
|
|
125
|
+
if (reply && env.corr) {
|
|
126
|
+
const addr = this.addressOf(msg.from);
|
|
127
|
+
if (addr) {
|
|
128
|
+
const replyMsg = this.stamp({ ...reply, to: msg.from });
|
|
129
|
+
await this.wire.send(addr, { type: 'consensus', payload: { corr: env.corr, isReply: true, msg: replyMsg } as WireEnvelope, streamId: this.streamId }).catch(() => {});
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
onMessage(handler: ConsensusMessageHandler): void {
|
|
137
|
+
this.handler = handler;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
peers(): readonly string[] {
|
|
141
|
+
return this.peerIdsFn().filter(id => id !== this.nodeId);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
private stamp(msg: Omit<ConsensusMessage, 'from'>): ConsensusMessage {
|
|
145
|
+
const base: Omit<ConsensusMessage, 'signature'> = {
|
|
146
|
+
...msg,
|
|
147
|
+
from: this.nodeId,
|
|
148
|
+
seq: this.keyPair ? ++this.seqCounter : msg.seq,
|
|
149
|
+
};
|
|
150
|
+
return this.keyPair ? { ...base, signature: signMessage(base, this.keyPair.privateKeyPem) } : base;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
async send(to: string, msg: Omit<ConsensusMessage, 'from'>, timeoutMs?: number): Promise<ConsensusReply> {
|
|
154
|
+
if (this.closed) throw new Error('FederationTransport: closed');
|
|
155
|
+
const addr = this.addressOf(to);
|
|
156
|
+
if (!addr) throw new Error(`FederationTransport: no address for peer ${to}`);
|
|
157
|
+
const corr = randomBytes(8).toString('hex');
|
|
158
|
+
const stamped = this.stamp({ ...msg, to });
|
|
159
|
+
const t = timeoutMs ?? this.defaultTimeoutMs;
|
|
160
|
+
return new Promise<ConsensusReply>((resolve, reject) => {
|
|
161
|
+
const timer = setTimeout(() => { this.pending.delete(corr); reject(new Error(`FederationTransport: send to ${to} timed out (${t}ms)`)); }, t);
|
|
162
|
+
this.pending.set(corr, { resolve, reject, timer });
|
|
163
|
+
this.wire.send(addr, { type: 'consensus', payload: { corr, msg: stamped } as WireEnvelope, streamId: this.streamId })
|
|
164
|
+
.catch((e) => { clearTimeout(timer); this.pending.delete(corr); reject(e instanceof Error ? e : new Error(String(e))); });
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
async broadcast(msg: Omit<ConsensusMessage, 'from'>): Promise<void> {
|
|
169
|
+
if (this.closed) throw new Error('FederationTransport: closed');
|
|
170
|
+
const stamped = this.stamp(msg);
|
|
171
|
+
await Promise.allSettled(this.peers().map(async (to) => {
|
|
172
|
+
const addr = this.addressOf(to);
|
|
173
|
+
if (!addr) return;
|
|
174
|
+
await this.wire.send(addr, { type: 'consensus', payload: { msg: stamped } as WireEnvelope, streamId: this.streamId }).catch(() => {});
|
|
175
|
+
}));
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
async close(): Promise<void> {
|
|
179
|
+
this.closed = true;
|
|
180
|
+
this.handler = null;
|
|
181
|
+
for (const [, p] of this.pending) { clearTimeout(p.timer); p.reject(new Error('FederationTransport: closed')); }
|
|
182
|
+
this.pending.clear();
|
|
183
|
+
if (this.wire.close) await this.wire.close();
|
|
184
|
+
}
|
|
185
|
+
}
|
package/src/consensus/gossip.ts
CHANGED
|
@@ -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:
|
|
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
|
|
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
|
|
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
|
-
//
|
|
297
|
-
|
|
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
|
|
package/src/consensus/index.ts
CHANGED
|
@@ -16,10 +16,40 @@ import {
|
|
|
16
16
|
import { RaftConsensus, createRaftConsensus, RaftConfig } from './raft.js';
|
|
17
17
|
import { ByzantineConsensus, createByzantineConsensus, ByzantineConfig } from './byzantine.js';
|
|
18
18
|
import { GossipConsensus, createGossipConsensus, GossipConfig } from './gossip.js';
|
|
19
|
+
import type { ConsensusTransport } from './transport.js';
|
|
19
20
|
|
|
20
21
|
export { RaftConsensus, ByzantineConsensus, GossipConsensus };
|
|
21
22
|
export type { RaftConfig, ByzantineConfig, GossipConfig };
|
|
22
23
|
|
|
24
|
+
// ADR-095 G2 — pluggable consensus transport. Replaces the implicit
|
|
25
|
+
// single-process EventEmitter messaging in the consensus protocols.
|
|
26
|
+
// LocalTransport is the default (matches current behavior); FederationTransport
|
|
27
|
+
// (separate file, ADR-104 wire) is the multi-host one.
|
|
28
|
+
export {
|
|
29
|
+
LocalTransport,
|
|
30
|
+
LocalTransportRegistry,
|
|
31
|
+
defaultLocalRegistry,
|
|
32
|
+
generateNodeKeyPair,
|
|
33
|
+
signMessage,
|
|
34
|
+
verifyMessage,
|
|
35
|
+
canonicalizeForSigning,
|
|
36
|
+
messageDigest,
|
|
37
|
+
} from './transport.js';
|
|
38
|
+
export type {
|
|
39
|
+
ConsensusTransport,
|
|
40
|
+
ConsensusMessage,
|
|
41
|
+
ConsensusReply,
|
|
42
|
+
ConsensusMessageHandler,
|
|
43
|
+
NodeKeyPair,
|
|
44
|
+
LocalTransportOptions,
|
|
45
|
+
} from './transport.js';
|
|
46
|
+
|
|
47
|
+
// ADR-095 G2 — FederationTransport: ConsensusTransport over the federation
|
|
48
|
+
// plugin's ADR-104 WS wire (agentic-flow/transport/loader). Structural —
|
|
49
|
+
// swarm doesn't import agentic-flow; the caller passes a transport instance.
|
|
50
|
+
export { FederationTransport } from './federation-transport.js';
|
|
51
|
+
export type { AgenticFlowTransportLike, FederationTransportOptions } from './federation-transport.js';
|
|
52
|
+
|
|
23
53
|
type ConsensusImplementation = RaftConsensus | ByzantineConsensus | GossipConsensus;
|
|
24
54
|
|
|
25
55
|
export class ConsensusEngine extends EventEmitter implements IConsensusEngine {
|
|
@@ -45,6 +75,14 @@ export class ConsensusEngine extends EventEmitter implements IConsensusEngine {
|
|
|
45
75
|
this.config = { ...this.config, ...config };
|
|
46
76
|
}
|
|
47
77
|
|
|
78
|
+
// ADR-095 G2.2 — narrow the typed-as-unknown `transport` from
|
|
79
|
+
// ConsensusConfig into the real ConsensusTransport so Raft / Byzantine /
|
|
80
|
+
// Gossip get a working inter-node wire. Structural check, not
|
|
81
|
+
// instanceof, so test mocks satisfy it without importing transport.ts.
|
|
82
|
+
const transport = isConsensusTransport(this.config.transport)
|
|
83
|
+
? this.config.transport
|
|
84
|
+
: undefined;
|
|
85
|
+
|
|
48
86
|
// Create implementation based on algorithm
|
|
49
87
|
switch (this.config.algorithm) {
|
|
50
88
|
case 'raft':
|
|
@@ -53,6 +91,7 @@ export class ConsensusEngine extends EventEmitter implements IConsensusEngine {
|
|
|
53
91
|
timeoutMs: this.config.timeoutMs,
|
|
54
92
|
maxRounds: this.config.maxRounds,
|
|
55
93
|
requireQuorum: this.config.requireQuorum,
|
|
94
|
+
transport,
|
|
56
95
|
});
|
|
57
96
|
break;
|
|
58
97
|
|
|
@@ -62,6 +101,7 @@ export class ConsensusEngine extends EventEmitter implements IConsensusEngine {
|
|
|
62
101
|
timeoutMs: this.config.timeoutMs,
|
|
63
102
|
maxRounds: this.config.maxRounds,
|
|
64
103
|
requireQuorum: this.config.requireQuorum,
|
|
104
|
+
transport,
|
|
65
105
|
});
|
|
66
106
|
break;
|
|
67
107
|
|
|
@@ -71,6 +111,7 @@ export class ConsensusEngine extends EventEmitter implements IConsensusEngine {
|
|
|
71
111
|
timeoutMs: this.config.timeoutMs,
|
|
72
112
|
maxRounds: this.config.maxRounds,
|
|
73
113
|
requireQuorum: this.config.requireQuorum,
|
|
114
|
+
transport,
|
|
74
115
|
});
|
|
75
116
|
break;
|
|
76
117
|
|
|
@@ -81,6 +122,7 @@ export class ConsensusEngine extends EventEmitter implements IConsensusEngine {
|
|
|
81
122
|
timeoutMs: this.config.timeoutMs,
|
|
82
123
|
maxRounds: this.config.maxRounds,
|
|
83
124
|
requireQuorum: this.config.requireQuorum,
|
|
125
|
+
transport,
|
|
84
126
|
});
|
|
85
127
|
break;
|
|
86
128
|
|
|
@@ -233,6 +275,23 @@ export function createConsensusEngine(
|
|
|
233
275
|
return new ConsensusEngine(nodeId, { ...config, algorithm });
|
|
234
276
|
}
|
|
235
277
|
|
|
278
|
+
/**
|
|
279
|
+
* ADR-095 G2.2 — structural check that an opaque value implements the
|
|
280
|
+
* ConsensusTransport interface. Used by the engine to safely narrow the
|
|
281
|
+
* `transport` field on `ConsensusConfig` (typed as `unknown` to keep
|
|
282
|
+
* `types.ts` free of cross-module imports).
|
|
283
|
+
*/
|
|
284
|
+
function isConsensusTransport(value: unknown): value is ConsensusTransport {
|
|
285
|
+
if (!value || typeof value !== 'object') return false;
|
|
286
|
+
const t = value as Record<string, unknown>;
|
|
287
|
+
return typeof t.nodeId === 'string'
|
|
288
|
+
&& typeof t.send === 'function'
|
|
289
|
+
&& typeof t.broadcast === 'function'
|
|
290
|
+
&& typeof t.onMessage === 'function'
|
|
291
|
+
&& typeof t.peers === 'function'
|
|
292
|
+
&& typeof t.close === 'function';
|
|
293
|
+
}
|
|
294
|
+
|
|
236
295
|
// Helper to select optimal algorithm based on requirements
|
|
237
296
|
export function selectOptimalAlgorithm(requirements: {
|
|
238
297
|
faultTolerance: 'crash' | 'byzantine';
|
package/src/consensus/raft.ts
CHANGED
|
@@ -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
|
|