hungry-ghost-hive 0.45.0 → 0.46.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 (113) 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/req-spawn.test.d.ts +2 -0
  7. package/dist/cli/commands/req-spawn.test.d.ts.map +1 -0
  8. package/dist/cli/commands/req-spawn.test.js +116 -0
  9. package/dist/cli/commands/req-spawn.test.js.map +1 -0
  10. package/dist/cli/commands/req.d.ts.map +1 -1
  11. package/dist/cli/commands/req.js +21 -13
  12. package/dist/cli/commands/req.js.map +1 -1
  13. package/dist/cluster/cluster-http-server.d.ts +32 -0
  14. package/dist/cluster/cluster-http-server.d.ts.map +1 -1
  15. package/dist/cluster/cluster-http-server.js +42 -0
  16. package/dist/cluster/cluster-http-server.js.map +1 -1
  17. package/dist/cluster/distributed-runtime-coverage.test.js +9 -0
  18. package/dist/cluster/distributed-runtime-coverage.test.js.map +1 -1
  19. package/dist/cluster/distributed-system.test.js +135 -0
  20. package/dist/cluster/distributed-system.test.js.map +1 -1
  21. package/dist/cluster/events.d.ts +23 -0
  22. package/dist/cluster/events.d.ts.map +1 -1
  23. package/dist/cluster/events.js +74 -0
  24. package/dist/cluster/events.js.map +1 -1
  25. package/dist/cluster/heartbeat-manager.d.ts +2 -0
  26. package/dist/cluster/heartbeat-manager.d.ts.map +1 -1
  27. package/dist/cluster/heartbeat-manager.js +42 -6
  28. package/dist/cluster/heartbeat-manager.js.map +1 -1
  29. package/dist/cluster/membership.test.d.ts +2 -0
  30. package/dist/cluster/membership.test.d.ts.map +1 -0
  31. package/dist/cluster/membership.test.js +416 -0
  32. package/dist/cluster/membership.test.js.map +1 -0
  33. package/dist/cluster/partition-safety.test.d.ts +2 -0
  34. package/dist/cluster/partition-safety.test.d.ts.map +1 -0
  35. package/dist/cluster/partition-safety.test.js +440 -0
  36. package/dist/cluster/partition-safety.test.js.map +1 -0
  37. package/dist/cluster/raft-state-machine.d.ts +33 -1
  38. package/dist/cluster/raft-state-machine.d.ts.map +1 -1
  39. package/dist/cluster/raft-state-machine.js +65 -3
  40. package/dist/cluster/raft-state-machine.js.map +1 -1
  41. package/dist/cluster/raft-store.d.ts +26 -1
  42. package/dist/cluster/raft-store.d.ts.map +1 -1
  43. package/dist/cluster/raft-store.js +137 -0
  44. package/dist/cluster/raft-store.js.map +1 -1
  45. package/dist/cluster/replication-lag.test.d.ts +2 -0
  46. package/dist/cluster/replication-lag.test.d.ts.map +1 -0
  47. package/dist/cluster/replication-lag.test.js +239 -0
  48. package/dist/cluster/replication-lag.test.js.map +1 -0
  49. package/dist/cluster/replication.d.ts +2 -2
  50. package/dist/cluster/replication.d.ts.map +1 -1
  51. package/dist/cluster/replication.js +1 -1
  52. package/dist/cluster/replication.js.map +1 -1
  53. package/dist/cluster/runtime.d.ts +78 -0
  54. package/dist/cluster/runtime.d.ts.map +1 -1
  55. package/dist/cluster/runtime.js +400 -13
  56. package/dist/cluster/runtime.js.map +1 -1
  57. package/dist/cluster/state-recovery.test.d.ts +2 -0
  58. package/dist/cluster/state-recovery.test.d.ts.map +1 -0
  59. package/dist/cluster/state-recovery.test.js +310 -0
  60. package/dist/cluster/state-recovery.test.js.map +1 -0
  61. package/dist/cluster/types.d.ts +30 -0
  62. package/dist/cluster/types.d.ts.map +1 -1
  63. package/dist/config/schema.d.ts +48 -0
  64. package/dist/config/schema.d.ts.map +1 -1
  65. package/dist/config/schema.js +11 -0
  66. package/dist/config/schema.js.map +1 -1
  67. package/dist/context-files/generator.js +1 -1
  68. package/dist/context-files/generator.js.map +1 -1
  69. package/dist/context-files/generator.test.js +51 -0
  70. package/dist/context-files/generator.test.js.map +1 -1
  71. package/dist/orchestrator/orphan-recovery.d.ts +1 -1
  72. package/dist/orchestrator/orphan-recovery.d.ts.map +1 -1
  73. package/dist/orchestrator/orphan-recovery.js +4 -4
  74. package/dist/orchestrator/orphan-recovery.js.map +1 -1
  75. package/dist/orchestrator/prompt-templates.d.ts +3 -1
  76. package/dist/orchestrator/prompt-templates.d.ts.map +1 -1
  77. package/dist/orchestrator/prompt-templates.js +45 -8
  78. package/dist/orchestrator/prompt-templates.js.map +1 -1
  79. package/dist/orchestrator/prompt-templates.test.js +210 -0
  80. package/dist/orchestrator/prompt-templates.test.js.map +1 -1
  81. package/dist/orchestrator/scheduler.d.ts +1 -0
  82. package/dist/orchestrator/scheduler.d.ts.map +1 -1
  83. package/dist/orchestrator/scheduler.js +15 -10
  84. package/dist/orchestrator/scheduler.js.map +1 -1
  85. package/dist/orchestrator/scheduler.test.js +97 -6
  86. package/dist/orchestrator/scheduler.test.js.map +1 -1
  87. package/package.json +1 -1
  88. package/src/cli/commands/cluster.test.ts +387 -9
  89. package/src/cli/commands/cluster.ts +486 -1
  90. package/src/cli/commands/req-spawn.test.ts +153 -0
  91. package/src/cli/commands/req.ts +31 -18
  92. package/src/cluster/cluster-http-server.ts +80 -0
  93. package/src/cluster/distributed-runtime-coverage.test.ts +9 -0
  94. package/src/cluster/distributed-system.test.ts +168 -0
  95. package/src/cluster/events.ts +90 -0
  96. package/src/cluster/heartbeat-manager.ts +48 -6
  97. package/src/cluster/membership.test.ts +498 -0
  98. package/src/cluster/partition-safety.test.ts +523 -0
  99. package/src/cluster/raft-state-machine.ts +76 -4
  100. package/src/cluster/raft-store.ts +167 -1
  101. package/src/cluster/replication-lag.test.ts +284 -0
  102. package/src/cluster/replication.ts +6 -0
  103. package/src/cluster/runtime.ts +551 -12
  104. package/src/cluster/state-recovery.test.ts +420 -0
  105. package/src/cluster/types.ts +32 -0
  106. package/src/config/schema.ts +11 -0
  107. package/src/context-files/generator.test.ts +55 -0
  108. package/src/context-files/generator.ts +5 -5
  109. package/src/orchestrator/orphan-recovery.ts +32 -13
  110. package/src/orchestrator/prompt-templates.test.ts +263 -0
  111. package/src/orchestrator/prompt-templates.ts +49 -8
  112. package/src/orchestrator/scheduler.test.ts +129 -6
  113. package/src/orchestrator/scheduler.ts +46 -20
@@ -14,7 +14,12 @@ import { createAgent, getTechLead, updateAgent } from '../../db/queries/agents.j
14
14
  import { createLog } from '../../db/queries/logs.js';
15
15
  import { createRequirement, updateRequirement } from '../../db/queries/requirements.js';
16
16
  import { getAllTeams } from '../../db/queries/teams.js';
17
- import { isTmuxAvailable, spawnTmuxSession } from '../../tmux/manager.js';
17
+ import {
18
+ isTmuxAvailable,
19
+ isTmuxSessionRunning,
20
+ sendToTmuxSession,
21
+ spawnTmuxSession,
22
+ } from '../../tmux/manager.js';
18
23
  import { getTechLeadSessionName } from '../../utils/instance.js';
19
24
  import { withHiveContext } from '../../utils/with-hive-context.js';
20
25
  import { startDashboard } from '../dashboard/index.js';
@@ -222,24 +227,32 @@ export const reqCommand = new Command('req')
222
227
  );
223
228
 
224
229
  try {
225
- // Build CLI command using the configured runtime for Tech Lead
226
- const chromeEnabled =
227
- config.agents?.chrome_enabled === true && techLeadCliTool === 'claude';
228
- const commandArgs = getCliRuntimeBuilder(techLeadCliTool).buildSpawnCommand(
229
- techLeadModel,
230
- techLeadSafetyMode,
231
- { chrome: chromeEnabled }
232
- );
230
+ const sessionAlreadyRunning = await isTmuxSessionRunning(sessionName);
231
+
232
+ if (sessionAlreadyRunning) {
233
+ // Session is already running — send the prompt to the existing session
234
+ // rather than killing it and losing in-progress work.
235
+ await sendToTmuxSession(sessionName, techLeadPrompt);
236
+ } else {
237
+ // Build CLI command using the configured runtime for Tech Lead
238
+ const chromeEnabled =
239
+ config.agents?.chrome_enabled === true && techLeadCliTool === 'claude';
240
+ const commandArgs = getCliRuntimeBuilder(techLeadCliTool).buildSpawnCommand(
241
+ techLeadModel,
242
+ techLeadSafetyMode,
243
+ { chrome: chromeEnabled }
244
+ );
233
245
 
234
- // Pass the prompt as initialPrompt so it's included as a CLI positional
235
- // argument via $(cat ...). This delivers the full multi-line prompt
236
- // reliably without tmux send-keys newline issues.
237
- await spawnTmuxSession({
238
- sessionName,
239
- workDir: root,
240
- commandArgs,
241
- initialPrompt: techLeadPrompt,
242
- });
246
+ // Pass the prompt as initialPrompt so it's included as a CLI positional
247
+ // argument via $(cat ...). This delivers the full multi-line prompt
248
+ // reliably without tmux send-keys newline issues.
249
+ await spawnTmuxSession({
250
+ sessionName,
251
+ workDir: root,
252
+ commandArgs,
253
+ initialPrompt: techLeadPrompt,
254
+ });
255
+ }
243
256
 
244
257
  // Update agent and log spawning/planning events (atomic transaction)
245
258
  await withTransaction(db.db, () => {
@@ -7,21 +7,53 @@ import type { ClusterEvent, VersionVector } from './replication.js';
7
7
  interface DeltaRequest {
8
8
  version_vector: VersionVector;
9
9
  limit?: number;
10
+ fencing_token?: number;
10
11
  }
11
12
 
12
13
  interface DeltaResponse {
13
14
  events: ClusterEvent[];
14
15
  version_vector: VersionVector;
16
+ fencing_token: number;
15
17
  }
16
18
 
17
19
  const MAX_CLUSTER_REQUEST_BODY_BYTES = 1024 * 1024; // 1 MiB
18
20
 
21
+ export interface MembershipJoinRequest {
22
+ node_id: string;
23
+ url: string;
24
+ }
25
+
26
+ export interface MembershipJoinResponse {
27
+ success: boolean;
28
+ leader_id: string | null;
29
+ leader_url: string | null;
30
+ peers: Array<{ id: string; url: string }>;
31
+ term: number;
32
+ }
33
+
34
+ export interface MembershipLeaveRequest {
35
+ node_id: string;
36
+ }
37
+
38
+ export interface MembershipLeaveResponse {
39
+ success: boolean;
40
+ peers: Array<{ id: string; url: string }>;
41
+ }
42
+
19
43
  export interface ClusterHttpHandlers {
20
44
  getStatus: () => unknown;
21
45
  handleVoteRequest: (body: unknown) => unknown;
22
46
  handleHeartbeat: (body: unknown) => unknown;
23
47
  getDeltaFromCache: (vector: VersionVector, limit: number) => ClusterEvent[];
24
48
  getVersionVectorCache: () => VersionVector;
49
+ getReplicationLag: () => unknown;
50
+ getFencingToken: () => number;
51
+ validateFencingToken: (token: number) => boolean;
52
+ isLeaderLeaseValid: () => boolean;
53
+ handleMembershipJoin: (body: MembershipJoinRequest) => MembershipJoinResponse;
54
+ handleMembershipLeave: (body: MembershipLeaveRequest) => MembershipLeaveResponse;
55
+ /** Returns a full snapshot of all replicated tables for state recovery. */
56
+ getSnapshot: () => unknown;
25
57
  }
26
58
 
27
59
  export class ClusterHttpServer {
@@ -72,6 +104,11 @@ export class ClusterHttpServer {
72
104
  return;
73
105
  }
74
106
 
107
+ if (method === 'GET' && path === '/cluster/v1/snapshot') {
108
+ sendJson(res, 200, this.handlers.getSnapshot());
109
+ return;
110
+ }
111
+
75
112
  if (method === 'POST' && path === '/cluster/v1/election/request-vote') {
76
113
  const body = await readJsonBody(req);
77
114
  const response = this.handlers.handleVoteRequest(body);
@@ -86,8 +123,25 @@ export class ClusterHttpServer {
86
123
  return;
87
124
  }
88
125
 
126
+ if (method === 'GET' && path === '/cluster/v1/replication-lag') {
127
+ sendJson(res, 200, this.handlers.getReplicationLag());
128
+ return;
129
+ }
130
+
89
131
  if (method === 'POST' && path === '/cluster/v1/events/delta') {
90
132
  const body = (await readJsonBody(req)) as Partial<DeltaRequest>;
133
+
134
+ // Validate fencing token if provided — reject stale-leader requests
135
+ if (typeof body.fencing_token === 'number') {
136
+ if (!this.handlers.validateFencingToken(body.fencing_token)) {
137
+ sendJson(res, 409, {
138
+ error: 'Fencing token rejected: stale leader epoch',
139
+ fencing_token: this.handlers.getFencingToken(),
140
+ });
141
+ return;
142
+ }
143
+ }
144
+
91
145
  const vector = toVersionVector(body.version_vector);
92
146
  const limit =
93
147
  typeof body.limit === 'number' && Number.isFinite(body.limit) && body.limit > 0
@@ -98,10 +152,36 @@ export class ClusterHttpServer {
98
152
  sendJson(res, 200, {
99
153
  events,
100
154
  version_vector: this.handlers.getVersionVectorCache(),
155
+ fencing_token: this.handlers.getFencingToken(),
101
156
  } satisfies DeltaResponse);
102
157
  return;
103
158
  }
104
159
 
160
+ if (method === 'POST' && path === '/cluster/v1/membership/join') {
161
+ const body = (await readJsonBody(req)) as Partial<MembershipJoinRequest>;
162
+ if (typeof body.node_id !== 'string' || typeof body.url !== 'string') {
163
+ sendJson(res, 400, { error: 'node_id and url are required' });
164
+ return;
165
+ }
166
+ const response = this.handlers.handleMembershipJoin({
167
+ node_id: body.node_id,
168
+ url: body.url,
169
+ });
170
+ sendJson(res, response.success ? 200 : 307, response);
171
+ return;
172
+ }
173
+
174
+ if (method === 'POST' && path === '/cluster/v1/membership/leave') {
175
+ const body = (await readJsonBody(req)) as Partial<MembershipLeaveRequest>;
176
+ if (typeof body.node_id !== 'string') {
177
+ sendJson(res, 400, { error: 'node_id is required' });
178
+ return;
179
+ }
180
+ const response = this.handlers.handleMembershipLeave({ node_id: body.node_id });
181
+ sendJson(res, response.success ? 200 : 400, response);
182
+ return;
183
+ }
184
+
105
185
  sendJson(res, 404, { error: 'Not found' });
106
186
  } catch (error) {
107
187
  if (error instanceof HttpRequestError) {
@@ -131,10 +131,14 @@ describe('distributed runtime transport and status', () => {
131
131
  is_leader: false,
132
132
  leader_id: null,
133
133
  leader_url: null,
134
+ fencing_token: 0,
135
+ leader_lease_valid: false,
136
+ leader_lease_duration_ms: 0,
134
137
  raft_commit_index: 12,
135
138
  raft_last_applied: 0,
136
139
  raft_last_log_index: 9,
137
140
  peers: [{ id: 'peer-a', url: 'http://127.0.0.1:8080' }],
141
+ is_catching_up: false,
138
142
  } satisfies ClusterStatus);
139
143
  });
140
144
 
@@ -477,6 +481,11 @@ describe('distributed runtime sync behavior', () => {
477
481
  imported_events_applied: 0,
478
482
  merged_duplicate_stories: 0,
479
483
  durable_log_entries_appended: 0,
484
+ log_entries_compacted: 0,
485
+ cluster_events_pruned: 0,
486
+ used_snapshot_recovery: false,
487
+ catch_up_applied: 0,
488
+ catch_up_total: 0,
480
489
  });
481
490
 
482
491
  db.close();
@@ -646,6 +646,174 @@ describe('durable raft metadata store', () => {
646
646
  });
647
647
  });
648
648
 
649
+ describe('log compaction and snapshotting', () => {
650
+ it('creates a snapshot and compacts the raft log', () => {
651
+ const dir = mkdtempSync(join(tmpdir(), 'hive-compaction-'));
652
+ tempDirs.push(dir);
653
+
654
+ const store = new RaftMetadataStore({ clusterDir: dir, nodeId: 'node-compact' });
655
+ store.setState({ current_term: 5 });
656
+
657
+ // Append 20 entries
658
+ for (let i = 0; i < 20; i++) {
659
+ store.appendEntry({ type: 'runtime', metadata: { seq: i } });
660
+ }
661
+
662
+ expect(store.getLogEntryCount()).toBe(20);
663
+ expect(store.getState().last_log_index).toBe(20);
664
+
665
+ // Create snapshot at current state
666
+ const snapshot = store.createSnapshot({ 'node-compact': 10 });
667
+ expect(snapshot.last_included_index).toBe(20);
668
+ expect(snapshot.last_included_term).toBe(5);
669
+ expect(snapshot.version_vector).toEqual({ 'node-compact': 10 });
670
+ expect(existsSync(join(dir, 'raft-snapshot.json'))).toBe(true);
671
+
672
+ // Compact the log
673
+ const result = store.compactLog();
674
+ expect(result.entries_removed).toBe(20);
675
+ expect(result.entries_retained).toBe(0);
676
+ expect(result.snapshot_index).toBe(20);
677
+ expect(store.getLogEntryCount()).toBe(0);
678
+ });
679
+
680
+ it('retains entries after the snapshot index during compaction', () => {
681
+ const dir = mkdtempSync(join(tmpdir(), 'hive-compaction-'));
682
+ tempDirs.push(dir);
683
+
684
+ const store = new RaftMetadataStore({ clusterDir: dir, nodeId: 'node-retain' });
685
+
686
+ // Append 10 entries
687
+ for (let i = 0; i < 10; i++) {
688
+ store.appendEntry({ type: 'runtime', metadata: { seq: i } });
689
+ }
690
+
691
+ // Snapshot at index 5 (manually set to test partial compaction)
692
+ const snapshot = store.createSnapshot({ 'node-retain': 5 });
693
+ expect(snapshot.last_included_index).toBe(10);
694
+
695
+ // Append 5 more entries AFTER snapshot
696
+ for (let i = 0; i < 5; i++) {
697
+ store.appendEntry({ type: 'runtime', metadata: { seq: 10 + i } });
698
+ }
699
+
700
+ expect(store.getLogEntryCount()).toBe(15);
701
+
702
+ const result = store.compactLog();
703
+ expect(result.entries_removed).toBe(10);
704
+ expect(result.entries_retained).toBe(5);
705
+ expect(store.getLogEntryCount()).toBe(5);
706
+ });
707
+
708
+ it('restores snapshot and event IDs across restarts', () => {
709
+ const dir = mkdtempSync(join(tmpdir(), 'hive-snapshot-restart-'));
710
+ tempDirs.push(dir);
711
+
712
+ const first = new RaftMetadataStore({ clusterDir: dir, nodeId: 'node-snap' });
713
+ first.setState({ current_term: 3 });
714
+
715
+ const events: ClusterEvent[] = [
716
+ buildStoryEvent({
717
+ event_id: 'node-a:1',
718
+ row_id: 'STORY-A',
719
+ version: { actor_id: 'node-a', actor_counter: 1, logical_ts: 1000 },
720
+ }),
721
+ buildStoryEvent({
722
+ event_id: 'node-b:1',
723
+ row_id: 'STORY-B',
724
+ version: { actor_id: 'node-b', actor_counter: 1, logical_ts: 2000 },
725
+ }),
726
+ ];
727
+ first.appendClusterEvents(events, 3);
728
+ first.createSnapshot({ 'node-a': 1, 'node-b': 1 });
729
+ first.compactLog();
730
+
731
+ // Restart
732
+ const second = new RaftMetadataStore({ clusterDir: dir, nodeId: 'node-snap' });
733
+ const restored = second.getState();
734
+ const snap = second.getSnapshot();
735
+
736
+ expect(restored.last_log_index).toBeGreaterThanOrEqual(2);
737
+ expect(snap).not.toBeNull();
738
+ expect(snap!.version_vector).toEqual({ 'node-a': 1, 'node-b': 1 });
739
+
740
+ // Event IDs should be restored from snapshot
741
+ expect(second.hasEvent('node-a:1')).toBe(true);
742
+ expect(second.hasEvent('node-b:1')).toBe(true);
743
+
744
+ // Deduplication should still work
745
+ const deduped = second.appendClusterEvents(events, 3);
746
+ expect(deduped).toBe(0);
747
+ });
748
+
749
+ it('compaction without snapshot is a no-op', () => {
750
+ const dir = mkdtempSync(join(tmpdir(), 'hive-no-snap-'));
751
+ tempDirs.push(dir);
752
+
753
+ const store = new RaftMetadataStore({ clusterDir: dir, nodeId: 'node-nosn' });
754
+ for (let i = 0; i < 5; i++) {
755
+ store.appendEntry({ type: 'runtime', metadata: { seq: i } });
756
+ }
757
+
758
+ const result = store.compactLog();
759
+ expect(result.entries_removed).toBe(0);
760
+ expect(result.entries_retained).toBe(0);
761
+ expect(store.getLogEntryCount()).toBe(5);
762
+ });
763
+ });
764
+
765
+ describe('cluster_events pruning', () => {
766
+ it('prunes old events keeping the most recent ones', async () => {
767
+ const db = await createTestDatabase();
768
+ ensureClusterTables(db, 'node-prune');
769
+
770
+ // Emit 10 events
771
+ for (let i = 0; i < 10; i++) {
772
+ const { emitLocalEvent } = await import('./events.js');
773
+ emitLocalEvent(db, 'node-prune', {
774
+ table_name: 'stories',
775
+ row_id: `STORY-${i}`,
776
+ op: 'upsert',
777
+ payload: storyPayload(`STORY-${i}`),
778
+ });
779
+ }
780
+
781
+ const { getClusterEventCount, pruneClusterEvents } = await import('./events.js');
782
+ expect(getClusterEventCount(db)).toBe(10);
783
+
784
+ // Prune to keep only 5
785
+ const pruned = pruneClusterEvents(db, 5);
786
+ expect(pruned).toBe(5);
787
+ expect(getClusterEventCount(db)).toBe(5);
788
+
789
+ db.close();
790
+ });
791
+
792
+ it('does not prune when count is below threshold', async () => {
793
+ const db = await createTestDatabase();
794
+ ensureClusterTables(db, 'node-no-prune');
795
+
796
+ const { emitLocalEvent } = await import('./events.js');
797
+ for (let i = 0; i < 3; i++) {
798
+ emitLocalEvent(db, 'node-no-prune', {
799
+ table_name: 'stories',
800
+ row_id: `STORY-${i}`,
801
+ op: 'upsert',
802
+ payload: storyPayload(`STORY-${i}`),
803
+ });
804
+ }
805
+
806
+ const { getClusterEventCount, pruneClusterEvents } = await import('./events.js');
807
+ expect(getClusterEventCount(db)).toBe(3);
808
+
809
+ const pruned = pruneClusterEvents(db, 10);
810
+ expect(pruned).toBe(0);
811
+ expect(getClusterEventCount(db)).toBe(3);
812
+
813
+ db.close();
814
+ });
815
+ });
816
+
649
817
  function storyPayload(
650
818
  id: string,
651
819
  overrides: Partial<Record<string, unknown>> = {}
@@ -96,6 +96,13 @@ export function ensureClusterTables(db: Database, nodeId: string): void {
96
96
  `
97
97
  );
98
98
 
99
+ // Add snapshot_version_vector column if it doesn't exist yet (backward-compat migration)
100
+ try {
101
+ run(db, 'ALTER TABLE cluster_state ADD COLUMN snapshot_version_vector TEXT');
102
+ } catch {
103
+ // Column already exists — ignore
104
+ }
105
+
99
106
  const state = queryOne<{ id: number }>(db, 'SELECT id FROM cluster_state WHERE id = 1');
100
107
  const now = new Date().toISOString();
101
108
 
@@ -128,6 +135,53 @@ export function getVersionVector(db: Database): VersionVector {
128
135
  return vector;
129
136
  }
130
137
 
138
+ /**
139
+ * Returns the snapshot version vector stored after the last snapshot-based recovery.
140
+ * Empty object if no snapshot has been applied.
141
+ */
142
+ export function getSnapshotVersionVector(db: Database): VersionVector {
143
+ const row = queryOne<{ snapshot_version_vector: string | null }>(
144
+ db,
145
+ 'SELECT snapshot_version_vector FROM cluster_state WHERE id = 1'
146
+ );
147
+
148
+ if (!row?.snapshot_version_vector) return {};
149
+
150
+ try {
151
+ return JSON.parse(row.snapshot_version_vector) as VersionVector;
152
+ } catch {
153
+ return {};
154
+ }
155
+ }
156
+
157
+ /**
158
+ * Persists the snapshot version vector so that future delta requests start
159
+ * from this point rather than from the (empty) event log.
160
+ */
161
+ export function setSnapshotVersionVector(db: Database, vector: VersionVector): void {
162
+ run(db, 'UPDATE cluster_state SET snapshot_version_vector = ? WHERE id = 1', [
163
+ JSON.stringify(vector),
164
+ ]);
165
+ }
166
+
167
+ /**
168
+ * Returns the effective version vector for delta-sync requests.
169
+ * Takes the max per actor between the event-log-derived vector and any
170
+ * snapshot vector stored from a previous snapshot-based recovery.
171
+ * This prevents re-requesting events that were already covered by a snapshot.
172
+ */
173
+ export function getEffectiveVersionVector(db: Database): VersionVector {
174
+ const eventVector = getVersionVector(db);
175
+ const snapshotVector = getSnapshotVersionVector(db);
176
+
177
+ const effective: VersionVector = { ...snapshotVector };
178
+ for (const [actor, counter] of Object.entries(eventVector)) {
179
+ effective[actor] = Math.max(effective[actor] ?? 0, counter);
180
+ }
181
+
182
+ return effective;
183
+ }
184
+
131
185
  export function getAllClusterEvents(db: Database): ClusterEvent[] {
132
186
  const rows = queryAll<ClusterEventRow>(
133
187
  db,
@@ -204,6 +258,42 @@ export function emitLocalEvent(
204
258
  );
205
259
  }
206
260
 
261
+ /**
262
+ * Prune old cluster_events rows, retaining only the most recent `retainCount` events.
263
+ * Returns the number of rows deleted.
264
+ */
265
+ export function pruneClusterEvents(db: Database, retainCount: number): number {
266
+ if (retainCount <= 0) return 0;
267
+
268
+ const countRow = queryOne<{ total: number }>(db, 'SELECT COUNT(*) as total FROM cluster_events');
269
+ const total = countRow?.total || 0;
270
+
271
+ if (total <= retainCount) return 0;
272
+
273
+ // Delete events that are not in the most recent `retainCount` by logical_ts ordering.
274
+ // We keep the newest events and delete the oldest.
275
+ run(
276
+ db,
277
+ `
278
+ DELETE FROM cluster_events
279
+ WHERE event_id NOT IN (
280
+ SELECT event_id FROM cluster_events
281
+ ORDER BY logical_ts DESC, actor_id DESC, actor_counter DESC
282
+ LIMIT ?
283
+ )
284
+ `,
285
+ [retainCount]
286
+ );
287
+
288
+ const afterRow = queryOne<{ total: number }>(db, 'SELECT COUNT(*) as total FROM cluster_events');
289
+ return total - (afterRow?.total || 0);
290
+ }
291
+
292
+ export function getClusterEventCount(db: Database): number {
293
+ const row = queryOne<{ total: number }>(db, 'SELECT COUNT(*) as total FROM cluster_events');
294
+ return row?.total || 0;
295
+ }
296
+
207
297
  export function fetchTableSnapshots(db: Database, adapter: TableAdapter): TableRowSnapshot[] {
208
298
  const rows = queryAll<Record<string, unknown>>(db, adapter.selectSql);
209
299
 
@@ -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
  }