@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.
- package/.claude-flow/data/pending-insights.jsonl +12 -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 +9 -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/federation-hub.js +1 -1
- package/dist/federation-hub.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/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 +29 -0
- package/src/consensus/raft.ts +125 -7
- package/src/consensus/transport.ts +284 -0
- package/src/federation-hub.ts +1 -1
- package/src/queen-coordinator.ts +4 -3
- 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
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
|
@@ -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 {
|
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
|
|