@remnic/core 10.0.52 → 10.0.54

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 (257) 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-UM3ZLT2X.js → chunk-2GU6XS4L.js} +31 -28
  28. package/dist/chunk-2GU6XS4L.js.map +1 -0
  29. package/dist/{chunk-AHGOOZUC.js → chunk-34UVJNSG.js} +2 -2
  30. package/dist/{chunk-2EXF6IIB.js → chunk-3632XJMR.js} +2 -2
  31. package/dist/{chunk-KETS5A62.js → chunk-3H3PNQEF.js} +2 -2
  32. package/dist/{chunk-SFRBUBUZ.js → chunk-3LNAQLW4.js} +3 -3
  33. package/dist/{chunk-75BK4XGV.js → chunk-4BUJICMI.js} +10 -10
  34. package/dist/{chunk-KIWO4327.js → chunk-4W5B5Z6P.js} +2 -2
  35. package/dist/{chunk-B35EQQ7L.js → chunk-53OFDW3P.js} +3 -3
  36. package/dist/{chunk-2ONKXEC7.js → chunk-5LBBXI2A.js} +2 -2
  37. package/dist/{chunk-UCLJTGP3.js → chunk-65B5RFKQ.js} +2 -2
  38. package/dist/{chunk-KAJYSO3U.js → chunk-6CDNOREO.js} +2 -2
  39. package/dist/{chunk-POJRFSJM.js → chunk-7XGNGLAN.js} +2 -2
  40. package/dist/{chunk-A6JDT6QF.js → chunk-AO5ZOD32.js} +2 -2
  41. package/dist/{chunk-RA7YRL6H.js → chunk-AOSCGNIC.js} +3 -3
  42. package/dist/{chunk-LWMLBVGB.js → chunk-B43P3DTC.js} +2 -2
  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-SHNM7RIT.js → chunk-HJ4X4BGS.js} +2 -2
  53. package/dist/{chunk-MUNYUSBU.js → chunk-HJQ4NKKO.js} +2 -2
  54. package/dist/{chunk-V6Z4NAJZ.js → chunk-HM3ZTDAX.js} +2 -2
  55. package/dist/{chunk-EZS6QJNC.js → chunk-IFOCK3OJ.js} +2 -2
  56. package/dist/{chunk-LXXIV7Y2.js → chunk-JEX2JL3U.js} +7 -2
  57. package/dist/{chunk-LXXIV7Y2.js.map → chunk-JEX2JL3U.js.map} +1 -1
  58. package/dist/{chunk-DF6ZF2BN.js → chunk-LIVNX7WK.js} +2 -2
  59. package/dist/{chunk-WY66J4PI.js → chunk-LRVFS4KA.js} +2 -2
  60. package/dist/{chunk-LMB47UM6.js → chunk-LTDK6HBV.js} +2 -2
  61. package/dist/{chunk-HCIRR3BA.js → chunk-M4U25HLR.js} +2 -2
  62. package/dist/{chunk-YK7ZJPQM.js → chunk-MGN2RQRG.js} +2 -2
  63. package/dist/{chunk-G26YNEOC.js → chunk-NWA6IXR3.js} +1 -1
  64. package/dist/{chunk-G26YNEOC.js.map → chunk-NWA6IXR3.js.map} +1 -1
  65. package/dist/{chunk-JIJAWTRK.js → chunk-OCXDZJL4.js} +264 -194
  66. package/dist/chunk-OCXDZJL4.js.map +1 -0
  67. package/dist/{chunk-IUQXRRJB.js → chunk-OMWQO7H6.js} +2 -2
  68. package/dist/{chunk-7VO6E5UD.js → chunk-OVZIYLBZ.js} +6 -2
  69. package/dist/chunk-OVZIYLBZ.js.map +1 -0
  70. package/dist/{chunk-SPTPBKZX.js → chunk-OWNAWY6D.js} +2 -2
  71. package/dist/{chunk-XKDQTDXN.js → chunk-PFUXPTBT.js} +36 -15
  72. package/dist/chunk-PFUXPTBT.js.map +1 -0
  73. package/dist/{chunk-EC2KVA27.js → chunk-PVG7TCUG.js} +186 -105
  74. package/dist/chunk-PVG7TCUG.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/lifecycle-pending-fallback-drain.d.ts +18 -0
  126. package/dist/orchestration/lifecycle-pending-fallback-drain.d.ts.map +1 -0
  127. package/dist/orchestration/maintenance.d.ts +11 -13
  128. package/dist/orchestration/maintenance.d.ts.map +1 -1
  129. package/dist/orchestration/maintenance.js +20 -20
  130. package/dist/orchestration/qmd-maintenance-completion.d.ts +25 -0
  131. package/dist/orchestration/qmd-maintenance-completion.d.ts.map +1 -0
  132. package/dist/orchestrator.d.ts.map +1 -1
  133. package/dist/orchestrator.js +65 -65
  134. package/dist/qmd.js +2 -2
  135. package/dist/recall-composition-decision.js +2 -2
  136. package/dist/recall-navigation-operations.js +25 -25
  137. package/dist/recall-navigation.js +24 -24
  138. package/dist/recall-state-view-admission.js +5 -5
  139. package/dist/recall-why-service.js +5 -5
  140. package/dist/resume-bundles.js +9 -9
  141. package/dist/search/factory.js +7 -7
  142. package/dist/search/index.js +7 -7
  143. package/dist/search/lancedb-backend.d.ts +3 -0
  144. package/dist/search/lancedb-backend.d.ts.map +1 -1
  145. package/dist/search/lancedb-backend.js +2 -2
  146. package/dist/search/meilisearch-backend.d.ts +3 -0
  147. package/dist/search/meilisearch-backend.d.ts.map +1 -1
  148. package/dist/search/meilisearch-backend.js +2 -2
  149. package/dist/search/orama-backend.d.ts +3 -0
  150. package/dist/search/orama-backend.d.ts.map +1 -1
  151. package/dist/search/orama-backend.js +2 -2
  152. package/dist/search/port.d.ts +7 -0
  153. package/dist/search/port.d.ts.map +1 -1
  154. package/dist/search/port.js +1 -1
  155. package/dist/search/remote-backend.d.ts +2 -0
  156. package/dist/search/remote-backend.d.ts.map +1 -1
  157. package/dist/search/remote-backend.js +2 -2
  158. package/dist/semantic-consolidation.js +11 -11
  159. package/dist/semantic-rule-promotion.js +10 -10
  160. package/dist/semantic-rule-verifier.js +10 -10
  161. package/dist/source-agent-qualifier.js +13 -13
  162. package/dist/standup/build.js +10 -10
  163. package/dist/storage/entity-canonical-id-migration-adapter.d.ts +7 -1
  164. package/dist/storage/entity-canonical-id-migration-adapter.d.ts.map +1 -1
  165. package/dist/storage/entity-migration-maintenance-pending.d.ts +31 -0
  166. package/dist/storage/entity-migration-maintenance-pending.d.ts.map +1 -0
  167. package/dist/storage.d.ts +2 -2
  168. package/dist/storage.d.ts.map +1 -1
  169. package/dist/storage.js +9 -9
  170. package/dist/support-passport/index.js +27 -27
  171. package/dist/transfer/export-okf.js +10 -10
  172. package/dist/verified-recall.js +10 -10
  173. package/dist/who-knows-cli.js +11 -11
  174. package/dist/who-knows.js +10 -10
  175. package/package.json +2 -2
  176. package/src/namespaces/search.test.ts +121 -0
  177. package/src/namespaces/search.ts +8 -0
  178. package/src/orchestration/lifecycle-pending-fallback-drain.ts +97 -0
  179. package/src/orchestration/maintenance.test.ts +582 -1
  180. package/src/orchestration/maintenance.ts +82 -95
  181. package/src/orchestration/qmd-maintenance-completion.ts +44 -0
  182. package/src/orchestrator.ts +8 -1
  183. package/src/search/lancedb-backend.ts +29 -12
  184. package/src/search/meilisearch-backend.test.ts +148 -0
  185. package/src/search/meilisearch-backend.ts +41 -14
  186. package/src/search/orama-backend.test.ts +75 -1
  187. package/src/search/orama-backend.ts +22 -7
  188. package/src/search/port.ts +7 -0
  189. package/src/search/remote-backend.ts +4 -0
  190. package/src/storage/entity-canonical-id-migration-adapter.ts +44 -9
  191. package/src/storage/entity-migration-maintenance-pending-helpers.test.ts +87 -0
  192. package/src/storage/entity-migration-maintenance-pending.test.ts +242 -0
  193. package/src/storage/entity-migration-maintenance-pending.ts +89 -0
  194. package/src/storage.ts +2 -2
  195. package/dist/chunk-5CWH2LKL.js.map +0 -1
  196. package/dist/chunk-7VO6E5UD.js.map +0 -1
  197. package/dist/chunk-EC2KVA27.js.map +0 -1
  198. package/dist/chunk-JIJAWTRK.js.map +0 -1
  199. package/dist/chunk-UM3ZLT2X.js.map +0 -1
  200. package/dist/chunk-XKDQTDXN.js.map +0 -1
  201. package/dist/chunk-XRFLR3XF.js.map +0 -1
  202. /package/dist/{chunk-3NL4ALUP.js.map → chunk-24SIB3EW.js.map} +0 -0
  203. /package/dist/{chunk-Y3T5KQCR.js.map → chunk-2EYCW2ET.js.map} +0 -0
  204. /package/dist/{chunk-AHGOOZUC.js.map → chunk-34UVJNSG.js.map} +0 -0
  205. /package/dist/{chunk-2EXF6IIB.js.map → chunk-3632XJMR.js.map} +0 -0
  206. /package/dist/{chunk-KETS5A62.js.map → chunk-3H3PNQEF.js.map} +0 -0
  207. /package/dist/{chunk-SFRBUBUZ.js.map → chunk-3LNAQLW4.js.map} +0 -0
  208. /package/dist/{chunk-75BK4XGV.js.map → chunk-4BUJICMI.js.map} +0 -0
  209. /package/dist/{chunk-KIWO4327.js.map → chunk-4W5B5Z6P.js.map} +0 -0
  210. /package/dist/{chunk-B35EQQ7L.js.map → chunk-53OFDW3P.js.map} +0 -0
  211. /package/dist/{chunk-2ONKXEC7.js.map → chunk-5LBBXI2A.js.map} +0 -0
  212. /package/dist/{chunk-UCLJTGP3.js.map → chunk-65B5RFKQ.js.map} +0 -0
  213. /package/dist/{chunk-KAJYSO3U.js.map → chunk-6CDNOREO.js.map} +0 -0
  214. /package/dist/{chunk-POJRFSJM.js.map → chunk-7XGNGLAN.js.map} +0 -0
  215. /package/dist/{chunk-A6JDT6QF.js.map → chunk-AO5ZOD32.js.map} +0 -0
  216. /package/dist/{chunk-RA7YRL6H.js.map → chunk-AOSCGNIC.js.map} +0 -0
  217. /package/dist/{chunk-LWMLBVGB.js.map → chunk-B43P3DTC.js.map} +0 -0
  218. /package/dist/{chunk-VUMWVYMR.js.map → chunk-CIMD2MUR.js.map} +0 -0
  219. /package/dist/{chunk-44EUCSG4.js.map → chunk-FFQNNFHX.js.map} +0 -0
  220. /package/dist/{chunk-HEF4BRMT.js.map → chunk-FZLZUDJJ.js.map} +0 -0
  221. /package/dist/{chunk-L3EFYRYK.js.map → chunk-GGFQAGOT.js.map} +0 -0
  222. /package/dist/{chunk-ZLCBLDJO.js.map → chunk-GIRWNTMV.js.map} +0 -0
  223. /package/dist/{chunk-SHNM7RIT.js.map → chunk-HJ4X4BGS.js.map} +0 -0
  224. /package/dist/{chunk-MUNYUSBU.js.map → chunk-HJQ4NKKO.js.map} +0 -0
  225. /package/dist/{chunk-V6Z4NAJZ.js.map → chunk-HM3ZTDAX.js.map} +0 -0
  226. /package/dist/{chunk-EZS6QJNC.js.map → chunk-IFOCK3OJ.js.map} +0 -0
  227. /package/dist/{chunk-DF6ZF2BN.js.map → chunk-LIVNX7WK.js.map} +0 -0
  228. /package/dist/{chunk-WY66J4PI.js.map → chunk-LRVFS4KA.js.map} +0 -0
  229. /package/dist/{chunk-LMB47UM6.js.map → chunk-LTDK6HBV.js.map} +0 -0
  230. /package/dist/{chunk-HCIRR3BA.js.map → chunk-M4U25HLR.js.map} +0 -0
  231. /package/dist/{chunk-YK7ZJPQM.js.map → chunk-MGN2RQRG.js.map} +0 -0
  232. /package/dist/{chunk-IUQXRRJB.js.map → chunk-OMWQO7H6.js.map} +0 -0
  233. /package/dist/{chunk-SPTPBKZX.js.map → chunk-OWNAWY6D.js.map} +0 -0
  234. /package/dist/{chunk-DQOGZZJA.js.map → chunk-QAHP5SBG.js.map} +0 -0
  235. /package/dist/{chunk-GBGU5OZP.js.map → chunk-QF5EJ5VP.js.map} +0 -0
  236. /package/dist/{chunk-FT75AQ74.js.map → chunk-QS5DUSBM.js.map} +0 -0
  237. /package/dist/{chunk-72CBJ5VP.js.map → chunk-R3WZ74B2.js.map} +0 -0
  238. /package/dist/{chunk-PDTNQLPC.js.map → chunk-RG3I2663.js.map} +0 -0
  239. /package/dist/{chunk-CK6GCGMX.js.map → chunk-RTJJPW3C.js.map} +0 -0
  240. /package/dist/{chunk-RJDYDF5K.js.map → chunk-TRAOZ2WP.js.map} +0 -0
  241. /package/dist/{chunk-6ZAU7LAR.js.map → chunk-TTTPTQ6S.js.map} +0 -0
  242. /package/dist/{chunk-JS44PBCM.js.map → chunk-U2A56LLF.js.map} +0 -0
  243. /package/dist/{chunk-MC7PXJDP.js.map → chunk-UBOWDOL5.js.map} +0 -0
  244. /package/dist/{chunk-4FM35YIA.js.map → chunk-UQYBPPVC.js.map} +0 -0
  245. /package/dist/{chunk-OCRLJJOV.js.map → chunk-V4VCBFAL.js.map} +0 -0
  246. /package/dist/{chunk-FNNGWN3O.js.map → chunk-VGE4PUY6.js.map} +0 -0
  247. /package/dist/{chunk-5DVOBBTV.js.map → chunk-VV73XT3A.js.map} +0 -0
  248. /package/dist/{chunk-GE3ZX6YK.js.map → chunk-W7NKMRAF.js.map} +0 -0
  249. /package/dist/{chunk-AJJPXUE6.js.map → chunk-WBN66HPI.js.map} +0 -0
  250. /package/dist/{chunk-XWJUFER3.js.map → chunk-WGFEGBFB.js.map} +0 -0
  251. /package/dist/{chunk-43TUWQNR.js.map → chunk-XDKEHZUC.js.map} +0 -0
  252. /package/dist/{chunk-ROAKLYOL.js.map → chunk-XHEKS4UN.js.map} +0 -0
  253. /package/dist/{chunk-ZKK3VMWU.js.map → chunk-XVTB6BZS.js.map} +0 -0
  254. /package/dist/{chunk-AZLD4CKG.js.map → chunk-Y56D5FSD.js.map} +0 -0
  255. /package/dist/{chunk-MPWOAIIH.js.map → chunk-YDT3OEYF.js.map} +0 -0
  256. /package/dist/{chunk-IN6U2BS5.js.map → chunk-YJHOVA42.js.map} +0 -0
  257. /package/dist/{chunk-TC55M5PO.js.map → chunk-YLN4ZQV4.js.map} +0 -0
@@ -22,7 +22,7 @@
22
22
  import { resolveNamespaceCapabilities,
23
23
  resolveQmdCapabilities,resolveConsolidationCapabilities, resolveRecallAuxiliaryCapabilities } from "../capabilities.js";
24
24
  import { existsSync, type Dirent } from "node:fs";
25
- import { appendFile, lstat, mkdir, readFile, readdir, realpath, stat, writeFile } from "node:fs/promises";
25
+ import { lstat, readdir, realpath, stat } from "node:fs/promises";
26
26
  import path from "node:path";
27
27
 
28
28
  import type { PluginConfig } from "../types.js";
@@ -57,11 +57,8 @@ import {
57
57
  maybeRebuildMemoryProjectionScheduled,
58
58
  terminateScheduledProjectionRebuilds,
59
59
  } from "./projection-rebuild-schedule.js";
60
- import {
61
- drainPendingLifecycleLedgerIfAny,
62
- pendingLifecycleLedgerDir,
63
- type LifecyclePendingIo,
64
- } from "../storage/memory-lifecycle-ledger-access.js";
60
+ import { drainPendingLifecycleAppendsForPath } from "./lifecycle-pending-fallback-drain.js";
61
+ import { QmdMaintenanceCompletion } from "./qmd-maintenance-completion.js";
65
62
  import { STATE_FILE_MAX_DECRYPT_BYTES } from "../storage/secure-line-reader.js";
66
63
  import { createCompoundingWeeklyCron, type CompoundingWeeklySchedulerHandle } from "../compounding/weekly-scheduler.js";
67
64
  import { startLocationAutoSync, type LocationAutoSyncHandle } from "../location/scheduler.js";
@@ -70,7 +67,7 @@ import type { ActivitySyncRunSummary } from "../activity/runner.js";
70
67
  import { resolveHomeDir } from "../runtime/env.js";
71
68
  import { log } from "../logger.js";
72
69
  import { isErrnoCode } from "../utils/errno.js";
73
- import { assertPathInsideRoot, listContainedSpillFiles } from "../utils/path-containment.js";
70
+ import { assertPathInsideRoot } from "../utils/path-containment.js";
74
71
  import {
75
72
  probeEncryptedRegularFileHeader,
76
73
  SECURE_STORE_ENVELOPE_OVERHEAD_BYTES,
@@ -137,6 +134,9 @@ export class MaintenanceScheduler {
137
134
  private qmdMaintenanceTimer: NodeJS.Timeout | null = null;
138
135
  private qmdMaintenancePending = false;
139
136
  private qmdMaintenanceInFlight = false;
137
+ private readonly qmdCompletion = new QmdMaintenanceCompletion();
138
+ /** Namespaces that ran in passes of the request still waiting for full coverage. */
139
+ private readonly qmdCoveredNamespaces = new Set<string>();
140
140
  private lastQmdEmbedAtMs = 0;
141
141
  private lastQmdEmbedAtMsByNamespace = new Map<string, number>();
142
142
 
@@ -476,13 +476,13 @@ export class MaintenanceScheduler {
476
476
  // QMD maintenance scheduling (debounced + singleflight)
477
477
  // ───────────────────────────────────────────────────────────────────────
478
478
 
479
- /** Internal: queue a debounced QMD maintenance pass. */
480
- private requestQmdMaintenance(): void {
481
- if (!this.deps.getQmd().isAvailable()) return;
482
- if (!resolveQmdCapabilities(this.deps.config).qmdMaintenance) return;
479
+ /** Internal: queue a debounced QMD maintenance pass; false when nothing could be armed. */
480
+ private requestQmdMaintenance(): boolean {
481
+ if (!this.deps.getQmd().isAvailable()) return false;
482
+ if (!resolveQmdCapabilities(this.deps.config).qmdMaintenance) return false;
483
483
 
484
484
  this.qmdMaintenancePending = true;
485
- if (this.qmdMaintenanceTimer) return;
485
+ if (this.qmdMaintenanceTimer) return true;
486
486
 
487
487
  this.qmdMaintenanceTimer = setTimeout(() => {
488
488
  this.qmdMaintenanceTimer = null;
@@ -490,6 +490,18 @@ export class MaintenanceScheduler {
490
490
  log.debug(`background qmd maintenance failed: ${err}`),
491
491
  );
492
492
  }, this.deps.config.qmdMaintenanceDebounceMs);
493
+ return true;
494
+ }
495
+
496
+ /**
497
+ * Like a maintenance request, but resolves true only once a pass that started
498
+ * after it completed successfully. False when nothing could be armed (QMD
499
+ * unavailable or maintenance off), the pass failed, or dispose() abandoned it,
500
+ * so a caller that must not lose the re-index keeps owing it.
501
+ */
502
+ requestQmdMaintenanceCompletion(namespace?: string): Promise<boolean> {
503
+ if (this.disposed || !this.requestQmdMaintenance()) return Promise.resolve(false);
504
+ return this.qmdCompletion.wait(namespace);
493
505
  }
494
506
 
495
507
  /**
@@ -530,6 +542,12 @@ export class MaintenanceScheduler {
530
542
  if (!this.qmdMaintenancePending) return;
531
543
  this.qmdMaintenanceInFlight = true;
532
544
  this.qmdMaintenancePending = false;
545
+ // Waiters claimed here are served by THIS pass; later requests wait for the next.
546
+ const settle = this.qmdCompletion.claim();
547
+ let served = false;
548
+ let deferred = false;
549
+ // Set only by the namespace-aware branch; absent means the pass outcome applies to every waiter.
550
+ let ranNamespaces: Set<string> | undefined;
533
551
 
534
552
  try {
535
553
  if (resolveNamespaceCapabilities(this.deps.config).namespaces) {
@@ -555,7 +573,7 @@ export class MaintenanceScheduler {
555
573
  }
556
574
  this.lastQmdEmbedAtMs = now;
557
575
  };
558
- await runNamespaceMaintenanceBatchPlan(
576
+ const summary = await runNamespaceMaintenanceBatchPlan(
559
577
  this.deps.config,
560
578
  plan,
561
579
  async (candidates) => {
@@ -605,8 +623,53 @@ export class MaintenanceScheduler {
605
623
  skipReasonForError: qmdMaintenanceSkipReasonForError,
606
624
  },
607
625
  );
626
+ // Namespaces skipped for a TRANSIENT reason (budget_exhausted, lock_held) were not covered
627
+ // by this pass. They are covered once they ran in this or an earlier pass of the same
628
+ // request, so a deployment with more namespaces than the cycle budget still converges.
629
+ // Permanent skip reasons describe namespaces this deployment never re-indexes.
630
+ const covered = this.qmdCoveredNamespaces;
631
+ const ran = new Set<string>();
632
+ ranNamespaces = ran;
633
+ const before = covered.size;
634
+ for (const status of summary.statuses) {
635
+ if (status.state !== "ran") continue;
636
+ covered.add(status.namespace);
637
+ ran.add(status.namespace);
638
+ }
639
+ const uncovered = summary.statuses.some(
640
+ (status) =>
641
+ status.state === "skipped" &&
642
+ (status.reason === "budget_exhausted" || status.reason === "lock_held") &&
643
+ !covered.has(status.namespace),
644
+ );
645
+ // The batch runner drops namespace "*" skips from the statuses, so a failed catalog read
646
+ // is only visible on the plan: cataloged namespaces were never discovered this pass.
647
+ const catalogReadFailed = plan.skipped.some((skip) => skip.reason === "catalog_read_failed");
648
+ served = summary.ran > 0 && summary.failed === 0 && !uncovered && !catalogReadFailed;
649
+ // Partial progress keeps the waiters for another pass; no progress, a failure or a failed
650
+ // catalog read ends the request unserved so the next start asks again.
651
+ deferred = !served && summary.failed === 0 && !catalogReadFailed && covered.size > before && !this.disposed;
608
652
  } else {
609
- await this.deps.getQmd().update();
653
+ const qmd = this.deps.getQmd();
654
+ // Plain update() is fail-open on every backend: it resolves when the min-interval gate,
655
+ // failure backoff or an early exit suppressed it, and when it errored. Only the variants
656
+ // that report whether an update ran count (updateCompleted, else QMD's updateStrict).
657
+ if (qmd.updateCompleted) {
658
+ try {
659
+ served = await qmd.updateCompleted();
660
+ } catch (err) {
661
+ log.debug(`qmd maintenance update did not run: ${err}`);
662
+ }
663
+ } else if (qmd.updateStrict) {
664
+ try {
665
+ await qmd.updateStrict();
666
+ served = true;
667
+ } catch (err) {
668
+ log.debug(`qmd maintenance update did not run: ${err}`);
669
+ }
670
+ } else {
671
+ await qmd.update();
672
+ }
610
673
  const now = Date.now();
611
674
  if (
612
675
  resolveQmdCapabilities(this.deps.config).qmdAutoEmbed &&
@@ -624,7 +687,10 @@ export class MaintenanceScheduler {
624
687
  // backoff) does not advance lastUpdateRanAtMs, so it leaves caches warm
625
688
  // (#1904, Codex/Cursor).
626
689
  } finally {
690
+ settle(served, deferred, ranNamespaces);
691
+ if (!deferred) this.qmdCoveredNamespaces.clear();
627
692
  this.qmdMaintenanceInFlight = false;
693
+ if (deferred) this.qmdMaintenancePending = true;
628
694
  if (this.qmdMaintenancePending) {
629
695
  this.requestQmdMaintenance();
630
696
  }
@@ -772,7 +838,7 @@ export class MaintenanceScheduler {
772
838
  if (target.storage) {
773
839
  await this.drainPendingLifecycleAppends(target.storage);
774
840
  } else {
775
- await this.drainPendingLifecycleAppendsForPath(target.memoryDir);
841
+ await drainPendingLifecycleAppendsForPath(target.memoryDir);
776
842
  }
777
843
  }
778
844
 
@@ -787,86 +853,6 @@ export class MaintenanceScheduler {
787
853
  }
788
854
  }
789
855
 
790
- /**
791
- * Drain a filesystem-fallback target's pending lifecycle-append spill when no
792
- * secure StorageManager is available (catalog-disabled namespace; issue
793
- * #2033). Uses the safe path-scoped PLAINTEXT context this fallback already
794
- * operates in: a keyless drain can only fold plaintext spills into a plaintext
795
- * ledger, so it refuses when the ledger or ANY spill is encrypted at rest,
796
- * leaving those pending for a keyed (catalog-path) drain — exactly mirroring
797
- * the fallback compaction's encrypted-ledger deferral. Non-fatal; a fast
798
- * no-op when nothing is pending. The shared spill lister enforces the symlink/
799
- * containment guard on every spill file it reads or deletes.
800
- */
801
- private async drainPendingLifecycleAppendsForPath(memoryDir: string): Promise<void> {
802
- const ledgerPath = path.join(memoryDir, "state", "memory-lifecycle-ledger.jsonl");
803
- const spillDir = pendingLifecycleLedgerDir(ledgerPath);
804
- // Enumerate spills through the SAME guarded lister the actual drain uses
805
- // (listContainedSpillFiles): it rejects a symlinked/non-directory spill dir
806
- // and skips any symlink/FIFO/device/escaping entry, returning only regular
807
- // files contained in the dir. Never raw-readdir + open() an untrusted name:
808
- // opening a FIFO would block until a writer appears and a symlink would
809
- // redirect the probe outside the dir (#2033). Enumerate BOTH live `*.jsonl`
810
- // spills and crash-orphaned `*.jsonl.claimed` files: a drain that died
811
- // between claiming a spill (rename to `.claimed`) and committing it leaves
812
- // the ONLY copy of those rows as a claimed orphan. Gating solely on live
813
- // spills would early-return here whenever the pending dir holds nothing but
814
- // orphans, stranding those rows forever; proceeding lets
815
- // drainPendingLifecycleLedgerIfAny -> recoverOrphanedClaims restore and
816
- // re-commit them under the ledger lock (#2033).
817
- let spillFiles: string[];
818
- let claimedOrphans: string[];
819
- try {
820
- spillFiles = await listContainedSpillFiles(spillDir);
821
- claimedOrphans = await listContainedSpillFiles(spillDir, ".jsonl.claimed");
822
- } catch (err) {
823
- log.debug(`fallback lifecycle pending scan failed (non-fatal) for ${memoryDir}: ${err}`);
824
- return;
825
- }
826
- if (spillFiles.length === 0 && claimedOrphans.length === 0) return;
827
- // The pending encryption state is authoritative only UNDER the ledger lock:
828
- // drainPendingLifecycleLedgerIfAny waits for the lock and re-enumerates the
829
- // pending dir, so a secure-store writer that spills an encrypted file — or a
830
- // keyed compaction that rewrites the ledger encrypted — while this fallback
831
- // waits would defeat any pre-lock probe (#2033). Probe each file at the
832
- // moment of use inside the held lock instead: readSecure refuses an
833
- // encrypted spill (the fold reads each spill lazily and propagates the throw
834
- // BEFORE claiming that spill, leaving it intact) and the append refuses a
835
- // ledger that became encrypted (the fold rolls the claims back). Either way
836
- // the plaintext IO
837
- // never reads or writes ciphertext; the keyed catalog-path drain handles the
838
- // deferred rows.
839
- const io: LifecyclePendingIo = {
840
- writeSecure: async (filePath, payload) => {
841
- await mkdir(path.dirname(filePath), { recursive: true });
842
- await writeFile(filePath, payload, "utf-8");
843
- },
844
- readSecure: async (filePath) => {
845
- if (await probeEncryptedRegularFileHeader(filePath)) {
846
- throw new Error(`fallback lifecycle drain deferred: encrypted spill at ${filePath}`);
847
- }
848
- return readFile(filePath, "utf-8");
849
- },
850
- };
851
- try {
852
- await drainPendingLifecycleLedgerIfAny(
853
- ledgerPath,
854
- io,
855
- async (payload) => {
856
- if (await probeEncryptedRegularFileHeader(ledgerPath)) {
857
- throw new Error(`fallback lifecycle drain deferred: ledger became encrypted at ${ledgerPath}`);
858
- }
859
- await appendFile(ledgerPath, payload, "utf-8");
860
- },
861
- async () => {
862
- await mkdir(path.dirname(ledgerPath), { recursive: true });
863
- },
864
- );
865
- } catch (err) {
866
- log.debug(`fallback lifecycle pending drain failed (non-fatal) for ${memoryDir}: ${err}`);
867
- }
868
- }
869
-
870
856
  /**
871
857
  * Enumerate the lifecycle ledgers to consider for compaction: the root
872
858
  * (through the secure-configured root storage when available) plus every
@@ -1186,6 +1172,7 @@ export class MaintenanceScheduler {
1186
1172
  this.qmdMaintenanceTimer = null;
1187
1173
  }
1188
1174
  this.qmdMaintenancePending = false;
1175
+ this.qmdCompletion.abandon();
1189
1176
  await activityDrain;
1190
1177
  await locationDrain; await weeklyDrain;
1191
1178
  // Belt-and-suspenders: clear anything a late tick armed before the latch.
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Callers that must know the re-index actually ran (the entity-migration
3
+ * pending marker, issue #3286) cannot rely on arming the debounced timer: a
4
+ * crash or dispose() inside the debounce window would lose the request. They
5
+ * wait here instead; the scheduler claims the waiters when a pass starts and
6
+ * settles them with the pass outcome, and abandons them on dispose().
7
+ */
8
+ export class QmdMaintenanceCompletion {
9
+ private waiters: Array<{ resolve: (served: boolean) => void; namespace?: string }> = [];
10
+
11
+ /**
12
+ * Resolves true when a pass that started after this call completed successfully. With a
13
+ * `namespace` the request is served as soon as a pass ran that namespace, whatever else it skipped.
14
+ */
15
+ wait(namespace?: string): Promise<boolean> {
16
+ return new Promise((resolve) => this.waiters.push({ resolve, namespace }));
17
+ }
18
+
19
+ /**
20
+ * A pass is starting: take every waiter it will serve and return their settle function.
21
+ * `ran` lists the namespaces a namespace-aware pass ran (absent when namespaces are disabled:
22
+ * one corpus is maintained and the pass outcome applies to every waiter); `defer` keeps the
23
+ * waiters it did not serve waiting for a later pass (a pass that covered only part of the selection).
24
+ */
25
+ claim(): (served: boolean, defer?: boolean, ran?: ReadonlySet<string>) => void {
26
+ const claimed = this.waiters.splice(0);
27
+ return (served, defer = false, ran) => {
28
+ const requeue: typeof claimed = [];
29
+ for (const waiter of claimed) {
30
+ // A scoped waiter needs its own namespace to have run; the pass outcome alone is not enough.
31
+ const own = waiter.namespace !== undefined && ran !== undefined ? ran.has(waiter.namespace) : served;
32
+ if (own) waiter.resolve(true);
33
+ else if (defer) requeue.push(waiter);
34
+ else waiter.resolve(false);
35
+ }
36
+ this.waiters.unshift(...requeue);
37
+ };
38
+ }
39
+
40
+ /** The scheduler stopped: nothing will serve the waiters. */
41
+ abandon(): void {
42
+ this.claim()(false);
43
+ }
44
+ }
@@ -1512,7 +1512,14 @@ export class Orchestrator {
1512
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)}`)); },
1513
1513
  });
1514
1514
  const bindStorage = (storage: StorageManager, namespace: string) => {
1515
- 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(namespace);
1519
+ // Same side effects as before: projection rebuild and ledger-compaction checks, independent of QMD.
1520
+ this.requestQmdMaintenance();
1521
+ return served;
1522
+ };
1516
1523
  if (this.prioritizedEmbedding) storage.onMemoryWrite = (filePath) => this.prioritizedEmbedding?.enqueue(filePath, namespace);
1517
1524
  };
1518
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