hungry-ghost-hive 0.44.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 (238) hide show
  1. package/dist/agents/base-agent.d.ts +1 -0
  2. package/dist/agents/base-agent.d.ts.map +1 -1
  3. package/dist/agents/base-agent.js +4 -0
  4. package/dist/agents/base-agent.js.map +1 -1
  5. package/dist/agents/intermediate.js +2 -2
  6. package/dist/agents/intermediate.js.map +1 -1
  7. package/dist/agents/junior.js +2 -2
  8. package/dist/agents/junior.js.map +1 -1
  9. package/dist/agents/qa.d.ts.map +1 -1
  10. package/dist/agents/qa.js +5 -5
  11. package/dist/agents/qa.js.map +1 -1
  12. package/dist/agents/senior.d.ts.map +1 -1
  13. package/dist/agents/senior.js +5 -5
  14. package/dist/agents/senior.js.map +1 -1
  15. package/dist/agents/tech-lead.d.ts.map +1 -1
  16. package/dist/agents/tech-lead.js +4 -2
  17. package/dist/agents/tech-lead.js.map +1 -1
  18. package/dist/cli/commands/assign.d.ts.map +1 -1
  19. package/dist/cli/commands/assign.js +4 -2
  20. package/dist/cli/commands/assign.js.map +1 -1
  21. package/dist/cli/commands/assign.test.js +5 -0
  22. package/dist/cli/commands/assign.test.js.map +1 -1
  23. package/dist/cli/commands/cluster.d.ts.map +1 -1
  24. package/dist/cli/commands/cluster.js +348 -1
  25. package/dist/cli/commands/cluster.js.map +1 -1
  26. package/dist/cli/commands/cluster.test.js +313 -9
  27. package/dist/cli/commands/cluster.test.js.map +1 -1
  28. package/dist/cli/commands/manager/handoff-recovery.d.ts.map +1 -1
  29. package/dist/cli/commands/manager/handoff-recovery.js +4 -2
  30. package/dist/cli/commands/manager/handoff-recovery.js.map +1 -1
  31. package/dist/cli/commands/manager/index.d.ts.map +1 -1
  32. package/dist/cli/commands/manager/index.js +16 -12
  33. package/dist/cli/commands/manager/index.js.map +1 -1
  34. package/dist/cli/commands/manager/tech-lead-lifecycle.d.ts.map +1 -1
  35. package/dist/cli/commands/manager/tech-lead-lifecycle.js +4 -2
  36. package/dist/cli/commands/manager/tech-lead-lifecycle.js.map +1 -1
  37. package/dist/cli/commands/msg.d.ts.map +1 -1
  38. package/dist/cli/commands/msg.js +8 -7
  39. package/dist/cli/commands/msg.js.map +1 -1
  40. package/dist/cli/commands/my-stories.js +3 -3
  41. package/dist/cli/commands/my-stories.js.map +1 -1
  42. package/dist/cli/commands/nuke.d.ts.map +1 -1
  43. package/dist/cli/commands/nuke.js +18 -7
  44. package/dist/cli/commands/nuke.js.map +1 -1
  45. package/dist/cli/commands/nuke.test.js +24 -0
  46. package/dist/cli/commands/nuke.test.js.map +1 -1
  47. package/dist/cli/commands/req-spawn.test.d.ts +2 -0
  48. package/dist/cli/commands/req-spawn.test.d.ts.map +1 -0
  49. package/dist/cli/commands/req-spawn.test.js +116 -0
  50. package/dist/cli/commands/req-spawn.test.js.map +1 -0
  51. package/dist/cli/commands/req.d.ts +1 -1
  52. package/dist/cli/commands/req.d.ts.map +1 -1
  53. package/dist/cli/commands/req.js +28 -18
  54. package/dist/cli/commands/req.js.map +1 -1
  55. package/dist/cli/commands/stories.js +3 -3
  56. package/dist/cli/commands/stories.js.map +1 -1
  57. package/dist/cli/dashboard/panels/agents.d.ts.map +1 -1
  58. package/dist/cli/dashboard/panels/agents.js +7 -3
  59. package/dist/cli/dashboard/panels/agents.js.map +1 -1
  60. package/dist/cluster/cluster-http-server.d.ts +32 -0
  61. package/dist/cluster/cluster-http-server.d.ts.map +1 -1
  62. package/dist/cluster/cluster-http-server.js +42 -0
  63. package/dist/cluster/cluster-http-server.js.map +1 -1
  64. package/dist/cluster/distributed-runtime-coverage.test.js +9 -0
  65. package/dist/cluster/distributed-runtime-coverage.test.js.map +1 -1
  66. package/dist/cluster/distributed-system.test.js +135 -0
  67. package/dist/cluster/distributed-system.test.js.map +1 -1
  68. package/dist/cluster/events.d.ts +23 -0
  69. package/dist/cluster/events.d.ts.map +1 -1
  70. package/dist/cluster/events.js +74 -0
  71. package/dist/cluster/events.js.map +1 -1
  72. package/dist/cluster/heartbeat-manager.d.ts +2 -0
  73. package/dist/cluster/heartbeat-manager.d.ts.map +1 -1
  74. package/dist/cluster/heartbeat-manager.js +42 -6
  75. package/dist/cluster/heartbeat-manager.js.map +1 -1
  76. package/dist/cluster/membership.test.d.ts +2 -0
  77. package/dist/cluster/membership.test.d.ts.map +1 -0
  78. package/dist/cluster/membership.test.js +416 -0
  79. package/dist/cluster/membership.test.js.map +1 -0
  80. package/dist/cluster/partition-safety.test.d.ts +2 -0
  81. package/dist/cluster/partition-safety.test.d.ts.map +1 -0
  82. package/dist/cluster/partition-safety.test.js +440 -0
  83. package/dist/cluster/partition-safety.test.js.map +1 -0
  84. package/dist/cluster/raft-state-machine.d.ts +33 -1
  85. package/dist/cluster/raft-state-machine.d.ts.map +1 -1
  86. package/dist/cluster/raft-state-machine.js +65 -3
  87. package/dist/cluster/raft-state-machine.js.map +1 -1
  88. package/dist/cluster/raft-store.d.ts +26 -1
  89. package/dist/cluster/raft-store.d.ts.map +1 -1
  90. package/dist/cluster/raft-store.js +137 -0
  91. package/dist/cluster/raft-store.js.map +1 -1
  92. package/dist/cluster/replication-lag.test.d.ts +2 -0
  93. package/dist/cluster/replication-lag.test.d.ts.map +1 -0
  94. package/dist/cluster/replication-lag.test.js +239 -0
  95. package/dist/cluster/replication-lag.test.js.map +1 -0
  96. package/dist/cluster/replication.d.ts +2 -2
  97. package/dist/cluster/replication.d.ts.map +1 -1
  98. package/dist/cluster/replication.js +1 -1
  99. package/dist/cluster/replication.js.map +1 -1
  100. package/dist/cluster/runtime.d.ts +78 -0
  101. package/dist/cluster/runtime.d.ts.map +1 -1
  102. package/dist/cluster/runtime.js +400 -13
  103. package/dist/cluster/runtime.js.map +1 -1
  104. package/dist/cluster/state-recovery.test.d.ts +2 -0
  105. package/dist/cluster/state-recovery.test.d.ts.map +1 -0
  106. package/dist/cluster/state-recovery.test.js +310 -0
  107. package/dist/cluster/state-recovery.test.js.map +1 -0
  108. package/dist/cluster/types.d.ts +30 -0
  109. package/dist/cluster/types.d.ts.map +1 -1
  110. package/dist/config/schema.d.ts +48 -0
  111. package/dist/config/schema.d.ts.map +1 -1
  112. package/dist/config/schema.js +11 -0
  113. package/dist/config/schema.js.map +1 -1
  114. package/dist/context-files/generator.d.ts +1 -1
  115. package/dist/context-files/generator.d.ts.map +1 -1
  116. package/dist/context-files/generator.js +4 -3
  117. package/dist/context-files/generator.js.map +1 -1
  118. package/dist/context-files/generator.test.js +51 -0
  119. package/dist/context-files/generator.test.js.map +1 -1
  120. package/dist/context-files/index.test.js +1 -0
  121. package/dist/context-files/index.test.js.map +1 -1
  122. package/dist/db/client.d.ts +1 -0
  123. package/dist/db/client.d.ts.map +1 -1
  124. package/dist/db/client.js +6 -0
  125. package/dist/db/client.js.map +1 -1
  126. package/dist/db/migrations/015-add-story-markdown-path.sql +5 -0
  127. package/dist/db/queries/stories.d.ts +3 -3
  128. package/dist/db/queries/stories.d.ts.map +1 -1
  129. package/dist/db/queries/stories.js +23 -5
  130. package/dist/db/queries/stories.js.map +1 -1
  131. package/dist/db/queries/test-helpers.d.ts.map +1 -1
  132. package/dist/db/queries/test-helpers.js +1 -0
  133. package/dist/db/queries/test-helpers.js.map +1 -1
  134. package/dist/git/worktree.d.ts.map +1 -1
  135. package/dist/git/worktree.js +7 -0
  136. package/dist/git/worktree.js.map +1 -1
  137. package/dist/git/worktree.test.js +30 -0
  138. package/dist/git/worktree.test.js.map +1 -1
  139. package/dist/orchestrator/orphan-recovery.d.ts +1 -1
  140. package/dist/orchestrator/orphan-recovery.d.ts.map +1 -1
  141. package/dist/orchestrator/orphan-recovery.js +4 -4
  142. package/dist/orchestrator/orphan-recovery.js.map +1 -1
  143. package/dist/orchestrator/prompt-templates.d.ts +6 -2
  144. package/dist/orchestrator/prompt-templates.d.ts.map +1 -1
  145. package/dist/orchestrator/prompt-templates.js +61 -16
  146. package/dist/orchestrator/prompt-templates.js.map +1 -1
  147. package/dist/orchestrator/prompt-templates.test.js +214 -0
  148. package/dist/orchestrator/prompt-templates.test.js.map +1 -1
  149. package/dist/orchestrator/scheduler.d.ts +1 -0
  150. package/dist/orchestrator/scheduler.d.ts.map +1 -1
  151. package/dist/orchestrator/scheduler.js +30 -17
  152. package/dist/orchestrator/scheduler.js.map +1 -1
  153. package/dist/orchestrator/scheduler.test.js +98 -6
  154. package/dist/orchestrator/scheduler.test.js.map +1 -1
  155. package/dist/tmux/manager.d.ts +7 -6
  156. package/dist/tmux/manager.d.ts.map +1 -1
  157. package/dist/tmux/manager.js +29 -13
  158. package/dist/tmux/manager.js.map +1 -1
  159. package/dist/utils/instance.d.ts +32 -0
  160. package/dist/utils/instance.d.ts.map +1 -0
  161. package/dist/utils/instance.js +82 -0
  162. package/dist/utils/instance.js.map +1 -0
  163. package/dist/utils/instance.test.d.ts +2 -0
  164. package/dist/utils/instance.test.d.ts.map +1 -0
  165. package/dist/utils/instance.test.js +103 -0
  166. package/dist/utils/instance.test.js.map +1 -0
  167. package/dist/utils/paths.d.ts +2 -0
  168. package/dist/utils/paths.d.ts.map +1 -1
  169. package/dist/utils/paths.js +2 -0
  170. package/dist/utils/paths.js.map +1 -1
  171. package/dist/utils/paths.test.js +6 -0
  172. package/dist/utils/paths.test.js.map +1 -1
  173. package/dist/utils/story-markdown.d.ts +16 -0
  174. package/dist/utils/story-markdown.d.ts.map +1 -0
  175. package/dist/utils/story-markdown.js +82 -0
  176. package/dist/utils/story-markdown.js.map +1 -0
  177. package/dist/utils/story-markdown.test.d.ts +2 -0
  178. package/dist/utils/story-markdown.test.d.ts.map +1 -0
  179. package/dist/utils/story-markdown.test.js +143 -0
  180. package/dist/utils/story-markdown.test.js.map +1 -0
  181. package/package.json +1 -1
  182. package/src/agents/base-agent.ts +5 -0
  183. package/src/agents/intermediate.ts +2 -2
  184. package/src/agents/junior.ts +2 -2
  185. package/src/agents/qa.ts +13 -8
  186. package/src/agents/senior.ts +21 -11
  187. package/src/agents/tech-lead.ts +24 -12
  188. package/src/cli/commands/assign.test.ts +5 -0
  189. package/src/cli/commands/assign.ts +4 -2
  190. package/src/cli/commands/cluster.test.ts +387 -9
  191. package/src/cli/commands/cluster.ts +486 -1
  192. package/src/cli/commands/manager/handoff-recovery.ts +4 -2
  193. package/src/cli/commands/manager/index.ts +16 -11
  194. package/src/cli/commands/manager/tech-lead-lifecycle.ts +5 -2
  195. package/src/cli/commands/msg.ts +8 -7
  196. package/src/cli/commands/my-stories.ts +22 -13
  197. package/src/cli/commands/nuke.test.ts +31 -0
  198. package/src/cli/commands/nuke.ts +18 -7
  199. package/src/cli/commands/req-spawn.test.ts +153 -0
  200. package/src/cli/commands/req.ts +40 -23
  201. package/src/cli/commands/stories.ts +22 -13
  202. package/src/cli/dashboard/panels/agents.ts +7 -3
  203. package/src/cluster/cluster-http-server.ts +80 -0
  204. package/src/cluster/distributed-runtime-coverage.test.ts +9 -0
  205. package/src/cluster/distributed-system.test.ts +168 -0
  206. package/src/cluster/events.ts +90 -0
  207. package/src/cluster/heartbeat-manager.ts +48 -6
  208. package/src/cluster/membership.test.ts +498 -0
  209. package/src/cluster/partition-safety.test.ts +523 -0
  210. package/src/cluster/raft-state-machine.ts +76 -4
  211. package/src/cluster/raft-store.ts +167 -1
  212. package/src/cluster/replication-lag.test.ts +284 -0
  213. package/src/cluster/replication.ts +6 -0
  214. package/src/cluster/runtime.ts +551 -12
  215. package/src/cluster/state-recovery.test.ts +420 -0
  216. package/src/cluster/types.ts +32 -0
  217. package/src/config/schema.ts +11 -0
  218. package/src/context-files/generator.test.ts +55 -0
  219. package/src/context-files/generator.ts +8 -7
  220. package/src/context-files/index.test.ts +1 -0
  221. package/src/db/client.ts +7 -0
  222. package/src/db/migrations/015-add-story-markdown-path.sql +5 -0
  223. package/src/db/queries/stories.ts +29 -5
  224. package/src/db/queries/test-helpers.ts +1 -0
  225. package/src/git/worktree.test.ts +43 -0
  226. package/src/git/worktree.ts +10 -0
  227. package/src/orchestrator/orphan-recovery.ts +32 -13
  228. package/src/orchestrator/prompt-templates.test.ts +267 -0
  229. package/src/orchestrator/prompt-templates.ts +69 -16
  230. package/src/orchestrator/scheduler.test.ts +130 -6
  231. package/src/orchestrator/scheduler.ts +66 -27
  232. package/src/tmux/manager.ts +42 -13
  233. package/src/utils/instance.test.ts +129 -0
  234. package/src/utils/instance.ts +95 -0
  235. package/src/utils/paths.test.ts +8 -0
  236. package/src/utils/paths.ts +3 -0
  237. package/src/utils/story-markdown.test.ts +176 -0
  238. package/src/utils/story-markdown.ts +94 -0
@@ -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
  }