hungry-ghost-hive 0.45.0 → 0.47.0

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 (136) hide show
  1. package/dist/cli/commands/cluster.d.ts.map +1 -1
  2. package/dist/cli/commands/cluster.js +348 -1
  3. package/dist/cli/commands/cluster.js.map +1 -1
  4. package/dist/cli/commands/cluster.test.js +313 -9
  5. package/dist/cli/commands/cluster.test.js.map +1 -1
  6. package/dist/cli/commands/manager/index.js +6 -4
  7. package/dist/cli/commands/manager/index.js.map +1 -1
  8. package/dist/cli/commands/req-spawn.test.d.ts +2 -0
  9. package/dist/cli/commands/req-spawn.test.d.ts.map +1 -0
  10. package/dist/cli/commands/req-spawn.test.js +116 -0
  11. package/dist/cli/commands/req-spawn.test.js.map +1 -0
  12. package/dist/cli/commands/req.d.ts.map +1 -1
  13. package/dist/cli/commands/req.js +21 -13
  14. package/dist/cli/commands/req.js.map +1 -1
  15. package/dist/cli/dashboard/index.d.ts.map +1 -1
  16. package/dist/cli/dashboard/index.js +14 -2
  17. package/dist/cli/dashboard/index.js.map +1 -1
  18. package/dist/cli/dashboard/panels/agents.d.ts +1 -1
  19. package/dist/cli/dashboard/panels/agents.d.ts.map +1 -1
  20. package/dist/cli/dashboard/panels/agents.js +7 -3
  21. package/dist/cli/dashboard/panels/agents.js.map +1 -1
  22. package/dist/cli/dashboard/panels/escalations.d.ts +1 -1
  23. package/dist/cli/dashboard/panels/escalations.d.ts.map +1 -1
  24. package/dist/cli/dashboard/panels/escalations.js +7 -1
  25. package/dist/cli/dashboard/panels/escalations.js.map +1 -1
  26. package/dist/cluster/cluster-http-server.d.ts +32 -0
  27. package/dist/cluster/cluster-http-server.d.ts.map +1 -1
  28. package/dist/cluster/cluster-http-server.js +42 -0
  29. package/dist/cluster/cluster-http-server.js.map +1 -1
  30. package/dist/cluster/distributed-runtime-coverage.test.js +9 -0
  31. package/dist/cluster/distributed-runtime-coverage.test.js.map +1 -1
  32. package/dist/cluster/distributed-system.test.js +135 -0
  33. package/dist/cluster/distributed-system.test.js.map +1 -1
  34. package/dist/cluster/events.d.ts +23 -0
  35. package/dist/cluster/events.d.ts.map +1 -1
  36. package/dist/cluster/events.js +74 -0
  37. package/dist/cluster/events.js.map +1 -1
  38. package/dist/cluster/heartbeat-manager.d.ts +2 -0
  39. package/dist/cluster/heartbeat-manager.d.ts.map +1 -1
  40. package/dist/cluster/heartbeat-manager.js +42 -6
  41. package/dist/cluster/heartbeat-manager.js.map +1 -1
  42. package/dist/cluster/membership.test.d.ts +2 -0
  43. package/dist/cluster/membership.test.d.ts.map +1 -0
  44. package/dist/cluster/membership.test.js +416 -0
  45. package/dist/cluster/membership.test.js.map +1 -0
  46. package/dist/cluster/partition-safety.test.d.ts +2 -0
  47. package/dist/cluster/partition-safety.test.d.ts.map +1 -0
  48. package/dist/cluster/partition-safety.test.js +440 -0
  49. package/dist/cluster/partition-safety.test.js.map +1 -0
  50. package/dist/cluster/raft-state-machine.d.ts +33 -1
  51. package/dist/cluster/raft-state-machine.d.ts.map +1 -1
  52. package/dist/cluster/raft-state-machine.js +65 -3
  53. package/dist/cluster/raft-state-machine.js.map +1 -1
  54. package/dist/cluster/raft-store.d.ts +26 -1
  55. package/dist/cluster/raft-store.d.ts.map +1 -1
  56. package/dist/cluster/raft-store.js +137 -0
  57. package/dist/cluster/raft-store.js.map +1 -1
  58. package/dist/cluster/replication-lag.test.d.ts +2 -0
  59. package/dist/cluster/replication-lag.test.d.ts.map +1 -0
  60. package/dist/cluster/replication-lag.test.js +239 -0
  61. package/dist/cluster/replication-lag.test.js.map +1 -0
  62. package/dist/cluster/replication.d.ts +2 -2
  63. package/dist/cluster/replication.d.ts.map +1 -1
  64. package/dist/cluster/replication.js +1 -1
  65. package/dist/cluster/replication.js.map +1 -1
  66. package/dist/cluster/runtime.d.ts +78 -0
  67. package/dist/cluster/runtime.d.ts.map +1 -1
  68. package/dist/cluster/runtime.js +400 -13
  69. package/dist/cluster/runtime.js.map +1 -1
  70. package/dist/cluster/state-recovery.test.d.ts +2 -0
  71. package/dist/cluster/state-recovery.test.d.ts.map +1 -0
  72. package/dist/cluster/state-recovery.test.js +310 -0
  73. package/dist/cluster/state-recovery.test.js.map +1 -0
  74. package/dist/cluster/types.d.ts +30 -0
  75. package/dist/cluster/types.d.ts.map +1 -1
  76. package/dist/config/schema.d.ts +48 -0
  77. package/dist/config/schema.d.ts.map +1 -1
  78. package/dist/config/schema.js +11 -0
  79. package/dist/config/schema.js.map +1 -1
  80. package/dist/connectors/auth/jira.js +1 -1
  81. package/dist/connectors/auth/jira.js.map +1 -1
  82. package/dist/connectors/auth/jira.test.js +18 -0
  83. package/dist/connectors/auth/jira.test.js.map +1 -1
  84. package/dist/context-files/generator.js +1 -1
  85. package/dist/context-files/generator.js.map +1 -1
  86. package/dist/context-files/generator.test.js +51 -0
  87. package/dist/context-files/generator.test.js.map +1 -1
  88. package/dist/orchestrator/orphan-recovery.d.ts +1 -1
  89. package/dist/orchestrator/orphan-recovery.d.ts.map +1 -1
  90. package/dist/orchestrator/orphan-recovery.js +4 -4
  91. package/dist/orchestrator/orphan-recovery.js.map +1 -1
  92. package/dist/orchestrator/prompt-templates.d.ts +3 -1
  93. package/dist/orchestrator/prompt-templates.d.ts.map +1 -1
  94. package/dist/orchestrator/prompt-templates.js +45 -8
  95. package/dist/orchestrator/prompt-templates.js.map +1 -1
  96. package/dist/orchestrator/prompt-templates.test.js +210 -0
  97. package/dist/orchestrator/prompt-templates.test.js.map +1 -1
  98. package/dist/orchestrator/scheduler.d.ts +1 -0
  99. package/dist/orchestrator/scheduler.d.ts.map +1 -1
  100. package/dist/orchestrator/scheduler.js +15 -10
  101. package/dist/orchestrator/scheduler.js.map +1 -1
  102. package/dist/orchestrator/scheduler.test.js +97 -6
  103. package/dist/orchestrator/scheduler.test.js.map +1 -1
  104. package/package.json +1 -1
  105. package/src/cli/commands/cluster.test.ts +387 -9
  106. package/src/cli/commands/cluster.ts +486 -1
  107. package/src/cli/commands/manager/index.ts +6 -4
  108. package/src/cli/commands/req-spawn.test.ts +153 -0
  109. package/src/cli/commands/req.ts +31 -18
  110. package/src/cli/dashboard/index.ts +14 -2
  111. package/src/cli/dashboard/panels/agents.ts +12 -3
  112. package/src/cli/dashboard/panels/escalations.ts +12 -1
  113. package/src/cluster/cluster-http-server.ts +80 -0
  114. package/src/cluster/distributed-runtime-coverage.test.ts +9 -0
  115. package/src/cluster/distributed-system.test.ts +168 -0
  116. package/src/cluster/events.ts +90 -0
  117. package/src/cluster/heartbeat-manager.ts +48 -6
  118. package/src/cluster/membership.test.ts +498 -0
  119. package/src/cluster/partition-safety.test.ts +523 -0
  120. package/src/cluster/raft-state-machine.ts +76 -4
  121. package/src/cluster/raft-store.ts +167 -1
  122. package/src/cluster/replication-lag.test.ts +284 -0
  123. package/src/cluster/replication.ts +6 -0
  124. package/src/cluster/runtime.ts +551 -12
  125. package/src/cluster/state-recovery.test.ts +420 -0
  126. package/src/cluster/types.ts +32 -0
  127. package/src/config/schema.ts +11 -0
  128. package/src/connectors/auth/jira.test.ts +21 -0
  129. package/src/connectors/auth/jira.ts +1 -1
  130. package/src/context-files/generator.test.ts +55 -0
  131. package/src/context-files/generator.ts +5 -5
  132. package/src/orchestrator/orphan-recovery.ts +32 -13
  133. package/src/orchestrator/prompt-templates.test.ts +263 -0
  134. package/src/orchestrator/prompt-templates.ts +49 -8
  135. package/src/orchestrator/scheduler.test.ts +129 -6
  136. package/src/orchestrator/scheduler.ts +46 -20
@@ -6,11 +6,14 @@ import type { RaftStateMachine } from './raft-state-machine.js';
6
6
  interface HeartbeatRequest {
7
7
  term: number;
8
8
  leader_id: string;
9
+ fencing_token: number;
10
+ peers?: Array<{ id: string; url: string }>;
9
11
  }
10
12
 
11
13
  interface HeartbeatResponse {
12
14
  term: number;
13
15
  success: boolean;
16
+ fencing_token: number;
14
17
  }
15
18
 
16
19
  export interface HeartbeatManagerDeps {
@@ -18,6 +21,7 @@ export interface HeartbeatManagerDeps {
18
21
  postJson: <T>(peer: ClusterPeerConfig, path: string, body: unknown) => Promise<T | null>;
19
22
  isActive: () => boolean;
20
23
  handleBackgroundError: (error: unknown) => void;
24
+ onPeersUpdated?: (peers: ClusterPeerConfig[]) => void;
21
25
  }
22
26
 
23
27
  export class HeartbeatManager {
@@ -47,20 +51,23 @@ export class HeartbeatManager {
47
51
  if (!this.deps.isActive()) return;
48
52
 
49
53
  const { raft } = this.deps;
54
+ const peers = raft.getPeers();
50
55
 
51
56
  const heartbeat: HeartbeatRequest = {
52
57
  term: raft.currentTerm,
53
58
  leader_id: this.config.node_id,
59
+ fencing_token: raft.getFencingToken(),
60
+ peers: peers.map(p => ({ id: p.id, url: p.url })),
54
61
  };
55
62
 
56
63
  raft.appendDurableEntry('heartbeat_sent', {
57
64
  term: raft.currentTerm,
58
65
  leader_id: this.config.node_id,
59
- peer_count: this.config.peers.filter(peer => peer.id !== this.config.node_id).length,
66
+ peer_count: peers.filter(peer => peer.id !== this.config.node_id).length,
60
67
  });
61
68
 
62
69
  await Promise.all(
63
- this.config.peers
70
+ peers
64
71
  .filter(peer => peer.id !== this.config.node_id)
65
72
  .map(async peer => {
66
73
  const response = await this.deps.postJson<HeartbeatResponse>(
@@ -69,8 +76,11 @@ export class HeartbeatManager {
69
76
  heartbeat
70
77
  );
71
78
 
72
- if (response && response.term > raft.currentTerm) {
73
- raft.stepDown(response.term, peer.id);
79
+ if (response) {
80
+ const remoteTerm = Math.max(response.term, response.fencing_token ?? 0);
81
+ if (remoteTerm > raft.currentTerm) {
82
+ raft.stepDown(remoteTerm, peer.id);
83
+ }
74
84
  }
75
85
  })
76
86
  );
@@ -82,9 +92,16 @@ export class HeartbeatManager {
82
92
  const request = body as Partial<HeartbeatRequest>;
83
93
  const term = Number(request.term || 0);
84
94
  const leaderId = typeof request.leader_id === 'string' ? request.leader_id : null;
95
+ const fencingToken = Number(request.fencing_token ?? term);
85
96
 
97
+ // Reject heartbeats from stale leaders
86
98
  if (term < raft.currentTerm) {
87
- return { term: raft.currentTerm, success: false };
99
+ return { term: raft.currentTerm, success: false, fencing_token: raft.getFencingToken() };
100
+ }
101
+
102
+ // Reject if fencing token doesn't match the heartbeat term
103
+ if (fencingToken < term) {
104
+ return { term: raft.currentTerm, success: false, fencing_token: raft.getFencingToken() };
88
105
  }
89
106
 
90
107
  const changed =
@@ -98,15 +115,40 @@ export class HeartbeatManager {
98
115
  raft.persistRaftState();
99
116
  }
100
117
 
118
+ // Update lease: record that we received a valid heartbeat now
119
+ raft.lastHeartbeatReceivedAt = Date.now();
101
120
  raft.resetElectionDeadline();
102
121
 
122
+ // Apply peer list from leader if present
123
+ const requestPeers = (request as { peers?: unknown }).peers;
124
+ if (Array.isArray(requestPeers)) {
125
+ const parsed = parsePeerList(requestPeers);
126
+ if (parsed.length > 0) {
127
+ raft.setPeers(parsed);
128
+ this.deps.onPeersUpdated?.(parsed);
129
+ }
130
+ }
131
+
103
132
  if (changed) {
104
133
  raft.appendDurableEntry('heartbeat_received', {
105
134
  term,
106
135
  leader_id: leaderId,
136
+ fencing_token: fencingToken,
107
137
  });
108
138
  }
109
139
 
110
- return { term: raft.currentTerm, success: true };
140
+ return { term: raft.currentTerm, success: true, fencing_token: raft.getFencingToken() };
141
+ }
142
+ }
143
+
144
+ function parsePeerList(input: unknown[]): ClusterPeerConfig[] {
145
+ const peers: ClusterPeerConfig[] = [];
146
+ for (const item of input) {
147
+ if (!item || typeof item !== 'object') continue;
148
+ const p = item as { id?: unknown; url?: unknown };
149
+ if (typeof p.id === 'string' && typeof p.url === 'string') {
150
+ peers.push({ id: p.id, url: p.url });
151
+ }
111
152
  }
153
+ return peers;
112
154
  }
@@ -0,0 +1,498 @@
1
+ // Licensed under the Hungry Ghost Hive License. See LICENSE.
2
+
3
+ import { mkdirSync, mkdtempSync, rmSync } from 'fs';
4
+ import { createServer as createNetServer } from 'net';
5
+ import { tmpdir } from 'os';
6
+ import { join } from 'path';
7
+ import { afterEach, describe, expect, it } from 'vitest';
8
+ import type { ClusterConfig } from '../config/schema.js';
9
+ import { ClusterRuntime } from './runtime.js';
10
+
11
+ interface RuntimeFixture {
12
+ root: string;
13
+ hiveDir: string;
14
+ config: ClusterConfig;
15
+ runtime: ClusterRuntime;
16
+ }
17
+
18
+ const tempRoots: string[] = [];
19
+ const activeRuntimes: ClusterRuntime[] = [];
20
+
21
+ afterEach(async () => {
22
+ for (const runtime of activeRuntimes.splice(0)) {
23
+ try {
24
+ await runtime.stop();
25
+ } catch {
26
+ // Best effort shutdown for test cleanup.
27
+ }
28
+ }
29
+
30
+ for (const root of tempRoots.splice(0)) {
31
+ rmSync(root, { recursive: true, force: true });
32
+ }
33
+ });
34
+
35
+ describe('dynamic membership join', () => {
36
+ it('leader accepts join request and adds peer to cluster', async () => {
37
+ if (!(await canListenOnLocalhost())) return;
38
+
39
+ const fixture = await startRuntimeFixture({
40
+ node_id: 'leader-join',
41
+ election_timeout_min_ms: 80,
42
+ election_timeout_max_ms: 120,
43
+ heartbeat_interval_ms: 60,
44
+ });
45
+
46
+ await waitFor(() => fixture.runtime.getStatus().is_leader, 4000);
47
+
48
+ const res = await postJson(fixture.config.public_url, '/cluster/v1/membership/join', {
49
+ node_id: 'new-node',
50
+ url: 'http://127.0.0.1:9999',
51
+ });
52
+
53
+ expect(res.success).toBe(true);
54
+ expect(res.leader_id).toBe('leader-join');
55
+ expect(res.peers).toContainEqual({ id: 'new-node', url: 'http://127.0.0.1:9999' });
56
+
57
+ const status = fixture.runtime.getStatus();
58
+ expect(status.peers).toContainEqual({ id: 'new-node', url: 'http://127.0.0.1:9999' });
59
+ });
60
+
61
+ it('follower redirects join request to leader', async () => {
62
+ if (!(await canListenOnLocalhost())) return;
63
+
64
+ const fixture = await startRuntimeFixture({
65
+ node_id: 'follower-join',
66
+ election_timeout_min_ms: 5000,
67
+ election_timeout_max_ms: 5000,
68
+ peers: [{ id: 'remote-leader', url: 'http://127.0.0.1:9998' }],
69
+ });
70
+
71
+ // Set the node as follower with a known leader
72
+ await postJson(fixture.config.public_url, '/cluster/v1/election/heartbeat', {
73
+ term: 3,
74
+ leader_id: 'remote-leader',
75
+ fencing_token: 3,
76
+ });
77
+
78
+ const res = await fetch(`${fixture.config.public_url}/cluster/v1/membership/join`, {
79
+ method: 'POST',
80
+ headers: { 'Content-Type': 'application/json' },
81
+ body: JSON.stringify({ node_id: 'joiner', url: 'http://127.0.0.1:9997' }),
82
+ });
83
+
84
+ expect(res.status).toBe(307);
85
+ const body = (await res.json()) as Record<string, unknown>;
86
+ expect(body.success).toBe(false);
87
+ expect(body.leader_id).toBe('remote-leader');
88
+ expect(body.leader_url).toBe('http://127.0.0.1:9998');
89
+ });
90
+
91
+ it('rejects join request with missing fields', async () => {
92
+ if (!(await canListenOnLocalhost())) return;
93
+
94
+ const fixture = await startRuntimeFixture({ node_id: 'leader-join-bad' });
95
+
96
+ const res = await fetch(`${fixture.config.public_url}/cluster/v1/membership/join`, {
97
+ method: 'POST',
98
+ headers: { 'Content-Type': 'application/json' },
99
+ body: JSON.stringify({ node_id: 'missing-url' }),
100
+ });
101
+
102
+ expect(res.status).toBe(400);
103
+ });
104
+
105
+ it('updates url for existing peer on re-join', async () => {
106
+ if (!(await canListenOnLocalhost())) return;
107
+
108
+ const fixture = await startRuntimeFixture({
109
+ node_id: 'leader-rejoin',
110
+ election_timeout_min_ms: 80,
111
+ election_timeout_max_ms: 120,
112
+ heartbeat_interval_ms: 60,
113
+ });
114
+
115
+ await waitFor(() => fixture.runtime.getStatus().is_leader, 4000);
116
+
117
+ // First add the peer
118
+ await postJson(fixture.config.public_url, '/cluster/v1/membership/join', {
119
+ node_id: 'existing-peer',
120
+ url: 'http://127.0.0.1:8000',
121
+ });
122
+
123
+ // Re-join with different URL
124
+ const res = await postJson(fixture.config.public_url, '/cluster/v1/membership/join', {
125
+ node_id: 'existing-peer',
126
+ url: 'http://127.0.0.1:9000',
127
+ });
128
+
129
+ expect(res.success).toBe(true);
130
+ expect(res.peers).toContainEqual({ id: 'existing-peer', url: 'http://127.0.0.1:9000' });
131
+ });
132
+
133
+ it('idempotent join with same url returns success', async () => {
134
+ if (!(await canListenOnLocalhost())) return;
135
+
136
+ const fixture = await startRuntimeFixture({
137
+ node_id: 'leader-idem',
138
+ election_timeout_min_ms: 80,
139
+ election_timeout_max_ms: 120,
140
+ heartbeat_interval_ms: 60,
141
+ });
142
+
143
+ await waitFor(() => fixture.runtime.getStatus().is_leader, 4000);
144
+
145
+ // Add peer first
146
+ await postJson(fixture.config.public_url, '/cluster/v1/membership/join', {
147
+ node_id: 'peer-x',
148
+ url: 'http://127.0.0.1:7777',
149
+ });
150
+
151
+ // Join again with same details — idempotent
152
+ const res = await postJson(fixture.config.public_url, '/cluster/v1/membership/join', {
153
+ node_id: 'peer-x',
154
+ url: 'http://127.0.0.1:7777',
155
+ });
156
+
157
+ expect(res.success).toBe(true);
158
+ });
159
+ });
160
+
161
+ describe('dynamic membership leave', () => {
162
+ it('leader removes peer on leave request', async () => {
163
+ if (!(await canListenOnLocalhost())) return;
164
+
165
+ const fixture = await startRuntimeFixture({
166
+ node_id: 'leader-leave',
167
+ election_timeout_min_ms: 80,
168
+ election_timeout_max_ms: 120,
169
+ heartbeat_interval_ms: 60,
170
+ });
171
+
172
+ await waitFor(() => fixture.runtime.getStatus().is_leader, 4000);
173
+
174
+ // Add peer first, then remove it
175
+ await postJson(fixture.config.public_url, '/cluster/v1/membership/join', {
176
+ node_id: 'departing-node',
177
+ url: 'http://127.0.0.1:8888',
178
+ });
179
+
180
+ const res = await postJson(fixture.config.public_url, '/cluster/v1/membership/leave', {
181
+ node_id: 'departing-node',
182
+ });
183
+
184
+ expect(res.success).toBe(true);
185
+ expect(res.peers).not.toContainEqual(expect.objectContaining({ id: 'departing-node' }));
186
+
187
+ const status = fixture.runtime.getStatus();
188
+ expect(status.peers.find(p => p.id === 'departing-node')).toBeUndefined();
189
+ });
190
+
191
+ it('follower rejects leave request', async () => {
192
+ if (!(await canListenOnLocalhost())) return;
193
+
194
+ const fixture = await startRuntimeFixture({
195
+ node_id: 'follower-leave',
196
+ election_timeout_min_ms: 5000,
197
+ election_timeout_max_ms: 5000,
198
+ });
199
+
200
+ const res = await fetch(`${fixture.config.public_url}/cluster/v1/membership/leave`, {
201
+ method: 'POST',
202
+ headers: { 'Content-Type': 'application/json' },
203
+ body: JSON.stringify({ node_id: 'some-node' }),
204
+ });
205
+
206
+ expect(res.status).toBe(400);
207
+ const body = (await res.json()) as Record<string, unknown>;
208
+ expect(body.success).toBe(false);
209
+ });
210
+
211
+ it('leader cannot remove itself', async () => {
212
+ if (!(await canListenOnLocalhost())) return;
213
+
214
+ const fixture = await startRuntimeFixture({
215
+ node_id: 'leader-self-leave',
216
+ election_timeout_min_ms: 80,
217
+ election_timeout_max_ms: 120,
218
+ heartbeat_interval_ms: 60,
219
+ });
220
+
221
+ await waitFor(() => fixture.runtime.getStatus().is_leader, 4000);
222
+
223
+ const res = await fetch(`${fixture.config.public_url}/cluster/v1/membership/leave`, {
224
+ method: 'POST',
225
+ headers: { 'Content-Type': 'application/json' },
226
+ body: JSON.stringify({ node_id: 'leader-self-leave' }),
227
+ });
228
+
229
+ expect(res.status).toBe(400);
230
+ });
231
+
232
+ it('leave for unknown node is a no-op success', async () => {
233
+ if (!(await canListenOnLocalhost())) return;
234
+
235
+ const fixture = await startRuntimeFixture({
236
+ node_id: 'leader-unknown-leave',
237
+ election_timeout_min_ms: 80,
238
+ election_timeout_max_ms: 120,
239
+ heartbeat_interval_ms: 60,
240
+ });
241
+
242
+ await waitFor(() => fixture.runtime.getStatus().is_leader, 4000);
243
+
244
+ const res = await postJson(fixture.config.public_url, '/cluster/v1/membership/leave', {
245
+ node_id: 'ghost-node',
246
+ });
247
+
248
+ expect(res.success).toBe(true);
249
+ });
250
+
251
+ it('rejects leave request with missing node_id', async () => {
252
+ if (!(await canListenOnLocalhost())) return;
253
+
254
+ const fixture = await startRuntimeFixture({ node_id: 'leader-leave-bad' });
255
+
256
+ const res = await fetch(`${fixture.config.public_url}/cluster/v1/membership/leave`, {
257
+ method: 'POST',
258
+ headers: { 'Content-Type': 'application/json' },
259
+ body: JSON.stringify({}),
260
+ });
261
+
262
+ expect(res.status).toBe(400);
263
+ });
264
+ });
265
+
266
+ describe('peer list propagation via heartbeat', () => {
267
+ it('leader propagates updated peer list to followers', async () => {
268
+ if (!(await canListenOnLocalhost())) return;
269
+
270
+ const portLeader = await getFreePort();
271
+ const portFollower = await getFreePort();
272
+
273
+ const leaderConfig = await buildConfig({
274
+ node_id: 'leader-prop',
275
+ listen_port: portLeader,
276
+ public_url: `http://127.0.0.1:${portLeader}`,
277
+ peers: [{ id: 'follower-prop', url: `http://127.0.0.1:${portFollower}` }],
278
+ election_timeout_min_ms: 80,
279
+ election_timeout_max_ms: 120,
280
+ heartbeat_interval_ms: 60,
281
+ });
282
+ const followerConfig = await buildConfig({
283
+ node_id: 'follower-prop',
284
+ listen_port: portFollower,
285
+ public_url: `http://127.0.0.1:${portFollower}`,
286
+ peers: [{ id: 'leader-prop', url: `http://127.0.0.1:${portLeader}` }],
287
+ election_timeout_min_ms: 5000,
288
+ election_timeout_max_ms: 5000,
289
+ });
290
+
291
+ const leaderFixture = await startRuntimeWithConfig(leaderConfig);
292
+ const followerFixture = await startRuntimeWithConfig(followerConfig);
293
+
294
+ // Wait for leader election
295
+ await waitFor(() => leaderFixture.runtime.getStatus().is_leader, 4000);
296
+
297
+ // Add a new peer via the leader
298
+ await postJson(leaderFixture.config.public_url, '/cluster/v1/membership/join', {
299
+ node_id: 'new-node-prop',
300
+ url: 'http://127.0.0.1:7777',
301
+ });
302
+
303
+ // Wait for heartbeat to propagate peer list to follower
304
+ await waitFor(() => {
305
+ const peers = followerFixture.runtime.getStatus().peers;
306
+ return peers.some(p => p.id === 'new-node-prop');
307
+ }, 4000);
308
+
309
+ const followerPeers = followerFixture.runtime.getStatus().peers;
310
+ expect(followerPeers).toContainEqual({ id: 'new-node-prop', url: 'http://127.0.0.1:7777' });
311
+ });
312
+
313
+ it('follower applies peer list from heartbeat', async () => {
314
+ if (!(await canListenOnLocalhost())) return;
315
+
316
+ const fixture = await startRuntimeFixture({
317
+ node_id: 'follower-apply',
318
+ election_timeout_min_ms: 5000,
319
+ election_timeout_max_ms: 5000,
320
+ });
321
+
322
+ // Send heartbeat with peer list
323
+ await postJson(fixture.config.public_url, '/cluster/v1/election/heartbeat', {
324
+ term: 5,
325
+ leader_id: 'external-leader',
326
+ fencing_token: 5,
327
+ peers: [
328
+ { id: 'external-leader', url: 'http://127.0.0.1:6000' },
329
+ { id: 'follower-apply', url: fixture.config.public_url },
330
+ { id: 'peer-z', url: 'http://127.0.0.1:6001' },
331
+ ],
332
+ });
333
+
334
+ const status = fixture.runtime.getStatus();
335
+ expect(status.peers).toHaveLength(3);
336
+ expect(status.peers).toContainEqual({ id: 'peer-z', url: 'http://127.0.0.1:6001' });
337
+ });
338
+ });
339
+
340
+ describe('quorum recalculation after membership change', () => {
341
+ it('quorum adjusts after adding a peer', async () => {
342
+ if (!(await canListenOnLocalhost())) return;
343
+
344
+ // Start as a single node (quorum = 1)
345
+ const fixture = await startRuntimeFixture({
346
+ node_id: 'quorum-node',
347
+ election_timeout_min_ms: 80,
348
+ election_timeout_max_ms: 120,
349
+ heartbeat_interval_ms: 60,
350
+ });
351
+
352
+ await waitFor(() => fixture.runtime.getStatus().is_leader, 4000);
353
+
354
+ // Single node: quorum = 1
355
+ // Add two peers: 3 nodes total, quorum = 2
356
+ await postJson(fixture.config.public_url, '/cluster/v1/membership/join', {
357
+ node_id: 'peer-1',
358
+ url: 'http://127.0.0.1:9001',
359
+ });
360
+ await postJson(fixture.config.public_url, '/cluster/v1/membership/join', {
361
+ node_id: 'peer-2',
362
+ url: 'http://127.0.0.1:9002',
363
+ });
364
+
365
+ const status = fixture.runtime.getStatus();
366
+ expect(status.peers).toHaveLength(2);
367
+ // The node should still be functional with updated peer list
368
+ expect(status.is_leader).toBe(true);
369
+ });
370
+ });
371
+
372
+ // --- Test helpers ---
373
+
374
+ async function startRuntimeFixture(
375
+ overrides: Partial<ClusterConfig> = {}
376
+ ): Promise<RuntimeFixture> {
377
+ const attempts = overrides.listen_port ? 1 : 5;
378
+ let lastError: unknown;
379
+
380
+ for (let i = 0; i < attempts; i++) {
381
+ const config = await buildConfig(overrides);
382
+ try {
383
+ return await startRuntimeWithConfig(config);
384
+ } catch (error) {
385
+ lastError = error;
386
+ const err = error as NodeJS.ErrnoException;
387
+ if (!overrides.listen_port && err.code === 'EADDRINUSE') {
388
+ continue;
389
+ }
390
+ throw error;
391
+ }
392
+ }
393
+
394
+ throw lastError instanceof Error ? lastError : new Error('Failed to start runtime fixture');
395
+ }
396
+
397
+ async function startRuntimeWithConfig(config: ClusterConfig): Promise<RuntimeFixture> {
398
+ const root = mkdtempSync(join(tmpdir(), `hive-membership-${config.node_id}-`));
399
+ const hiveDir = join(root, '.hive');
400
+ mkdirSync(hiveDir, { recursive: true });
401
+
402
+ const runtime = new ClusterRuntime(config, { hiveDir });
403
+ try {
404
+ await runtime.start();
405
+ activeRuntimes.push(runtime);
406
+ tempRoots.push(root);
407
+
408
+ return { root, hiveDir, config, runtime };
409
+ } catch (error) {
410
+ try {
411
+ await runtime.stop();
412
+ } catch {
413
+ // Best effort cleanup for partial starts.
414
+ }
415
+ rmSync(root, { recursive: true, force: true });
416
+ throw error;
417
+ }
418
+ }
419
+
420
+ async function buildConfig(overrides: Partial<ClusterConfig> = {}): Promise<ClusterConfig> {
421
+ const port = overrides.listen_port ?? (await getFreePort());
422
+ const base: ClusterConfig = {
423
+ enabled: true,
424
+ node_id: 'node-test',
425
+ listen_host: '127.0.0.1',
426
+ listen_port: port,
427
+ public_url: `http://127.0.0.1:${port}`,
428
+ peers: [],
429
+ heartbeat_interval_ms: 100,
430
+ election_timeout_min_ms: 150,
431
+ election_timeout_max_ms: 250,
432
+ sync_interval_ms: 200,
433
+ request_timeout_ms: 600,
434
+ story_similarity_threshold: 0.8,
435
+ };
436
+
437
+ return {
438
+ ...base,
439
+ ...overrides,
440
+ public_url: overrides.public_url || base.public_url,
441
+ peers: overrides.peers || base.peers,
442
+ };
443
+ }
444
+
445
+ async function postJson(
446
+ baseUrl: string,
447
+ path: string,
448
+ body: Record<string, unknown>
449
+ ): Promise<Record<string, any>> {
450
+ const res = await fetch(`${baseUrl}${path}`, {
451
+ method: 'POST',
452
+ headers: { 'Content-Type': 'application/json' },
453
+ body: JSON.stringify(body),
454
+ });
455
+
456
+ return (await res.json()) as Record<string, any>;
457
+ }
458
+
459
+ async function waitFor(predicate: () => boolean, timeoutMs: number): Promise<void> {
460
+ const start = Date.now();
461
+ while (Date.now() - start < timeoutMs) {
462
+ if (predicate()) return;
463
+ await new Promise(resolve => setTimeout(resolve, 25));
464
+ }
465
+ throw new Error('Timed out waiting for condition');
466
+ }
467
+
468
+ async function getFreePort(): Promise<number> {
469
+ return new Promise((resolve, reject) => {
470
+ const server = createNetServer();
471
+ server.once('error', reject);
472
+ server.listen(0, '127.0.0.1', () => {
473
+ const address = server.address();
474
+ if (!address || typeof address === 'string') {
475
+ server.close(() => reject(new Error('Failed to allocate free port')));
476
+ return;
477
+ }
478
+
479
+ const port = address.port;
480
+ server.close(err => {
481
+ if (err) {
482
+ reject(err);
483
+ return;
484
+ }
485
+ resolve(port);
486
+ });
487
+ });
488
+ });
489
+ }
490
+
491
+ async function canListenOnLocalhost(): Promise<boolean> {
492
+ try {
493
+ await getFreePort();
494
+ return true;
495
+ } catch {
496
+ return false;
497
+ }
498
+ }