@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
@@ -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(): Promise<boolean> {
503
+ if (this.disposed || !this.requestQmdMaintenance()) return Promise.resolve(false);
504
+ return this.qmdCompletion.wait();
493
505
  }
494
506
 
495
507
  /**
@@ -530,6 +542,10 @@ 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;
533
549
 
534
550
  try {
535
551
  if (resolveNamespaceCapabilities(this.deps.config).namespaces) {
@@ -555,7 +571,7 @@ export class MaintenanceScheduler {
555
571
  }
556
572
  this.lastQmdEmbedAtMs = now;
557
573
  };
558
- await runNamespaceMaintenanceBatchPlan(
574
+ const summary = await runNamespaceMaintenanceBatchPlan(
559
575
  this.deps.config,
560
576
  plan,
561
577
  async (candidates) => {
@@ -605,8 +621,46 @@ export class MaintenanceScheduler {
605
621
  skipReasonForError: qmdMaintenanceSkipReasonForError,
606
622
  },
607
623
  );
624
+ // Namespaces skipped for a TRANSIENT reason (budget_exhausted, lock_held) were not covered
625
+ // by this pass. They are covered once they ran in this or an earlier pass of the same
626
+ // request, so a deployment with more namespaces than the cycle budget still converges.
627
+ // Permanent skip reasons describe namespaces this deployment never re-indexes.
628
+ const covered = this.qmdCoveredNamespaces;
629
+ const before = covered.size;
630
+ for (const status of summary.statuses) {
631
+ if (status.state === "ran") covered.add(status.namespace);
632
+ }
633
+ const uncovered = summary.statuses.some(
634
+ (status) =>
635
+ status.state === "skipped" &&
636
+ (status.reason === "budget_exhausted" || status.reason === "lock_held") &&
637
+ !covered.has(status.namespace),
638
+ );
639
+ served = summary.ran > 0 && summary.failed === 0 && !uncovered;
640
+ // Partial progress keeps the waiters for another pass; no progress (or a failure) ends the
641
+ // request unserved so a stuck namespace cannot re-arm passes forever.
642
+ deferred = !served && summary.failed === 0 && covered.size > before && !this.disposed;
608
643
  } else {
609
- await this.deps.getQmd().update();
644
+ const qmd = this.deps.getQmd();
645
+ // Plain update() is fail-open on every backend: it resolves when the min-interval gate,
646
+ // failure backoff or an early exit suppressed it, and when it errored. Only the variants
647
+ // that report whether an update ran count (updateCompleted, else QMD's updateStrict).
648
+ if (qmd.updateCompleted) {
649
+ try {
650
+ served = await qmd.updateCompleted();
651
+ } catch (err) {
652
+ log.debug(`qmd maintenance update did not run: ${err}`);
653
+ }
654
+ } else if (qmd.updateStrict) {
655
+ try {
656
+ await qmd.updateStrict();
657
+ served = true;
658
+ } catch (err) {
659
+ log.debug(`qmd maintenance update did not run: ${err}`);
660
+ }
661
+ } else {
662
+ await qmd.update();
663
+ }
610
664
  const now = Date.now();
611
665
  if (
612
666
  resolveQmdCapabilities(this.deps.config).qmdAutoEmbed &&
@@ -624,7 +678,10 @@ export class MaintenanceScheduler {
624
678
  // backoff) does not advance lastUpdateRanAtMs, so it leaves caches warm
625
679
  // (#1904, Codex/Cursor).
626
680
  } finally {
681
+ settle(served, deferred);
682
+ if (!deferred) this.qmdCoveredNamespaces.clear();
627
683
  this.qmdMaintenanceInFlight = false;
684
+ if (deferred) this.qmdMaintenancePending = true;
628
685
  if (this.qmdMaintenancePending) {
629
686
  this.requestQmdMaintenance();
630
687
  }
@@ -772,7 +829,7 @@ export class MaintenanceScheduler {
772
829
  if (target.storage) {
773
830
  await this.drainPendingLifecycleAppends(target.storage);
774
831
  } else {
775
- await this.drainPendingLifecycleAppendsForPath(target.memoryDir);
832
+ await drainPendingLifecycleAppendsForPath(target.memoryDir);
776
833
  }
777
834
  }
778
835
 
@@ -787,86 +844,6 @@ export class MaintenanceScheduler {
787
844
  }
788
845
  }
789
846
 
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
847
  /**
871
848
  * Enumerate the lifecycle ledgers to consider for compaction: the root
872
849
  * (through the secure-configured root storage when available) plus every
@@ -1186,6 +1163,7 @@ export class MaintenanceScheduler {
1186
1163
  this.qmdMaintenanceTimer = null;
1187
1164
  }
1188
1165
  this.qmdMaintenancePending = false;
1166
+ this.qmdCompletion.abandon();
1189
1167
  await activityDrain;
1190
1168
  await locationDrain; await weeklyDrain;
1191
1169
  // Belt-and-suspenders: clear anything a late tick armed before the latch.
@@ -0,0 +1,32 @@
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<(served: boolean) => void> = [];
10
+
11
+ /** Resolves true when a pass that started after this call completed successfully. */
12
+ wait(): Promise<boolean> {
13
+ return new Promise((resolve) => this.waiters.push(resolve));
14
+ }
15
+
16
+ /**
17
+ * A pass is starting: take every waiter it will serve and return their settle function.
18
+ * `defer` keeps them waiting for a later pass (a pass that covered only part of the selection).
19
+ */
20
+ claim(): (served: boolean, defer?: boolean) => void {
21
+ const claimed = this.waiters.splice(0);
22
+ return (served, defer = false) => {
23
+ if (defer) this.waiters.unshift(...claimed);
24
+ else claimed.forEach((resolve) => resolve(served));
25
+ };
26
+ }
27
+
28
+ /** The scheduler stopped: nothing will serve the waiters. */
29
+ abandon(): void {
30
+ this.claim()(false);
31
+ }
32
+ }
@@ -0,0 +1,370 @@
1
+ import assert from "node:assert/strict";
2
+ import path from "node:path";
3
+ import test from "node:test";
4
+
5
+ import { SmartBuffer } from "./buffer.js";
6
+ import { resetExtractionLlmHealthForTests } from "./extraction-llm-health.js";
7
+ import { Orchestrator } from "./orchestrator.js";
8
+ import { ContentHashIndex } from "./storage.js";
9
+ import {
10
+ cleanupDir,
11
+ harmonicNodeStoreStatus,
12
+ makeHarmonicLifecycleConfig,
13
+ makeLifecycleConfig,
14
+ mkTempMemoryDir,
15
+ stubExtraction,
16
+ } from "./testing/orchestrator-lite.js";
17
+ import type { ExtractionResult } from "./types.js";
18
+
19
+ /** Owner contract for the private Orchestrator temporal/tag batch hook. */
20
+ const FACT = "Project Alpha uses a queue for background processing.";
21
+
22
+ for (const includeFact of [false, true]) {
23
+ test(`#3228: entity write failure retains input${includeFact ? " after a committed fact" : ""}`, async () => {
24
+ const memoryDir = await mkTempMemoryDir("entity-write-failure");
25
+ const config = makeLifecycleConfig(memoryDir, {
26
+ chunkingEnabled: false,
27
+ multiGraphMemoryEnabled: false,
28
+ factDeduplicationEnabled: true,
29
+ extractionMinImportanceLevel: "trivial",
30
+ entityActivityLogEnabled: false,
31
+ });
32
+ const orchestrator = new Orchestrator(config);
33
+ const storage = await orchestrator.getStorageForNamespace();
34
+ const writeEntity = storage.writeEntity.bind(storage);
35
+ try {
36
+ await storage.ensureDirectories();
37
+ const entityName = storage.normalizeEntityName("Alpha", "project");
38
+ await orchestrator.buffer.load();
39
+ const bufferKey = "entity-write-failure";
40
+ await orchestrator.buffer.addTurn(bufferKey, {
41
+ role: "user",
42
+ content: FACT,
43
+ sessionKey: bufferKey,
44
+ timestamp: "2026-10-01T12:00:00.000Z",
45
+ sourceValidAt: "2026-10-01T12:00:00.000Z",
46
+ persistProcessedFingerprint: true,
47
+ });
48
+ await orchestrator.buffer.flushPendingSave({ throwOnFailure: true });
49
+ const turns = orchestrator.buffer.getTurns(bufferKey);
50
+ assert.equal(turns.length, 1, "the source turn must be durably buffered before extraction");
51
+ const before = await storage.loadMeta();
52
+ const extractionCalls = stubExtraction(orchestrator, (): ExtractionResult => ({
53
+ facts: includeFact
54
+ ? [{ content: FACT, category: "fact", confidence: 0.9, tags: [] }]
55
+ : [],
56
+ entities: [{ name: "Alpha", type: "project", facts: ["Uses a queue"] }],
57
+ questions: [],
58
+ profileUpdates: [],
59
+ }));
60
+ const writeFailure = Object.assign(new Error("synthetic entity write denied"), { code: "EACCES" });
61
+ let entityWriteAttempts = 0;
62
+ storage.writeEntity = async (...args: Parameters<typeof writeEntity>) => {
63
+ entityWriteAttempts += 1;
64
+ assert.deepEqual(args.slice(0, 3), ["Alpha", "project", ["Uses a queue"]]);
65
+ throw writeFailure;
66
+ };
67
+ let commits = 0;
68
+ const options = { bufferKey, onDurableCommit: () => { commits += 1; } };
69
+
70
+ await assert.rejects(
71
+ orchestrator.runExtraction(turns, options),
72
+ (error: Error) => error === writeFailure,
73
+ "the original entity I/O failure must reach the caller",
74
+ );
75
+ assert.equal(entityWriteAttempts, 1, "the failure must come from the real entity persistence path");
76
+ assert.equal(commits, 0, "a failed batch must not signal durable completion");
77
+ assert.deepEqual(orchestrator.buffer.getTurns(bufferKey), turns);
78
+ const retainedBuffer = new SmartBuffer(config, storage);
79
+ await retainedBuffer.load();
80
+ assert.deepEqual(retainedBuffer.getTurns(bufferKey), turns, "source turns must remain on disk too");
81
+ const failedMeta = await storage.loadMeta();
82
+ assert.equal(failedMeta.processedExtractionFingerprints?.length ?? 0, 0);
83
+ assert.equal(failedMeta.extractionCount, before.extractionCount);
84
+ assert.equal(await storage.readEntity(entityName), "", "the failed entity was not written");
85
+
86
+ const factsBeforeRetry = (await storage.readAllMemories()).filter((m) => m.frontmatter.category === "fact");
87
+ assert.equal(factsBeforeRetry.length, includeFact ? 1 : 0);
88
+ if (includeFact) {
89
+ const hashes = new ContentHashIndex(path.join(storage.dir, "state"));
90
+ await hashes.load();
91
+ assert.equal(hashes.has(FACT), true, "the earlier fact's deferred hash must be durable before rejection");
92
+ }
93
+
94
+ storage.writeEntity = writeEntity;
95
+ const retried = await orchestrator.runExtraction(turns, options);
96
+ assert.equal(retried.status, "completed");
97
+ assert.equal(retried.persistedCount, 1, "retry writes the entity, not another copy of an earlier fact");
98
+ assert.equal(commits, 1);
99
+ assert.match(await storage.readEntity(entityName), /Uses a queue/);
100
+ assert.equal((await storage.listEntityNames()).length, 1);
101
+ const factsAfterRetry = (await storage.readAllMemories()).filter((m) => m.frontmatter.category === "fact");
102
+ assert.deepEqual(
103
+ factsAfterRetry.map((m) => m.frontmatter.id),
104
+ factsBeforeRetry.map((m) => m.frontmatter.id),
105
+ "retry must preserve the already committed fact without duplicating it",
106
+ );
107
+ assert.equal((await storage.loadMeta()).processedExtractionFingerprints?.length, 1);
108
+ assert.equal(orchestrator.buffer.getTurns(bufferKey).length, 0);
109
+ const clearedBuffer = new SmartBuffer(config, storage);
110
+ await clearedBuffer.load();
111
+ assert.equal(clearedBuffer.getTurns(bufferKey).length, 0);
112
+ assert.equal((await orchestrator.runExtraction(turns, options)).reason, "processed_fingerprint");
113
+ assert.equal(extractionCalls.length, 2, "only the failed attempt and successful retry call the extractor");
114
+ assert.equal(commits, 1, "processed replay must not emit a second commit");
115
+ } finally {
116
+ storage.writeEntity = writeEntity;
117
+ try {
118
+ await orchestrator.destroy();
119
+ } finally {
120
+ resetExtractionLlmHealthForTests();
121
+ await cleanupDir(memoryDir);
122
+ }
123
+ }
124
+ });
125
+ }
126
+
127
+ test("#3231: entity failure after a committed fact still runs deferred post-write maintenance", async () => {
128
+ // Partial-success path: fact (preference) is durable, later writeEntity throws.
129
+ // Batch-end finalization in persistExtraction (hash flush, behavior ledger,
130
+ // harmonic construction, temporal/tag index) and runExtraction's
131
+ // requestQmdMaintenance must still cover the earlier durable write. Retry
132
+ // exact-dedupes the fact (`continue`), so those effects are not re-queued.
133
+ const memoryDir = await mkTempMemoryDir("entity-write-failure-maintenance");
134
+ const preference =
135
+ "Prefers terse queue names for background jobs in Project Alpha.";
136
+ const config = makeHarmonicLifecycleConfig(memoryDir, {
137
+ chunkingEnabled: false,
138
+ multiGraphMemoryEnabled: false,
139
+ factDeduplicationEnabled: true,
140
+ extractionMinImportanceLevel: "trivial",
141
+ entityActivityLogEnabled: false,
142
+ queryAwareIndexingEnabled: true,
143
+ });
144
+ const orchestrator = new Orchestrator(config);
145
+ const storage = await orchestrator.getStorageForNamespace();
146
+ const writeEntity = storage.writeEntity.bind(storage);
147
+ const temporalIdBatches: string[][] = [];
148
+ // Bracket access is TypeScript's typed escape hatch for the private method; the spy only wraps and restores it.
149
+ const originalTemporal = orchestrator["updateTemporalTagIndexes"].bind(orchestrator);
150
+ orchestrator["updateTemporalTagIndexes"] = async (target, persistedIds) => {
151
+ temporalIdBatches.push([...persistedIds]);
152
+ return originalTemporal(target, persistedIds);
153
+ };
154
+ let qmdMaintenanceCalls = 0;
155
+ const originalQmd = orchestrator.requestQmdMaintenance.bind(orchestrator);
156
+ orchestrator.requestQmdMaintenance = () => {
157
+ qmdMaintenanceCalls += 1;
158
+ originalQmd();
159
+ };
160
+ try {
161
+ await storage.ensureDirectories();
162
+ await orchestrator.buffer.load();
163
+ const bufferKey = "entity-write-failure-maintenance";
164
+ await orchestrator.buffer.addTurn(bufferKey, {
165
+ role: "user",
166
+ content: preference,
167
+ sessionKey: bufferKey,
168
+ timestamp: "2026-10-01T12:00:00.000Z",
169
+ sourceValidAt: "2026-10-01T12:00:00.000Z",
170
+ persistProcessedFingerprint: true,
171
+ });
172
+ await orchestrator.buffer.flushPendingSave({ throwOnFailure: true });
173
+ const turns = orchestrator.buffer.getTurns(bufferKey);
174
+ assert.equal(turns.length, 1);
175
+ stubExtraction(orchestrator, (): ExtractionResult => ({
176
+ facts: [
177
+ {
178
+ content: preference,
179
+ category: "preference",
180
+ confidence: 0.95,
181
+ tags: ["queue-naming"],
182
+ },
183
+ ],
184
+ entities: [{ name: "Alpha", type: "project", facts: ["Uses a queue"] }],
185
+ questions: [],
186
+ profileUpdates: [],
187
+ }));
188
+ const writeFailure = Object.assign(new Error("synthetic entity write denied"), {
189
+ code: "EACCES",
190
+ });
191
+ storage.writeEntity = async () => {
192
+ throw writeFailure;
193
+ };
194
+
195
+ await assert.rejects(
196
+ orchestrator.runExtraction(turns, { bufferKey }),
197
+ (error: Error) => error === writeFailure,
198
+ "entity I/O failure must still reach the caller",
199
+ );
200
+
201
+ const facts = (await storage.readAllMemories()).filter(
202
+ (m) => m.frontmatter.category === "preference",
203
+ );
204
+ assert.equal(facts.length, 1, "the preference fact must remain durable");
205
+ const factId = facts[0]!.frontmatter.id;
206
+ const hashes = new ContentHashIndex(path.join(storage.dir, "state"));
207
+ await hashes.load();
208
+ assert.equal(hashes.has(preference), true, "deferred fact hash must flush before rejection");
209
+
210
+ const signals = await storage.readBehaviorSignals(50);
211
+ assert.ok(
212
+ signals.some(
213
+ (s) => s.memoryId === factId && s.signalType === "preference_affinity",
214
+ ),
215
+ "batch-end behavior-signal append must cover the committed preference before rethrow",
216
+ );
217
+
218
+ const harmonic = await harmonicNodeStoreStatus(memoryDir);
219
+ assert.equal(harmonic.nodes.invalid, 0);
220
+ assert.ok(
221
+ harmonic.nodes.valid > 0 && (harmonic.latestNode?.sourceMemoryIds ?? []).includes(factId),
222
+ "batch-end harmonic construction must cover the committed fact before rethrow",
223
+ );
224
+
225
+ assert.ok(
226
+ temporalIdBatches.some((ids) => ids.includes(factId)),
227
+ "batch-end temporal/tag index update must include the committed fact before rethrow",
228
+ );
229
+ assert.ok(
230
+ qmdMaintenanceCalls > 0,
231
+ "partial durable writes must still schedule QMD reindex before the entity error propagates",
232
+ );
233
+
234
+ // Retry exact-dedupes the preference (`continue`), so missing deferred
235
+ // effects are not re-queued. Prove the failure-path finalization is the
236
+ // only chance they have to land.
237
+ storage.writeEntity = writeEntity;
238
+ const retried = await orchestrator.runExtraction(turns, { bufferKey });
239
+ assert.equal(retried.status, "completed");
240
+ assert.equal(
241
+ (await storage.readAllMemories()).filter((m) => m.frontmatter.category === "preference").length,
242
+ 1,
243
+ "retry must not duplicate the already-committed preference",
244
+ );
245
+ assert.ok(
246
+ (await storage.readBehaviorSignals(50)).some(
247
+ (s) => s.memoryId === factId && s.signalType === "preference_affinity",
248
+ ),
249
+ "behavior signals must survive retry without relying on re-queue from the deduped fact",
250
+ );
251
+ assert.ok(
252
+ ((await harmonicNodeStoreStatus(memoryDir)).latestNode?.sourceMemoryIds ?? []).includes(factId),
253
+ "harmonic records must survive retry without relying on re-queue from the deduped fact",
254
+ );
255
+ } finally {
256
+ storage.writeEntity = writeEntity;
257
+ orchestrator.requestQmdMaintenance = originalQmd;
258
+ orchestrator["updateTemporalTagIndexes"] = originalTemporal;
259
+ try {
260
+ await orchestrator.destroy();
261
+ } finally {
262
+ resetExtractionLlmHealthForTests();
263
+ await cleanupDir(memoryDir);
264
+ }
265
+ }
266
+ });
267
+
268
+ test("#3231 P2: a post-write contradiction-resolve failure still finalizes the durable fact", async () => {
269
+ // The fact is durable (writeSealedMemory returned) when
270
+ // applyDeferredContradictionResolve rejects. Its behavior signal, harmonic
271
+ // node, temporal/tag index entry and QMD request must not depend on code
272
+ // that runs after that catch; retry exact-dedupes the fact.
273
+ const memoryDir = await mkTempMemoryDir("contradiction-resolve-failure-maintenance");
274
+ const preference = "Prefers terse queue names for background jobs in Project Beta.";
275
+ const config = makeHarmonicLifecycleConfig(memoryDir, {
276
+ chunkingEnabled: false,
277
+ multiGraphMemoryEnabled: false,
278
+ factDeduplicationEnabled: true,
279
+ extractionMinImportanceLevel: "trivial",
280
+ entityActivityLogEnabled: false,
281
+ queryAwareIndexingEnabled: true,
282
+ });
283
+ const orchestrator = new Orchestrator(config);
284
+ const storage = await orchestrator.getStorageForNamespace();
285
+ const temporalIdBatches: string[][] = [];
286
+ const originalTemporal = orchestrator["updateTemporalTagIndexes"].bind(orchestrator);
287
+ orchestrator["updateTemporalTagIndexes"] = async (target, persistedIds) => {
288
+ temporalIdBatches.push([...persistedIds]);
289
+ return originalTemporal(target, persistedIds);
290
+ };
291
+ let qmdMaintenanceCalls = 0;
292
+ const originalQmd = orchestrator.requestQmdMaintenance.bind(orchestrator);
293
+ orchestrator.requestQmdMaintenance = () => {
294
+ qmdMaintenanceCalls += 1;
295
+ originalQmd();
296
+ };
297
+ const originalResolve = orchestrator["applyDeferredContradictionResolve"].bind(orchestrator);
298
+ const resolveFailure = new Error("synthetic contradiction resolve failure");
299
+ orchestrator["applyDeferredContradictionResolve"] = async () => {
300
+ throw resolveFailure;
301
+ };
302
+ try {
303
+ await storage.ensureDirectories();
304
+ await orchestrator.buffer.load();
305
+ const bufferKey = "contradiction-resolve-failure-maintenance";
306
+ await orchestrator.buffer.addTurn(bufferKey, {
307
+ role: "user",
308
+ content: preference,
309
+ sessionKey: bufferKey,
310
+ timestamp: "2026-10-01T12:00:00.000Z",
311
+ sourceValidAt: "2026-10-01T12:00:00.000Z",
312
+ persistProcessedFingerprint: true,
313
+ });
314
+ await orchestrator.buffer.flushPendingSave({ throwOnFailure: true });
315
+ const turns = orchestrator.buffer.getTurns(bufferKey);
316
+ stubExtraction(orchestrator, (): ExtractionResult => ({
317
+ facts: [
318
+ {
319
+ content: preference,
320
+ category: "preference",
321
+ confidence: 0.95,
322
+ tags: ["queue-naming"],
323
+ },
324
+ ],
325
+ entities: [],
326
+ questions: [],
327
+ profileUpdates: [],
328
+ }));
329
+
330
+ await assert.rejects(
331
+ orchestrator.runExtraction(turns, { bufferKey }),
332
+ (error: Error) => error === resolveFailure,
333
+ "the post-write failure must still reach the caller",
334
+ );
335
+
336
+ const facts = (await storage.readAllMemories()).filter(
337
+ (m) => m.frontmatter.category === "preference",
338
+ );
339
+ assert.equal(facts.length, 1, "the preference fact must remain durable");
340
+ const factId = facts[0]!.frontmatter.id;
341
+ assert.ok(
342
+ (await storage.readBehaviorSignals(50)).some(
343
+ (s) => s.memoryId === factId && s.signalType === "preference_affinity",
344
+ ),
345
+ "the durable fact's behavior signal must land before the rethrow",
346
+ );
347
+ assert.ok(
348
+ ((await harmonicNodeStoreStatus(memoryDir)).latestNode?.sourceMemoryIds ?? []).includes(factId),
349
+ "the durable fact's harmonic node must land before the rethrow",
350
+ );
351
+ assert.ok(
352
+ temporalIdBatches.some((ids) => ids.includes(factId)),
353
+ "the durable fact's temporal/tag index update must run before the rethrow",
354
+ );
355
+ assert.ok(
356
+ qmdMaintenanceCalls > 0,
357
+ "a durable fact that is the only write must still schedule QMD reindex",
358
+ );
359
+ } finally {
360
+ orchestrator["applyDeferredContradictionResolve"] = originalResolve;
361
+ orchestrator.requestQmdMaintenance = originalQmd;
362
+ orchestrator["updateTemporalTagIndexes"] = originalTemporal;
363
+ try {
364
+ await orchestrator.destroy();
365
+ } finally {
366
+ resetExtractionLlmHealthForTests();
367
+ await cleanupDir(memoryDir);
368
+ }
369
+ }
370
+ });