@remnic/core 10.0.51 → 10.0.53

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 (266) hide show
  1. package/dist/access-admin-ops-surface.js +24 -24
  2. package/dist/access-authorization-probe.js +25 -25
  3. package/dist/access-boundary.js +24 -24
  4. package/dist/access-cli.js +65 -65
  5. package/dist/access-extraction-force-flush.js +24 -24
  6. package/dist/access-http-briefing.js +25 -25
  7. package/dist/access-http-query.js +26 -26
  8. package/dist/access-http.js +40 -40
  9. package/dist/access-identity-continuity-surface.js +24 -24
  10. package/dist/access-lcm-surface.js +24 -24
  11. package/dist/access-mcp.js +33 -33
  12. package/dist/access-namespace-preflight.js +24 -24
  13. package/dist/access-observe-write-surface.js +24 -24
  14. package/dist/access-operations-batch.js +25 -25
  15. package/dist/access-operations.js +32 -32
  16. package/dist/access-recall-concurrency.js +24 -24
  17. package/dist/access-recall-response.js +24 -24
  18. package/dist/access-recall-surface.js +24 -24
  19. package/dist/access-service.js +24 -24
  20. package/dist/access-surface-catalog.js +26 -26
  21. package/dist/active-recall.js +9 -9
  22. package/dist/briefing.js +10 -10
  23. package/dist/buffer.js +5 -5
  24. package/dist/causal-consolidation.js +11 -11
  25. package/dist/{chunk-3NL4ALUP.js → chunk-24SIB3EW.js} +3 -3
  26. package/dist/{chunk-Y3T5KQCR.js → chunk-2EYCW2ET.js} +2 -2
  27. package/dist/{chunk-AHGOOZUC.js → chunk-34UVJNSG.js} +2 -2
  28. package/dist/{chunk-2EXF6IIB.js → chunk-3632XJMR.js} +2 -2
  29. package/dist/{chunk-KETS5A62.js → chunk-3H3PNQEF.js} +2 -2
  30. package/dist/{chunk-SFRBUBUZ.js → chunk-3LNAQLW4.js} +3 -3
  31. package/dist/{chunk-75BK4XGV.js → chunk-4BUJICMI.js} +10 -10
  32. package/dist/{chunk-KIWO4327.js → chunk-4W5B5Z6P.js} +2 -2
  33. package/dist/{chunk-B35EQQ7L.js → chunk-53OFDW3P.js} +3 -3
  34. package/dist/{chunk-2ONKXEC7.js → chunk-5LBBXI2A.js} +2 -2
  35. package/dist/{chunk-UCLJTGP3.js → chunk-65B5RFKQ.js} +2 -2
  36. package/dist/{chunk-KAJYSO3U.js → chunk-6CDNOREO.js} +2 -2
  37. package/dist/{chunk-POJRFSJM.js → chunk-7XGNGLAN.js} +2 -2
  38. package/dist/{chunk-A6JDT6QF.js → chunk-AO5ZOD32.js} +2 -2
  39. package/dist/{chunk-RA7YRL6H.js → chunk-AOSCGNIC.js} +3 -3
  40. package/dist/{chunk-LWMLBVGB.js → chunk-B43P3DTC.js} +2 -2
  41. package/dist/{chunk-SJD334AG.js → chunk-CA6X37OB.js} +217 -173
  42. package/dist/chunk-CA6X37OB.js.map +1 -0
  43. package/dist/{chunk-VUMWVYMR.js → chunk-CIMD2MUR.js} +3 -3
  44. package/dist/{chunk-XRFLR3XF.js → chunk-DLZGSZVC.js} +22 -9
  45. package/dist/chunk-DLZGSZVC.js.map +1 -0
  46. package/dist/{chunk-5CWH2LKL.js → chunk-DOJBV6WR.js} +29 -14
  47. package/dist/chunk-DOJBV6WR.js.map +1 -0
  48. package/dist/{chunk-44EUCSG4.js → chunk-FFQNNFHX.js} +2 -2
  49. package/dist/{chunk-HEF4BRMT.js → chunk-FZLZUDJJ.js} +2 -2
  50. package/dist/{chunk-L3EFYRYK.js → chunk-GGFQAGOT.js} +7 -7
  51. package/dist/{chunk-ZLCBLDJO.js → chunk-GIRWNTMV.js} +2 -2
  52. package/dist/{chunk-EC2KVA27.js → chunk-H4DWSOBQ.js} +169 -105
  53. package/dist/chunk-H4DWSOBQ.js.map +1 -0
  54. package/dist/{chunk-SHNM7RIT.js → chunk-HJ4X4BGS.js} +2 -2
  55. package/dist/{chunk-MUNYUSBU.js → chunk-HJQ4NKKO.js} +2 -2
  56. package/dist/{chunk-V6Z4NAJZ.js → chunk-HM3ZTDAX.js} +2 -2
  57. package/dist/{chunk-EZS6QJNC.js → chunk-IFOCK3OJ.js} +2 -2
  58. package/dist/{chunk-LXXIV7Y2.js → chunk-JEX2JL3U.js} +7 -2
  59. package/dist/{chunk-LXXIV7Y2.js.map → chunk-JEX2JL3U.js.map} +1 -1
  60. package/dist/{chunk-DF6ZF2BN.js → chunk-LIVNX7WK.js} +2 -2
  61. package/dist/{chunk-WY66J4PI.js → chunk-LRVFS4KA.js} +2 -2
  62. package/dist/{chunk-LMB47UM6.js → chunk-LTDK6HBV.js} +2 -2
  63. package/dist/{chunk-HCIRR3BA.js → chunk-M4U25HLR.js} +2 -2
  64. package/dist/{chunk-YK7ZJPQM.js → chunk-MGN2RQRG.js} +2 -2
  65. package/dist/{chunk-G26YNEOC.js → chunk-NWA6IXR3.js} +1 -1
  66. package/dist/{chunk-G26YNEOC.js.map → chunk-NWA6IXR3.js.map} +1 -1
  67. package/dist/{chunk-JIJAWTRK.js → chunk-OCXDZJL4.js} +264 -194
  68. package/dist/chunk-OCXDZJL4.js.map +1 -0
  69. package/dist/{chunk-IUQXRRJB.js → chunk-OMWQO7H6.js} +2 -2
  70. package/dist/{chunk-7VO6E5UD.js → chunk-OVZIYLBZ.js} +6 -2
  71. package/dist/chunk-OVZIYLBZ.js.map +1 -0
  72. package/dist/{chunk-SPTPBKZX.js → chunk-OWNAWY6D.js} +2 -2
  73. package/dist/{chunk-XKDQTDXN.js → chunk-PFUXPTBT.js} +36 -15
  74. package/dist/chunk-PFUXPTBT.js.map +1 -0
  75. package/dist/{chunk-DQOGZZJA.js → chunk-QAHP5SBG.js} +3 -3
  76. package/dist/{chunk-GBGU5OZP.js → chunk-QF5EJ5VP.js} +13 -13
  77. package/dist/{chunk-FT75AQ74.js → chunk-QS5DUSBM.js} +2 -2
  78. package/dist/{chunk-72CBJ5VP.js → chunk-R3WZ74B2.js} +3 -3
  79. package/dist/{chunk-PDTNQLPC.js → chunk-RG3I2663.js} +2 -2
  80. package/dist/{chunk-CK6GCGMX.js → chunk-RTJJPW3C.js} +2 -2
  81. package/dist/{chunk-RJDYDF5K.js → chunk-TRAOZ2WP.js} +2 -2
  82. package/dist/{chunk-6ZAU7LAR.js → chunk-TTTPTQ6S.js} +2 -2
  83. package/dist/{chunk-JS44PBCM.js → chunk-U2A56LLF.js} +6 -6
  84. package/dist/{chunk-MC7PXJDP.js → chunk-UBOWDOL5.js} +5 -5
  85. package/dist/{chunk-4FM35YIA.js → chunk-UQYBPPVC.js} +2 -2
  86. package/dist/{chunk-OCRLJJOV.js → chunk-V4VCBFAL.js} +3 -3
  87. package/dist/{chunk-FNNGWN3O.js → chunk-VGE4PUY6.js} +2 -2
  88. package/dist/{chunk-5DVOBBTV.js → chunk-VV73XT3A.js} +2 -2
  89. package/dist/{chunk-GE3ZX6YK.js → chunk-W7NKMRAF.js} +2 -2
  90. package/dist/{chunk-AJJPXUE6.js → chunk-WBN66HPI.js} +2 -2
  91. package/dist/{chunk-XWJUFER3.js → chunk-WGFEGBFB.js} +2 -2
  92. package/dist/{chunk-43TUWQNR.js → chunk-XDKEHZUC.js} +2 -2
  93. package/dist/{chunk-ROAKLYOL.js → chunk-XHEKS4UN.js} +2 -2
  94. package/dist/{chunk-ZKK3VMWU.js → chunk-XVTB6BZS.js} +2 -2
  95. package/dist/{chunk-AZLD4CKG.js → chunk-Y56D5FSD.js} +17 -17
  96. package/dist/{chunk-MPWOAIIH.js → chunk-YDT3OEYF.js} +2 -2
  97. package/dist/{chunk-IN6U2BS5.js → chunk-YJHOVA42.js} +2 -2
  98. package/dist/{chunk-TC55M5PO.js → chunk-YLN4ZQV4.js} +2 -2
  99. package/dist/cli.js +50 -50
  100. package/dist/coding/export-okf-codegraph.js +9 -9
  101. package/dist/compounding/engine.js +11 -11
  102. package/dist/config.js +9 -9
  103. package/dist/connectors/codex-materialize-runner.js +10 -10
  104. package/dist/connectors/index.js +10 -10
  105. package/dist/convergence-refresh.js +12 -12
  106. package/dist/corpus-watermark.js +12 -12
  107. package/dist/entity-retrieval.js +10 -10
  108. package/dist/external-wiki-access.js +25 -25
  109. package/dist/extraction.js +14 -14
  110. package/dist/harmonic-retrieval.js +10 -10
  111. package/dist/index.js +65 -65
  112. package/dist/location/index.js +12 -12
  113. package/dist/maintenance/memory-governance.js +10 -10
  114. package/dist/maintenance/rebuild-memory-lifecycle-ledger.js +10 -10
  115. package/dist/maintenance/rebuild-memory-projection-runner.js +12 -12
  116. package/dist/maintenance/rebuild-memory-projection.js +11 -11
  117. package/dist/memory-browse-operations.js +25 -25
  118. package/dist/memory-browse.js +5 -5
  119. package/dist/namespaces/migrate.js +11 -11
  120. package/dist/namespaces/search.d.ts.map +1 -1
  121. package/dist/namespaces/search.js +8 -8
  122. package/dist/namespaces/storage.js +10 -10
  123. package/dist/operator-doctor-corpus.js +13 -13
  124. package/dist/operator-toolkit.js +18 -18
  125. package/dist/orchestration/extraction-persist-deps.d.ts +2 -0
  126. package/dist/orchestration/extraction-persist-deps.d.ts.map +1 -1
  127. package/dist/orchestration/extraction-persist-promotion.d.ts +40 -10
  128. package/dist/orchestration/extraction-persist-promotion.d.ts.map +1 -1
  129. package/dist/orchestration/extraction-persist.d.ts.map +1 -1
  130. package/dist/orchestration/lifecycle-pending-fallback-drain.d.ts +18 -0
  131. package/dist/orchestration/lifecycle-pending-fallback-drain.d.ts.map +1 -0
  132. package/dist/orchestration/maintenance.d.ts +11 -13
  133. package/dist/orchestration/maintenance.d.ts.map +1 -1
  134. package/dist/orchestration/maintenance.js +20 -20
  135. package/dist/orchestration/qmd-maintenance-completion.d.ts +20 -0
  136. package/dist/orchestration/qmd-maintenance-completion.d.ts.map +1 -0
  137. package/dist/orchestrator.d.ts.map +1 -1
  138. package/dist/orchestrator.js +65 -65
  139. package/dist/qmd.js +2 -2
  140. package/dist/recall-composition-decision.js +2 -2
  141. package/dist/recall-navigation-operations.js +25 -25
  142. package/dist/recall-navigation.js +24 -24
  143. package/dist/recall-state-view-admission.js +5 -5
  144. package/dist/recall-why-service.js +5 -5
  145. package/dist/resume-bundles.js +9 -9
  146. package/dist/search/factory.js +7 -7
  147. package/dist/search/index.js +7 -7
  148. package/dist/search/lancedb-backend.d.ts +3 -0
  149. package/dist/search/lancedb-backend.d.ts.map +1 -1
  150. package/dist/search/lancedb-backend.js +2 -2
  151. package/dist/search/meilisearch-backend.d.ts +3 -0
  152. package/dist/search/meilisearch-backend.d.ts.map +1 -1
  153. package/dist/search/meilisearch-backend.js +2 -2
  154. package/dist/search/orama-backend.d.ts +3 -0
  155. package/dist/search/orama-backend.d.ts.map +1 -1
  156. package/dist/search/orama-backend.js +2 -2
  157. package/dist/search/port.d.ts +7 -0
  158. package/dist/search/port.d.ts.map +1 -1
  159. package/dist/search/port.js +1 -1
  160. package/dist/search/remote-backend.d.ts +2 -0
  161. package/dist/search/remote-backend.d.ts.map +1 -1
  162. package/dist/search/remote-backend.js +2 -2
  163. package/dist/semantic-consolidation.js +11 -11
  164. package/dist/semantic-rule-promotion.js +10 -10
  165. package/dist/semantic-rule-verifier.js +10 -10
  166. package/dist/source-agent-qualifier.js +13 -13
  167. package/dist/standup/build.js +10 -10
  168. package/dist/storage/entity-canonical-id-migration-adapter.d.ts +7 -1
  169. package/dist/storage/entity-canonical-id-migration-adapter.d.ts.map +1 -1
  170. package/dist/storage/entity-migration-maintenance-pending.d.ts +31 -0
  171. package/dist/storage/entity-migration-maintenance-pending.d.ts.map +1 -0
  172. package/dist/storage.d.ts +2 -2
  173. package/dist/storage.d.ts.map +1 -1
  174. package/dist/storage.js +9 -9
  175. package/dist/support-passport/index.js +27 -27
  176. package/dist/transfer/export-okf.js +10 -10
  177. package/dist/verified-recall.js +10 -10
  178. package/dist/who-knows-cli.js +11 -11
  179. package/dist/who-knows.js +10 -10
  180. package/package.json +2 -2
  181. package/src/namespaces/search.test.ts +121 -0
  182. package/src/namespaces/search.ts +8 -0
  183. package/src/orchestration/extraction-persist-deps.ts +3 -0
  184. package/src/orchestration/extraction-persist-promotion.ts +104 -16
  185. package/src/orchestration/extraction-persist.ts +66 -77
  186. package/src/orchestration/lifecycle-pending-fallback-drain.ts +97 -0
  187. package/src/orchestration/maintenance.test.ts +443 -1
  188. package/src/orchestration/maintenance.ts +73 -95
  189. package/src/orchestration/qmd-maintenance-completion.ts +32 -0
  190. package/src/orchestrator-entity-write-failure.test.ts +370 -0
  191. package/src/orchestrator.ts +9 -1
  192. package/src/search/lancedb-backend.ts +29 -12
  193. package/src/search/meilisearch-backend.test.ts +148 -0
  194. package/src/search/meilisearch-backend.ts +41 -14
  195. package/src/search/orama-backend.test.ts +75 -1
  196. package/src/search/orama-backend.ts +22 -7
  197. package/src/search/port.ts +7 -0
  198. package/src/search/remote-backend.ts +4 -0
  199. package/src/storage/entity-canonical-id-migration-adapter.ts +44 -9
  200. package/src/storage/entity-migration-maintenance-pending-helpers.test.ts +87 -0
  201. package/src/storage/entity-migration-maintenance-pending.test.ts +242 -0
  202. package/src/storage/entity-migration-maintenance-pending.ts +89 -0
  203. package/src/storage.ts +2 -2
  204. package/dist/chunk-5CWH2LKL.js.map +0 -1
  205. package/dist/chunk-7VO6E5UD.js.map +0 -1
  206. package/dist/chunk-EC2KVA27.js.map +0 -1
  207. package/dist/chunk-JIJAWTRK.js.map +0 -1
  208. package/dist/chunk-SJD334AG.js.map +0 -1
  209. package/dist/chunk-XKDQTDXN.js.map +0 -1
  210. package/dist/chunk-XRFLR3XF.js.map +0 -1
  211. /package/dist/{chunk-3NL4ALUP.js.map → chunk-24SIB3EW.js.map} +0 -0
  212. /package/dist/{chunk-Y3T5KQCR.js.map → chunk-2EYCW2ET.js.map} +0 -0
  213. /package/dist/{chunk-AHGOOZUC.js.map → chunk-34UVJNSG.js.map} +0 -0
  214. /package/dist/{chunk-2EXF6IIB.js.map → chunk-3632XJMR.js.map} +0 -0
  215. /package/dist/{chunk-KETS5A62.js.map → chunk-3H3PNQEF.js.map} +0 -0
  216. /package/dist/{chunk-SFRBUBUZ.js.map → chunk-3LNAQLW4.js.map} +0 -0
  217. /package/dist/{chunk-75BK4XGV.js.map → chunk-4BUJICMI.js.map} +0 -0
  218. /package/dist/{chunk-KIWO4327.js.map → chunk-4W5B5Z6P.js.map} +0 -0
  219. /package/dist/{chunk-B35EQQ7L.js.map → chunk-53OFDW3P.js.map} +0 -0
  220. /package/dist/{chunk-2ONKXEC7.js.map → chunk-5LBBXI2A.js.map} +0 -0
  221. /package/dist/{chunk-UCLJTGP3.js.map → chunk-65B5RFKQ.js.map} +0 -0
  222. /package/dist/{chunk-KAJYSO3U.js.map → chunk-6CDNOREO.js.map} +0 -0
  223. /package/dist/{chunk-POJRFSJM.js.map → chunk-7XGNGLAN.js.map} +0 -0
  224. /package/dist/{chunk-A6JDT6QF.js.map → chunk-AO5ZOD32.js.map} +0 -0
  225. /package/dist/{chunk-RA7YRL6H.js.map → chunk-AOSCGNIC.js.map} +0 -0
  226. /package/dist/{chunk-LWMLBVGB.js.map → chunk-B43P3DTC.js.map} +0 -0
  227. /package/dist/{chunk-VUMWVYMR.js.map → chunk-CIMD2MUR.js.map} +0 -0
  228. /package/dist/{chunk-44EUCSG4.js.map → chunk-FFQNNFHX.js.map} +0 -0
  229. /package/dist/{chunk-HEF4BRMT.js.map → chunk-FZLZUDJJ.js.map} +0 -0
  230. /package/dist/{chunk-L3EFYRYK.js.map → chunk-GGFQAGOT.js.map} +0 -0
  231. /package/dist/{chunk-ZLCBLDJO.js.map → chunk-GIRWNTMV.js.map} +0 -0
  232. /package/dist/{chunk-SHNM7RIT.js.map → chunk-HJ4X4BGS.js.map} +0 -0
  233. /package/dist/{chunk-MUNYUSBU.js.map → chunk-HJQ4NKKO.js.map} +0 -0
  234. /package/dist/{chunk-V6Z4NAJZ.js.map → chunk-HM3ZTDAX.js.map} +0 -0
  235. /package/dist/{chunk-EZS6QJNC.js.map → chunk-IFOCK3OJ.js.map} +0 -0
  236. /package/dist/{chunk-DF6ZF2BN.js.map → chunk-LIVNX7WK.js.map} +0 -0
  237. /package/dist/{chunk-WY66J4PI.js.map → chunk-LRVFS4KA.js.map} +0 -0
  238. /package/dist/{chunk-LMB47UM6.js.map → chunk-LTDK6HBV.js.map} +0 -0
  239. /package/dist/{chunk-HCIRR3BA.js.map → chunk-M4U25HLR.js.map} +0 -0
  240. /package/dist/{chunk-YK7ZJPQM.js.map → chunk-MGN2RQRG.js.map} +0 -0
  241. /package/dist/{chunk-IUQXRRJB.js.map → chunk-OMWQO7H6.js.map} +0 -0
  242. /package/dist/{chunk-SPTPBKZX.js.map → chunk-OWNAWY6D.js.map} +0 -0
  243. /package/dist/{chunk-DQOGZZJA.js.map → chunk-QAHP5SBG.js.map} +0 -0
  244. /package/dist/{chunk-GBGU5OZP.js.map → chunk-QF5EJ5VP.js.map} +0 -0
  245. /package/dist/{chunk-FT75AQ74.js.map → chunk-QS5DUSBM.js.map} +0 -0
  246. /package/dist/{chunk-72CBJ5VP.js.map → chunk-R3WZ74B2.js.map} +0 -0
  247. /package/dist/{chunk-PDTNQLPC.js.map → chunk-RG3I2663.js.map} +0 -0
  248. /package/dist/{chunk-CK6GCGMX.js.map → chunk-RTJJPW3C.js.map} +0 -0
  249. /package/dist/{chunk-RJDYDF5K.js.map → chunk-TRAOZ2WP.js.map} +0 -0
  250. /package/dist/{chunk-6ZAU7LAR.js.map → chunk-TTTPTQ6S.js.map} +0 -0
  251. /package/dist/{chunk-JS44PBCM.js.map → chunk-U2A56LLF.js.map} +0 -0
  252. /package/dist/{chunk-MC7PXJDP.js.map → chunk-UBOWDOL5.js.map} +0 -0
  253. /package/dist/{chunk-4FM35YIA.js.map → chunk-UQYBPPVC.js.map} +0 -0
  254. /package/dist/{chunk-OCRLJJOV.js.map → chunk-V4VCBFAL.js.map} +0 -0
  255. /package/dist/{chunk-FNNGWN3O.js.map → chunk-VGE4PUY6.js.map} +0 -0
  256. /package/dist/{chunk-5DVOBBTV.js.map → chunk-VV73XT3A.js.map} +0 -0
  257. /package/dist/{chunk-GE3ZX6YK.js.map → chunk-W7NKMRAF.js.map} +0 -0
  258. /package/dist/{chunk-AJJPXUE6.js.map → chunk-WBN66HPI.js.map} +0 -0
  259. /package/dist/{chunk-XWJUFER3.js.map → chunk-WGFEGBFB.js.map} +0 -0
  260. /package/dist/{chunk-43TUWQNR.js.map → chunk-XDKEHZUC.js.map} +0 -0
  261. /package/dist/{chunk-ROAKLYOL.js.map → chunk-XHEKS4UN.js.map} +0 -0
  262. /package/dist/{chunk-ZKK3VMWU.js.map → chunk-XVTB6BZS.js.map} +0 -0
  263. /package/dist/{chunk-AZLD4CKG.js.map → chunk-Y56D5FSD.js.map} +0 -0
  264. /package/dist/{chunk-MPWOAIIH.js.map → chunk-YDT3OEYF.js.map} +0 -0
  265. /package/dist/{chunk-IN6U2BS5.js.map → chunk-YJHOVA42.js.map} +0 -0
  266. /package/dist/{chunk-TC55M5PO.js.map → chunk-YLN4ZQV4.js.map} +0 -0
@@ -865,6 +865,7 @@ export class Orchestrator {
865
865
  invalidateGraphEdgeCache: (storage) => this.graphIndexFor(storage).invalidateEdgeCache(),
866
866
  updateTemporalTagIndexes: (storage, persistedIds) =>
867
867
  this.updateTemporalTagIndexes(storage, persistedIds),
868
+ requestQmdMaintenance: () => this.requestQmdMaintenance(),
868
869
  });
869
870
  }
870
871
  return this._extractionPersistCoordinator;
@@ -1511,7 +1512,14 @@ export class Orchestrator {
1511
1512
  onActivitySynced: (summary) => { if (!this.config.meetings.enabled) return; void this.getMeetingsService().then((s) => s.requestBuildForActivitySync(summary.ranAt, this.config.activity.timezone, summary.touchedDates)).catch((err) => log.warn(`meetings: activity tail-step build failed to schedule: ${err instanceof Error ? err.message : String(err)}`)); },
1512
1513
  });
1513
1514
  const bindStorage = (storage: StorageManager, namespace: string) => {
1514
- storage.onEntityMigrationComplete = () => { if (this.acceptingEntityMigrationMaintenance) this.requestQmdMaintenance(); };
1515
+ storage.onEntityMigrationComplete = () => {
1516
+ if (!this.acceptingEntityMigrationMaintenance) return false;
1517
+ // Settles once a QMD pass covering this request ran; the migration keeps its pending marker until then.
1518
+ const served = this.maintenanceScheduler.requestQmdMaintenanceCompletion();
1519
+ // Same side effects as before: projection rebuild and ledger-compaction checks, independent of QMD.
1520
+ this.requestQmdMaintenance();
1521
+ return served;
1522
+ };
1515
1523
  if (this.prioritizedEmbedding) storage.onMemoryWrite = (filePath) => this.prioritizedEmbedding?.enqueue(filePath, namespace);
1516
1524
  };
1517
1525
  bindStorage(this.storage, this.config.defaultNamespace);
@@ -139,23 +139,38 @@ export class LanceDbBackend implements SearchBackend {
139
139
  }
140
140
 
141
141
  async updateCollectionFromDir(collection: string, memoryDir: string, execution?: SearchExecutionOptions): Promise<void> {
142
- if (isSearchAborted(execution)) return;
142
+ await this.refreshCollection(collection, memoryDir, execution);
143
+ }
144
+
145
+ async updateCompleted(execution?: SearchExecutionOptions): Promise<boolean> {
146
+ try {
147
+ return await this.refreshCollection(this.collection, this.memoryDir, execution);
148
+ } catch (err) {
149
+ log.debug(`LanceDbBackend update did not complete: ${err}`);
150
+ return false;
151
+ }
152
+ }
153
+
154
+ /** Brings the collection up to date; true only when it ran to completion (false: aborted, unavailable, skipped or failed). */
155
+ private async refreshCollection(collection: string, memoryDir: string, execution?: SearchExecutionOptions): Promise<boolean> {
156
+ if (isSearchAborted(execution)) return false;
143
157
  let table = await this.ensureTableForCollection(collection);
144
- if (isSearchAborted(execution)) return;
145
- if (!table) return;
158
+ if (isSearchAborted(execution)) return false;
159
+ if (!table) return false;
146
160
 
147
161
  const docs = await scanMemoryDir(memoryDir);
148
- if (isSearchAborted(execution)) return;
162
+ if (isSearchAborted(execution)) return false;
149
163
  if (docs.length === 0) {
150
164
  // Clear stale data when no docs remain
151
165
  try {
152
166
  const db = await this.ensureDb();
153
167
  await db.dropTable(collection).catch(() => {});
154
168
  if (collection === this.collection) this.table = null;
169
+ return true;
155
170
  } catch {
156
171
  // Best-effort cleanup
157
172
  }
158
- return;
173
+ return false;
159
174
  }
160
175
 
161
176
  const embeddingProviderIdentity = this.embedHelper.getProviderIdentity();
@@ -166,13 +181,13 @@ export class LanceDbBackend implements SearchBackend {
166
181
  const vectorProviderColumnState = await this.tableVectorProviderColumnState(table);
167
182
  if (vectorProviderColumnState === "missing") {
168
183
  table = await this.recreateTableForCollection(collection);
169
- if (isSearchAborted(execution)) return;
170
- if (!table) return;
184
+ if (isSearchAborted(execution)) return false;
185
+ if (!table) return false;
171
186
  } else if (vectorProviderColumnState === "present") {
172
187
  try {
173
188
  const existingRows = await table.query().select(["docid", "vector", "vectorProvider"]).toArray();
174
189
  for (const row of existingRows ?? []) {
175
- if (isSearchAborted(execution)) return;
190
+ if (isSearchAborted(execution)) return false;
176
191
  const docid = row.docid;
177
192
  if (typeof docid !== "string") continue;
178
193
  const vector = row.vector;
@@ -184,11 +199,11 @@ export class LanceDbBackend implements SearchBackend {
184
199
  }
185
200
  } catch {
186
201
  log.debug("LanceDbBackend skipped refresh after vector preservation failed");
187
- return;
202
+ return false;
188
203
  }
189
204
  } else {
190
205
  log.debug("LanceDbBackend skipped vector preservation after vectorProvider probe failed");
191
- return;
206
+ return false;
192
207
  }
193
208
 
194
209
  const rows = docs.map((d) => {
@@ -213,19 +228,21 @@ export class LanceDbBackend implements SearchBackend {
213
228
  });
214
229
 
215
230
  try {
216
- if (isSearchAborted(execution)) return;
231
+ if (isSearchAborted(execution)) return false;
217
232
  await table.add(rows, { mode: "overwrite" });
218
233
  this.rememberVectorProviderCompatibility(
219
234
  table,
220
235
  embeddingProviderIdentity,
221
236
  rows.length > 0 && rows.every((row) => row.vectorProvider === embeddingProviderIdentity),
222
237
  );
223
- if (isSearchAborted(execution)) return;
238
+ if (isSearchAborted(execution)) return false;
224
239
  // FTS index on the content column.
225
240
  await this.createFtsIndex(table);
226
241
  if (collection === this.collection) this.table = table;
242
+ return true;
227
243
  } catch (err) {
228
244
  log.debug(`LanceDbBackend update failed: ${err}`);
245
+ return false;
229
246
  }
230
247
  }
231
248
 
@@ -0,0 +1,148 @@
1
+ import assert from "node:assert/strict";
2
+ import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import test from "node:test";
6
+
7
+ import { MeilisearchBackend } from "./meilisearch-backend.js";
8
+
9
+ // Issue #3286: update() is fail-open on every backend, so the entity-migration
10
+ // pending marker asks updateCompleted() whether the re-index actually ran.
11
+ test("Meilisearch updateCompleted is false whenever an enabled refresh returned early", async () => {
12
+ const base = { host: "http://127.0.0.1:1", collection: "openclaw-engram", memoryDir: "/nonexistent" };
13
+ assert.equal(await new MeilisearchBackend({ ...base, autoIndex: true }).updateCompleted(), false, "never probed: unavailable");
14
+ assert.equal(await new MeilisearchBackend({ host: base.host, collection: base.collection, autoIndex: true }).updateCompleted(), false, "no memory dir");
15
+ });
16
+
17
+ test("Meilisearch updateCompleted is true when autoIndex is off: nothing is pushed by configuration (issue #3286)", async () => {
18
+ // autoIndex defaults to off (the server is managed outside Remnic). Reporting false would
19
+ // make every strict namespace update fail and leave the pending marker stuck.
20
+ const base = { host: "http://127.0.0.1:1", collection: "openclaw-engram", memoryDir: "/nonexistent" };
21
+ assert.equal(await new MeilisearchBackend({ ...base, autoIndex: false }).updateCompleted(), true);
22
+ assert.equal(await new MeilisearchBackend({ host: base.host, collection: base.collection }).updateCompleted(), true, "default config, no memory dir, never probed");
23
+ });
24
+
25
+ /** A Meilisearch client double: waitForTask resolves with the task status, as the real 0.46 SDK does. */
26
+ function stubClient(status: string) {
27
+ return {
28
+ async health() {},
29
+ async getIndex() {},
30
+ index: () => ({
31
+ async addDocuments() {
32
+ return { taskUid: 1 };
33
+ },
34
+ async getDocuments() {
35
+ return { results: [] };
36
+ },
37
+ async deleteDocuments() {
38
+ return { taskUid: 2 };
39
+ },
40
+ }),
41
+ // Resolves for failed and canceled tasks too; only a timeout rejects.
42
+ async waitForTask() {
43
+ return { status };
44
+ },
45
+ };
46
+ }
47
+
48
+ function reachableBackend(memoryDir: string, status: string): MeilisearchBackend {
49
+ const backend = new MeilisearchBackend({ host: "http://127.0.0.1:1", collection: "openclaw-engram", autoIndex: true, memoryDir });
50
+ Object.defineProperty(backend, "available", { value: true });
51
+ Object.defineProperty(backend, "client", { value: stubClient(status) });
52
+ return backend;
53
+ }
54
+
55
+ test("Meilisearch updateCompleted is true only when the add task succeeded (issue #3286)", async () => {
56
+ const root = await mkdtemp(path.join(os.tmpdir(), "remnic-meili-completed-"));
57
+ try {
58
+ await mkdir(path.join(root, "facts"), { recursive: true });
59
+ await writeFile(path.join(root, "facts", "fact.md"), "---\nid: mem-meili\n---\nA fact to index.");
60
+ assert.equal(await reachableBackend(root, "succeeded").updateCompleted(), true);
61
+ for (const status of ["failed", "canceled", "enqueued", "processing"]) {
62
+ assert.equal(await reachableBackend(root, status).updateCompleted(), false, `a ${status} task did not index anything`);
63
+ }
64
+ } finally {
65
+ await rm(root, { recursive: true, force: true });
66
+ }
67
+ });
68
+
69
+ test("Meilisearch updateCompleted is false for a reachable backend with no memory dir to index (issue #3286)", async () => {
70
+ const backend = new MeilisearchBackend({ host: "http://127.0.0.1:1", collection: "openclaw-engram", autoIndex: true });
71
+ Object.defineProperty(backend, "available", { value: true });
72
+ Object.defineProperty(backend, "client", { value: stubClient("succeeded") });
73
+ assert.equal(await backend.updateCompleted(), false);
74
+ });
75
+
76
+ test("Meilisearch updateCompleted is false when a stale-document deletion fails (issue #3286)", async () => {
77
+ const root = await mkdtemp(path.join(os.tmpdir(), "remnic-meili-deletion-"));
78
+ try {
79
+ await mkdir(path.join(root, "facts"), { recursive: true });
80
+ // One live fact plus one stale server-side doc: the add succeeds, the deletion must too.
81
+ await writeFile(path.join(root, "facts", "fact.md"), "---\nid: mem-meili\n---\nA fact to index.");
82
+ const backend = new MeilisearchBackend({ host: "http://127.0.0.1:1", collection: "openclaw-engram", autoIndex: true, memoryDir: root });
83
+ Object.defineProperty(backend, "available", { value: true });
84
+ let waitForTaskCalls = 0;
85
+ Object.defineProperty(backend, "client", {
86
+ value: {
87
+ async health() {},
88
+ async getIndex() {},
89
+ index: () => ({
90
+ async addDocuments() {
91
+ return { taskUid: 1 };
92
+ },
93
+ async getDocuments() {
94
+ return { results: [{ id: "mem-meili" }, { id: "mem-stale" }] };
95
+ },
96
+ async deleteDocuments() {
97
+ return { taskUid: 2 };
98
+ },
99
+ }),
100
+ // First wait is the add (succeeded); second is the stale-doc deletion.
101
+ async waitForTask() {
102
+ waitForTaskCalls += 1;
103
+ return { status: waitForTaskCalls === 1 ? "succeeded" : "failed" };
104
+ },
105
+ },
106
+ });
107
+ assert.equal(await backend.updateCompleted(), false, "a failed deletion leaves a stale document searchable");
108
+ } finally {
109
+ await rm(root, { recursive: true, force: true });
110
+ }
111
+ });
112
+
113
+ test("Meilisearch updateCompleted is false when waiting on the stale-document deletion throws (issue #3286)", async () => {
114
+ const root = await mkdtemp(path.join(os.tmpdir(), "remnic-meili-deletion-throw-"));
115
+ try {
116
+ await mkdir(path.join(root, "facts"), { recursive: true });
117
+ await writeFile(path.join(root, "facts", "fact.md"), "---\nid: mem-meili\n---\nA fact to index.");
118
+ const backend = new MeilisearchBackend({ host: "http://127.0.0.1:1", collection: "openclaw-engram", autoIndex: true, memoryDir: root });
119
+ Object.defineProperty(backend, "available", { value: true });
120
+ let waitForTaskCalls = 0;
121
+ Object.defineProperty(backend, "client", {
122
+ value: {
123
+ async health() {},
124
+ async getIndex() {},
125
+ index: () => ({
126
+ async addDocuments() {
127
+ return { taskUid: 1 };
128
+ },
129
+ async getDocuments() {
130
+ return { results: [{ id: "mem-meili" }, { id: "mem-stale" }] };
131
+ },
132
+ async deleteDocuments() {
133
+ return { taskUid: 2 };
134
+ },
135
+ }),
136
+ // The add succeeds; waiting on the deletion rejects (a timeout in the real SDK).
137
+ async waitForTask() {
138
+ waitForTaskCalls += 1;
139
+ if (waitForTaskCalls > 1) throw new Error("timed out waiting for the deletion task");
140
+ return { status: "succeeded" };
141
+ },
142
+ },
143
+ });
144
+ assert.equal(await backend.updateCompleted(), false);
145
+ } finally {
146
+ await rm(root, { recursive: true, force: true });
147
+ }
148
+ });
@@ -136,17 +136,30 @@ export class MeilisearchBackend implements SearchBackend {
136
136
  }
137
137
 
138
138
  async updateCollectionFromDir(collection: string, memoryDir: string, execution?: SearchExecutionOptions): Promise<void> {
139
- if (!this.autoIndex) return;
140
- if (!this.available) return;
141
- if (isSearchAborted(execution)) return;
139
+ await this.refreshCollection(collection, memoryDir, execution);
140
+ }
141
+
142
+ async updateCompleted(execution?: SearchExecutionOptions): Promise<boolean> {
143
+ // autoIndex is off by default: the Meilisearch server is managed outside Remnic and
144
+ // nothing is pushed by configuration, so there is no re-index that could be left incomplete.
145
+ if (!this.autoIndex) return true;
146
+ if (!this.memoryDir) return false;
147
+ return this.refreshCollection(this.collection, this.memoryDir, execution);
148
+ }
149
+
150
+ /** Brings the collection up to date; true only when it ran to completion (false: aborted, unavailable, skipped or failed). */
151
+ private async refreshCollection(collection: string, memoryDir: string, execution?: SearchExecutionOptions): Promise<boolean> {
152
+ if (!this.autoIndex) return false;
153
+ if (!this.available) return false;
154
+ if (isSearchAborted(execution)) return false;
142
155
 
143
156
  try {
144
157
  const ensured = await this.ensureCollection(memoryDir, collection, execution);
145
- if (ensured === "skipped" || ensured === "missing") return;
158
+ if (ensured === "skipped" || ensured === "missing") return false;
146
159
  const client = await this.ensureClient();
147
- if (isSearchAborted(execution)) return;
160
+ if (isSearchAborted(execution)) return false;
148
161
  const docs = await scanMemoryDir(memoryDir);
149
- if (isSearchAborted(execution)) return;
162
+ if (isSearchAborted(execution)) return false;
150
163
  const index = client.index(collection);
151
164
 
152
165
  const meilDocs = docs.map((d) => ({
@@ -157,10 +170,15 @@ export class MeilisearchBackend implements SearchBackend {
157
170
  }));
158
171
 
159
172
  // Upsert current docs and wait for the task to complete
160
- if (isSearchAborted(execution)) return;
173
+ if (isSearchAborted(execution)) return false;
161
174
  const addTask = await index.addDocuments(meilDocs, { primaryKey: "id" });
162
- await client.waitForTask(addTask.taskUid, { timeOutMs: this.timeoutMs });
163
- if (isSearchAborted(execution)) return;
175
+ // waitForTask resolves with a failed or canceled task and rejects only on timeout.
176
+ const added = await client.waitForTask(addTask.taskUid, { timeOutMs: this.timeoutMs });
177
+ if (added.status !== "succeeded") {
178
+ log.debug(`MeilisearchBackend add task finished with status ${added.status}`);
179
+ return false;
180
+ }
181
+ if (isSearchAborted(execution)) return false;
164
182
 
165
183
  // Remove docs that no longer exist on disk (paginated to handle large indexes)
166
184
  const currentIds = new Set(docs.map((d) => d.docid));
@@ -170,7 +188,7 @@ export class MeilisearchBackend implements SearchBackend {
170
188
  let staleIds: string[] = [];
171
189
  let hasMore = true;
172
190
  while (hasMore) {
173
- if (isSearchAborted(execution)) return;
191
+ if (isSearchAborted(execution)) return false;
174
192
  const page = await index.getDocuments({ limit: PAGE_SIZE, offset, fields: ["id"] });
175
193
  const results = page.results ?? [];
176
194
  for (const doc of results) {
@@ -181,15 +199,24 @@ export class MeilisearchBackend implements SearchBackend {
181
199
  hasMore = results.length === PAGE_SIZE;
182
200
  }
183
201
  if (staleIds.length > 0) {
184
- if (isSearchAborted(execution)) return;
202
+ if (isSearchAborted(execution)) return false;
185
203
  const delTask = await index.deleteDocuments(staleIds);
186
- await client.waitForTask(delTask.taskUid, { timeOutMs: this.timeoutMs });
204
+ // The deletion is part of completion: a stale document left searchable is not an
205
+ // up-to-date index, and waitForTask resolves failed/canceled tasks without throwing.
206
+ const deleted = await client.waitForTask(delTask.taskUid, { timeOutMs: this.timeoutMs });
207
+ if (deleted.status !== "succeeded") {
208
+ log.debug(`MeilisearchBackend stale-document deletion finished with status ${deleted.status}`);
209
+ return false;
210
+ }
187
211
  }
188
- } catch {
189
- // Deletion cleanup is best-effort
212
+ } catch (err) {
213
+ log.debug(`MeilisearchBackend stale-document deletion failed: ${err}`);
214
+ return false;
190
215
  }
216
+ return true;
191
217
  } catch (err) {
192
218
  log.debug(`MeilisearchBackend update failed: ${err}`);
219
+ return false;
193
220
  }
194
221
  }
195
222
 
@@ -1,5 +1,5 @@
1
1
  import assert from "node:assert/strict";
2
- import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
2
+ import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
3
3
  import os from "node:os";
4
4
  import path from "node:path";
5
5
  import test from "node:test";
@@ -232,3 +232,77 @@ test("Orama rebuild gate inspects every indexed string field, not only content (
232
232
  await rm(root, { recursive: true, force: true });
233
233
  }
234
234
  });
235
+
236
+ test("Orama updateCompleted reports a finished refresh, and false for an aborted one (issue #3286)", async () => {
237
+ const root = await mkdtemp(path.join(os.tmpdir(), "remnic-orama-completed-"));
238
+ try {
239
+ await writeMemoryFact(root, "fact.md", "---\nid: mem-completed\n---\nAn indexed fact about completion.");
240
+ const backend = await createBackend(root, path.join(root, "orama-db"), undefined, { update: false });
241
+
242
+ assert.equal(await backend.updateCompleted({ signal: AbortSignal.abort() }), false, "an aborted refresh did not finish");
243
+ assert.equal((await backend.bm25Search("completion")).length, 0, "precondition: nothing was indexed");
244
+
245
+ assert.equal(await backend.updateCompleted(), true);
246
+ assert.equal((await backend.bm25Search("completion")).some((r) => r.docid === "mem-completed"), true);
247
+ } finally {
248
+ await rm(root, { recursive: true, force: true });
249
+ }
250
+ });
251
+
252
+ /** A signal that reports aborted from its Nth read on: lets a test abort a refresh at each of its checkpoints. */
253
+ function abortingFromRead(read: number): AbortSignal {
254
+ const signal = new AbortController().signal;
255
+ let reads = 0;
256
+ Object.defineProperty(signal, "aborted", { get: () => ++reads >= read });
257
+ return signal;
258
+ }
259
+
260
+ test("Orama updateCompleted is false for a refresh aborted at any checkpoint, and true only once it persisted (issue #3286)", async () => {
261
+ const root = await mkdtemp(path.join(os.tmpdir(), "remnic-orama-abort-sweep-"));
262
+ try {
263
+ await writeMemoryFact(root, "kept.md", "---\nid: mem-kept\n---\nA fact that stays indexed.");
264
+ await writeMemoryFact(root, "second.md", "---\nid: mem-second\n---\nA second fact so the diff loops iterate.");
265
+ const dbPath = path.join(root, "orama-db");
266
+ // An existing, persisted index makes the refresh walk the stored hits as well as the corpus.
267
+ await createBackend(root, dbPath);
268
+ const persistedFile = resolveOramaCollectionDbFilePath(dbPath, "openclaw-engram");
269
+ const before = await readFile(persistedFile);
270
+ await rm(path.join(root, "facts", "second.md"));
271
+
272
+ // Every abort checkpoint must report false and leave the persisted index alone;
273
+ // the first run that reports true must be the one that persisted the change.
274
+ let completedAt = 0;
275
+ for (let read = 1; read < 200 && completedAt === 0; read += 1) {
276
+ // A fresh backend per attempt: an aborted refresh leaves its in-memory edits behind,
277
+ // which would shift the checkpoints of the next attempt.
278
+ const backend = await createBackend(root, dbPath, undefined, { update: false });
279
+ const completed = await backend.updateCompleted({ signal: abortingFromRead(read) });
280
+ const persisted = await readFile(persistedFile);
281
+ if (completed) {
282
+ completedAt = read;
283
+ assert.notDeepEqual(persisted, before, "a refresh reporting completion persisted the index");
284
+ } else {
285
+ assert.deepEqual(persisted, before, `aborted at read ${read}: nothing may be persisted`);
286
+ }
287
+ }
288
+ assert.ok(completedAt > 4, `the refresh checks for abort at several points (completed only at read ${completedAt})`);
289
+ } finally {
290
+ await rm(root, { recursive: true, force: true });
291
+ }
292
+ });
293
+
294
+ test("Orama updateCompleted reports false, not a rejection, when the refresh throws (issue #3286)", async () => {
295
+ const root = await mkdtemp(path.join(os.tmpdir(), "remnic-orama-throws-"));
296
+ try {
297
+ await writeMemoryFact(root, "fact.md", "---\nid: mem-throws\n---\nA fact.");
298
+ const backend = await createBackend(root, path.join(root, "orama-db"), undefined, { update: false });
299
+ Object.defineProperty(backend, "persistDbForCollection", {
300
+ value: async () => {
301
+ throw new Error("disk full");
302
+ },
303
+ });
304
+ assert.equal(await backend.updateCompleted(), false);
305
+ } finally {
306
+ await rm(root, { recursive: true, force: true });
307
+ }
308
+ });
@@ -178,14 +178,28 @@ export class OramaBackend implements SearchBackend {
178
178
  }
179
179
 
180
180
  async updateCollectionFromDir(collection: string, memoryDir: string, execution?: SearchExecutionOptions): Promise<void> {
181
- if (isSearchAborted(execution)) return;
181
+ await this.refreshCollection(collection, memoryDir, execution);
182
+ }
183
+
184
+ async updateCompleted(execution?: SearchExecutionOptions): Promise<boolean> {
185
+ try {
186
+ return await this.refreshCollection(this.collection, this.memoryDir, execution);
187
+ } catch (err) {
188
+ log.debug(`OramaBackend update did not complete: ${err}`);
189
+ return false;
190
+ }
191
+ }
192
+
193
+ /** Brings the collection up to date; true only when it ran to completion (false: aborted, unavailable, skipped or failed). */
194
+ private async refreshCollection(collection: string, memoryDir: string, execution?: SearchExecutionOptions): Promise<boolean> {
195
+ if (isSearchAborted(execution)) return false;
182
196
  const db = await this.ensureDbForCollection(collection);
183
- if (isSearchAborted(execution)) return;
184
- if (!db) return;
197
+ if (isSearchAborted(execution)) return false;
198
+ if (!db) return false;
185
199
  const { search: oramaSearch, insert, remove, count, getByID } = this.oramaModule;
186
200
 
187
201
  const docs = await scanMemoryDir(memoryDir);
188
- if (isSearchAborted(execution)) return;
202
+ if (isSearchAborted(execution)) return false;
189
203
  const docMap = new Map(docs.map((d) => [d.docid, d]));
190
204
  const { update: oramaUpdate } = this.oramaModule;
191
205
 
@@ -204,7 +218,7 @@ export class OramaBackend implements SearchBackend {
204
218
  limit: existingCount + 100,
205
219
  });
206
220
  for (const hit of allHits.hits) {
207
- if (isSearchAborted(execution)) return;
221
+ if (isSearchAborted(execution)) return false;
208
222
  const storedDocument =
209
223
  typeof getByID === "function"
210
224
  ? await getByID(db, hit.id)
@@ -227,7 +241,7 @@ export class OramaBackend implements SearchBackend {
227
241
 
228
242
  // Insert new docs, update existing ones (preserving vectors since update is remove+insert)
229
243
  for (const doc of docs) {
230
- if (isSearchAborted(execution)) return;
244
+ if (isSearchAborted(execution)) return false;
231
245
  const existing = existingDocs.get(doc.docid);
232
246
  if (existing) {
233
247
  const payload: Record<string, unknown> = {
@@ -281,13 +295,14 @@ export class OramaBackend implements SearchBackend {
281
295
  }
282
296
  }
283
297
 
284
- if (isSearchAborted(execution)) return;
298
+ if (isSearchAborted(execution)) return false;
285
299
  await this.persistDbForCollection(db, collection);
286
300
  this.rememberVectorProviderCompatibility(
287
301
  db,
288
302
  embeddingProviderIdentity,
289
303
  allRowsCompatible,
290
304
  );
305
+ return true;
291
306
  }
292
307
 
293
308
  async embed(): Promise<void> {
@@ -182,6 +182,13 @@ export interface SearchBackend {
182
182
  * calls remain fail-open for migration/maintenance resilience.
183
183
  */
184
184
  updateStrict?(execution?: SearchExecutionOptions): Promise<void>;
185
+ /**
186
+ * Fail-open like update(), but reports whether the index was actually brought
187
+ * up to date: false when it returned early (aborted, unavailable, suppressed
188
+ * or skipped) or failed. It never rejects for an update failure. For backends with no strict variant, so a caller
189
+ * that must not lose a re-index (the entity-migration pending marker) can tell.
190
+ */
191
+ updateCompleted?(execution?: SearchExecutionOptions): Promise<boolean>;
185
192
  updateCollection(collection: string, execution?: SearchExecutionOptions): Promise<void>;
186
193
  updateCollectionFromDir?(collection: string, memoryDir: string, execution?: SearchExecutionOptions): Promise<void>;
187
194
  /**
@@ -84,6 +84,10 @@ export class RemoteSearchBackend implements SearchBackend {
84
84
  }
85
85
 
86
86
  async update(_execution?: SearchExecutionOptions): Promise<void> {}
87
+ /** The remote service owns its own indexing, so there is no local re-index that could be left incomplete. */
88
+ async updateCompleted(_execution?: SearchExecutionOptions): Promise<boolean> {
89
+ return true;
90
+ }
87
91
  async updateCollection(_collection: string, _execution?: SearchExecutionOptions): Promise<void> {}
88
92
  async embed(): Promise<void> {}
89
93
  async embedCollection(_collection: string): Promise<void> {}
@@ -10,6 +10,12 @@ import {
10
10
  rewriteProjectedEntityReferences,
11
11
  rewriteProjectedMemoryEntityReference,
12
12
  } from "../memory-projection-mutations.js";
13
+ import {
14
+ listMaintenancePending,
15
+ settleMaintenancePending,
16
+ trackMaintenanceSettlement,
17
+ writeMaintenancePending,
18
+ } from "./entity-migration-maintenance-pending.js";
13
19
 
14
20
  export interface EntityCanonicalIdMigrationHost {
15
21
  readonly baseDir: string;
@@ -31,7 +37,13 @@ export interface EntityCanonicalIdMigrationHost {
31
37
  invalidateColdMemoriesCache(): void;
32
38
  bumpMemoryStatusVersion(): void;
33
39
  getEntityMutationVersion(): number;
34
- onEntityMigrationComplete?: () => void;
40
+ /**
41
+ * Ask search maintenance to re-index. `false` means the request was refused
42
+ * (e.g. the host is shutting down) and a promise settling to `false` means no
43
+ * pass served it: the pending markers stay so the next start retries. A
44
+ * promise is awaited off the startup path; `true` or nothing clears them.
45
+ */
46
+ onEntityMigrationComplete?: () => boolean | Promise<boolean> | void;
35
47
  }
36
48
 
37
49
  export async function runLegacyEntityCanonicalIdMigration(
@@ -43,15 +55,26 @@ export async function runLegacyEntityCanonicalIdMigration(
43
55
  const readMigrationFingerprint = () =>
44
56
  getFingerprint(host.baseDir, host.entitiesDir, () => String(host.getEntityMutationVersion()));
45
57
  // The migration bumps the status version only after a pass that rewrote
46
- // references; a no-op replay must not ask search maintenance to run.
58
+ // references; a no-op replay must not ask search maintenance to run unless a
59
+ // previous run died owing it (issue #3286 pending marker).
47
60
  let rewroteReferences = false;
61
+ // Markers an earlier run left behind: this run owes (and may clear) exactly
62
+ // these plus its own, never a marker a concurrent run arms meanwhile.
63
+ const owed = await listMaintenancePending(host.stateDir);
64
+ // Arm the marker before this run rewrites anything, so a crash between the
65
+ // rewrite and the hook leaves it behind. Idempotent per run.
66
+ let armed: Promise<string> | undefined;
67
+ const armPending = (): Promise<string> => (armed ??= writeMaintenancePending(host.stateDir));
48
68
  const completionFingerprint = await migrateLegacyEntityCanonicalIds({
49
69
  stateDir: host.stateDir,
50
70
  entitiesDir: host.entitiesDir,
51
71
  normalizeEntityName: host.normalizeEntityName.bind(host),
52
72
  resolveEntityFilePath: host.resolveEntityFilePath.bind(host),
53
73
  readStorageSecureFile: host.readStorageSecureFile.bind(host),
54
- writeStorageSecureFile: host.writeStorageSecureFile.bind(host),
74
+ writeStorageSecureFile: async (filePath, content, forceEncrypt) => {
75
+ await armPending();
76
+ return host.writeStorageSecureFile(filePath, content, forceEncrypt);
77
+ },
55
78
  isEncryptedStorageFile: host.isEncryptedFileHeader.bind(host),
56
79
  snapshotBeforeWrite: host.snapshotBeforeWrite.bind(host),
57
80
  parseEntityFile,
@@ -63,10 +86,14 @@ export async function runLegacyEntityCanonicalIdMigration(
63
86
  readArchivedMemories: host.readArchivedMemories.bind(host),
64
87
  validateMemoryScanRoots: () =>
65
88
  validateRoots(host.baseDir, host.entitiesDir, host.stateDir, host.archiveDir),
66
- rewriteProjectedEntityReferences: (mappings) =>
67
- rewriteProjectedEntityReferences(host.baseDir, mappings),
68
- rewriteProjectedMemoryEntityReference: (memoryId, previousEntityRef, nextEntityRef) =>
69
- rewriteProjectedMemoryEntityReference(host.baseDir, memoryId, previousEntityRef, nextEntityRef),
89
+ rewriteProjectedEntityReferences: async (mappings) => {
90
+ await armPending();
91
+ return rewriteProjectedEntityReferences(host.baseDir, mappings);
92
+ },
93
+ rewriteProjectedMemoryEntityReference: async (memoryId, previousEntityRef, nextEntityRef) => {
94
+ await armPending();
95
+ return rewriteProjectedMemoryEntityReference(host.baseDir, memoryId, previousEntityRef, nextEntityRef);
96
+ },
70
97
  invalidateKnowledgeIndexCache: host.invalidateKnowledgeIndexCache.bind(host),
71
98
  invalidateAllMemoriesCache: host.invalidateAllMemoriesCache.bind(host),
72
99
  invalidateColdMemoriesCache: host.invalidateColdMemoriesCache.bind(host),
@@ -76,9 +103,17 @@ export async function runLegacyEntityCanonicalIdMigration(
76
103
  },
77
104
  readMigrationFingerprint,
78
105
  } satisfies EntityCanonicalIdMigrationDependencies);
79
- if (completionFingerprint !== undefined && rewroteReferences) {
106
+ if (completionFingerprint !== undefined && (rewroteReferences || owed.length > 0) && host.onEntityMigrationComplete) {
107
+ // No hook wired (e.g. a CLI-only process) skips this block, keeping the markers too.
108
+ const held = armed ? [...owed, await armed] : owed;
80
109
  try {
81
- host.onEntityMigrationComplete?.();
110
+ const outcome = host.onEntityMigrationComplete();
111
+ const settling = settleMaintenancePending(host.stateDir, held, outcome).catch(() => {
112
+ log.warn("entity migration: search maintenance scheduling failed");
113
+ });
114
+ // An async hook is served after a debounce: never hold startup for it.
115
+ if (outcome instanceof Promise) trackMaintenanceSettlement(host.stateDir, settling);
116
+ else await settling;
82
117
  } catch {
83
118
  log.warn("entity migration: search maintenance scheduling failed");
84
119
  }