@remnic/core 10.0.65 → 10.0.67

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 (262) hide show
  1. package/dist/access-admin-ops-surface.js +16 -16
  2. package/dist/access-authorization-probe.js +17 -17
  3. package/dist/access-boundary.js +16 -16
  4. package/dist/access-cli.js +61 -61
  5. package/dist/access-extraction-force-flush.js +16 -16
  6. package/dist/access-http-briefing.js +17 -17
  7. package/dist/access-http-query.js +18 -18
  8. package/dist/access-http.js +33 -33
  9. package/dist/access-identity-continuity-surface.js +16 -16
  10. package/dist/access-lcm-surface.js +16 -16
  11. package/dist/access-mcp.js +26 -26
  12. package/dist/access-namespace-preflight.js +16 -16
  13. package/dist/access-observe-write-surface.js +16 -16
  14. package/dist/access-operations-batch.js +17 -17
  15. package/dist/access-operations.js +24 -24
  16. package/dist/access-recall-concurrency.js +16 -16
  17. package/dist/access-recall-diagnostics.js +2 -2
  18. package/dist/access-recall-response.js +16 -16
  19. package/dist/access-recall-surface.js +16 -16
  20. package/dist/access-service.js +16 -16
  21. package/dist/access-surface-catalog.js +18 -18
  22. package/dist/active-recall.js +3 -3
  23. package/dist/briefing.js +3 -3
  24. package/dist/buffer.js +2 -2
  25. package/dist/causal-consolidation.js +4 -4
  26. package/dist/{chunk-KISOMGY2.js → chunk-3RHU5MD7.js} +2 -2
  27. package/dist/{chunk-3LSTL6EY.js → chunk-4MPAKYZU.js} +2 -2
  28. package/dist/{chunk-OVL5AD3N.js → chunk-4NV7O76Q.js} +2 -2
  29. package/dist/{chunk-BH7NVSEC.js → chunk-4SOWMA55.js} +3 -3
  30. package/dist/{chunk-GROHOWR5.js → chunk-5MGKGEL7.js} +2 -2
  31. package/dist/{chunk-ZPLQ27MY.js → chunk-5UM4H64E.js} +2 -2
  32. package/dist/{chunk-2DNFRLLL.js → chunk-5Y6VA6X2.js} +2 -2
  33. package/dist/{chunk-GRAUQXQB.js → chunk-6WKWL53V.js} +13 -13
  34. package/dist/{chunk-HOR4RMUM.js → chunk-75DCOS6I.js} +24 -24
  35. package/dist/{chunk-25QK3LR2.js → chunk-7O2MC42R.js} +2 -2
  36. package/dist/{chunk-7ETWIBTO.js → chunk-ADYP7HXE.js} +3 -3
  37. package/dist/{chunk-FUDKGJDN.js → chunk-AVGDYYMA.js} +2 -2
  38. package/dist/{chunk-ECF627KC.js → chunk-AYJQ644F.js} +2 -2
  39. package/dist/{chunk-T22FBDG3.js → chunk-B3MWELZ3.js} +2 -2
  40. package/dist/{chunk-DRTSPRWL.js → chunk-B4DXREWC.js} +2 -2
  41. package/dist/{chunk-5UEGL5BF.js → chunk-CI3MAAIY.js} +2 -2
  42. package/dist/{chunk-7JYK7NNY.js → chunk-CTH2CQCB.js} +2 -2
  43. package/dist/{chunk-UVHXLVFJ.js → chunk-EURPSF4Y.js} +2 -2
  44. package/dist/{chunk-VPEA63FC.js → chunk-EWJZZDRG.js} +2 -2
  45. package/dist/{chunk-BOZQAUAJ.js → chunk-EXQJMFRF.js} +2 -2
  46. package/dist/{chunk-UH6ROXFJ.js → chunk-GGD2YDS6.js} +2 -2
  47. package/dist/{chunk-5JABRT5G.js → chunk-GHHQLSSY.js} +3 -3
  48. package/dist/{chunk-N4T5FW6U.js → chunk-GJ4HU6LK.js} +2 -2
  49. package/dist/{chunk-HRRAOTOQ.js → chunk-H5DIRCZP.js} +12 -12
  50. package/dist/{chunk-434E4BTO.js → chunk-HH73M65H.js} +3 -3
  51. package/dist/{chunk-LIGZO7XN.js → chunk-HPB5TF55.js} +2 -2
  52. package/dist/{chunk-E4QIX2OB.js → chunk-IAGVM7EY.js} +2 -2
  53. package/dist/{chunk-YJQJRXUI.js → chunk-IG3EZ3TF.js} +2 -2
  54. package/dist/{chunk-KNMSD2MQ.js → chunk-IVUVQPTQ.js} +2 -2
  55. package/dist/{chunk-XOFIJGB2.js → chunk-IZBIDPMY.js} +2 -2
  56. package/dist/{chunk-HFEGRMFG.js → chunk-JLGBJ4AX.js} +7 -7
  57. package/dist/{chunk-BBHKN2CN.js → chunk-JLMOH5JA.js} +2 -2
  58. package/dist/{chunk-TPCRDPUY.js → chunk-KZSGBWOJ.js} +2 -2
  59. package/dist/{chunk-SHHZWSSE.js → chunk-KZZ5EZ7O.js} +2 -2
  60. package/dist/{chunk-2ZSRACNK.js → chunk-LFYEQK45.js} +682 -549
  61. package/dist/chunk-LFYEQK45.js.map +1 -0
  62. package/dist/{chunk-FKGLJJVS.js → chunk-LN4X2NDA.js} +2 -2
  63. package/dist/{chunk-5ZHFPUBC.js → chunk-MOIJWWNA.js} +8 -8
  64. package/dist/{chunk-KO224R36.js → chunk-NCZGGGWI.js} +2 -2
  65. package/dist/{chunk-XVQVF2TK.js → chunk-NHWDTOBN.js} +269 -230
  66. package/dist/chunk-NHWDTOBN.js.map +1 -0
  67. package/dist/{chunk-7ZTGMEXF.js → chunk-O5ZINWW3.js} +2 -2
  68. package/dist/{chunk-WWH7GJ42.js → chunk-OCK4D5PC.js} +2 -2
  69. package/dist/{chunk-G6L3REL3.js → chunk-OPFVXM2D.js} +2 -2
  70. package/dist/{chunk-FR7PK7ED.js → chunk-P4TV3TQ2.js} +2 -2
  71. package/dist/{chunk-P2EM5LKH.js → chunk-PMDOGXA2.js} +2 -2
  72. package/dist/{chunk-NM2II57L.js → chunk-PRKNAIGZ.js} +2 -1
  73. package/dist/chunk-PRKNAIGZ.js.map +1 -0
  74. package/dist/{chunk-MRF5V5CD.js → chunk-QGFR73SN.js} +2 -2
  75. package/dist/{chunk-6D3IWYNY.js → chunk-R2BIVU2Z.js} +354 -292
  76. package/dist/chunk-R2BIVU2Z.js.map +1 -0
  77. package/dist/{chunk-HG3CG557.js → chunk-RLVI2M5Q.js} +2 -2
  78. package/dist/{chunk-HHL4MX3W.js → chunk-SXTVZ6JL.js} +2 -2
  79. package/dist/{chunk-CLSKGTAT.js → chunk-TECK5IVY.js} +2 -2
  80. package/dist/{chunk-SNS3DWG6.js → chunk-TVE7W67N.js} +2 -2
  81. package/dist/{chunk-KYSSUY66.js → chunk-U2CAGMUD.js} +2 -2
  82. package/dist/{chunk-XLCEFUAU.js → chunk-UVVFPCPX.js} +2 -2
  83. package/dist/{chunk-KQJUEQ2E.js → chunk-UW6LBFYN.js} +5 -5
  84. package/dist/{chunk-SEA56G6S.js → chunk-V4XE263I.js} +1 -1
  85. package/dist/chunk-V4XE263I.js.map +1 -0
  86. package/dist/{chunk-JL5HXL25.js → chunk-V6SBA5O7.js} +3 -3
  87. package/dist/{chunk-R7WYAXPO.js → chunk-VHSQU3RO.js} +81 -42
  88. package/dist/chunk-VHSQU3RO.js.map +1 -0
  89. package/dist/{chunk-B7VD36TI.js → chunk-WHROVQ5Q.js} +3 -3
  90. package/dist/{chunk-LDHR465K.js → chunk-Y3XPSGNF.js} +2 -2
  91. package/dist/{chunk-JPY3RMG2.js → chunk-ZORS5BG5.js} +4 -4
  92. package/dist/{chunk-KXY4HW2Y.js → chunk-ZXF3O7P4.js} +2 -2
  93. package/dist/cli.js +47 -47
  94. package/dist/coding/export-okf-codegraph.js +2 -2
  95. package/dist/compounding/engine.js +4 -4
  96. package/dist/config.d.ts.map +1 -1
  97. package/dist/config.js +3 -3
  98. package/dist/connectors/codex-materialize-runner.js +3 -3
  99. package/dist/connectors/index.js +3 -3
  100. package/dist/convergence-refresh.js +5 -5
  101. package/dist/corpus-watermark.js +5 -5
  102. package/dist/entity-retrieval.js +3 -3
  103. package/dist/external-wiki-access.js +17 -17
  104. package/dist/extraction.js +6 -6
  105. package/dist/harmonic-retrieval.js +3 -3
  106. package/dist/index.js +61 -61
  107. package/dist/location/index.js +5 -5
  108. package/dist/maintenance/memory-governance.js +3 -3
  109. package/dist/maintenance/rebuild-memory-lifecycle-ledger.js +3 -3
  110. package/dist/maintenance/rebuild-memory-projection-runner.js +5 -5
  111. package/dist/maintenance/rebuild-memory-projection.js +4 -4
  112. package/dist/memory-browse-operations.js +17 -17
  113. package/dist/namespaces/migrate.js +4 -4
  114. package/dist/namespaces/storage.js +3 -3
  115. package/dist/operator-doctor-corpus.js +6 -6
  116. package/dist/operator-toolkit.js +12 -12
  117. package/dist/orchestration/buffer-drain-runner.js +61 -61
  118. package/dist/orchestration/consolidation-run.d.ts.map +1 -1
  119. package/dist/orchestration/extraction-drain.d.ts +4 -4
  120. package/dist/orchestration/extraction-drain.d.ts.map +1 -1
  121. package/dist/orchestration/extraction-persist-deps.d.ts +0 -2
  122. package/dist/orchestration/extraction-persist-deps.d.ts.map +1 -1
  123. package/dist/orchestration/extraction-persist.d.ts +10 -4
  124. package/dist/orchestration/extraction-persist.d.ts.map +1 -1
  125. package/dist/orchestration/extraction-queue-coordinator.d.ts +67 -33
  126. package/dist/orchestration/extraction-queue-coordinator.d.ts.map +1 -1
  127. package/dist/orchestration/extraction-run-helpers.d.ts +3 -2
  128. package/dist/orchestration/extraction-run-helpers.d.ts.map +1 -1
  129. package/dist/orchestration/extraction-run.d.ts +4 -14
  130. package/dist/orchestration/extraction-run.d.ts.map +1 -1
  131. package/dist/orchestration/fact-dedup-window.d.ts +49 -0
  132. package/dist/orchestration/fact-dedup-window.d.ts.map +1 -0
  133. package/dist/orchestration/maintenance.js +13 -13
  134. package/dist/orchestration/provider-breaker.d.ts +58 -0
  135. package/dist/orchestration/provider-breaker.d.ts.map +1 -0
  136. package/dist/orchestrator.d.ts +2 -17
  137. package/dist/orchestrator.d.ts.map +1 -1
  138. package/dist/orchestrator.js +61 -61
  139. package/dist/recall-explain-renderer.js +3 -3
  140. package/dist/recall-navigate-access.js +2 -2
  141. package/dist/recall-navigate-disclosure.js +2 -2
  142. package/dist/recall-navigate.js +2 -2
  143. package/dist/recall-navigation-operations.js +17 -17
  144. package/dist/recall-navigation.js +16 -16
  145. package/dist/recall-xray-cli.js +4 -4
  146. package/dist/recall-xray-renderer.js +3 -3
  147. package/dist/recall-xray.js +2 -2
  148. package/dist/resume-bundles.js +4 -4
  149. package/dist/semantic-consolidation.js +4 -4
  150. package/dist/semantic-rule-promotion.js +3 -3
  151. package/dist/semantic-rule-verifier.js +3 -3
  152. package/dist/source-agent-qualifier.js +5 -5
  153. package/dist/standup/build.js +3 -3
  154. package/dist/storage/profile-mutation.d.ts +25 -0
  155. package/dist/storage/profile-mutation.d.ts.map +1 -0
  156. package/dist/storage.d.ts +4 -6
  157. package/dist/storage.d.ts.map +1 -1
  158. package/dist/storage.js +2 -2
  159. package/dist/support-passport/index.js +19 -19
  160. package/dist/threading.d.ts +21 -4
  161. package/dist/threading.d.ts.map +1 -1
  162. package/dist/threading.js +1 -1
  163. package/dist/transfer/export-okf.js +3 -3
  164. package/dist/types.d.ts +2 -0
  165. package/dist/types.d.ts.map +1 -1
  166. package/dist/types.js +1 -1
  167. package/dist/verified-recall.js +3 -3
  168. package/dist/who-knows-cli.js +4 -4
  169. package/dist/who-knows.js +3 -3
  170. package/package.json +2 -2
  171. package/src/access-observe-replay-durability.test.ts +1 -1
  172. package/src/buffer-drain-handover.test.ts +3 -3
  173. package/src/buffer-drop-extraction-chunk.test.ts +0 -1
  174. package/src/config.test.ts +12 -0
  175. package/src/config.ts +2 -0
  176. package/src/connector-provenance-lifecycle.test.ts +5 -8
  177. package/src/fact-dedup-window.test.ts +116 -0
  178. package/src/faithfulness-graph-guard.test.ts +11 -6
  179. package/src/harmonic-construction.test.ts +16 -10
  180. package/src/orchestration/consolidation-profile-race.test.ts +178 -0
  181. package/src/orchestration/consolidation-run.ts +19 -7
  182. package/src/orchestration/extraction-drain.ts +10 -3
  183. package/src/orchestration/extraction-persist-deps.ts +0 -2
  184. package/src/orchestration/extraction-persist.ts +159 -148
  185. package/src/orchestration/extraction-queue-coordinator.ts +167 -56
  186. package/src/orchestration/extraction-run-helpers.ts +5 -4
  187. package/src/orchestration/extraction-run.test.ts +1 -2
  188. package/src/orchestration/extraction-run.ts +56 -99
  189. package/src/orchestration/fact-dedup-window.ts +122 -0
  190. package/src/orchestration/provider-breaker.test.ts +89 -0
  191. package/src/orchestration/provider-breaker.ts +158 -0
  192. package/src/orchestration/turn-ingestion.ts +5 -5
  193. package/src/orchestrator-extraction-queue.test.ts +179 -8
  194. package/src/orchestrator.ts +7 -23
  195. package/src/storage/profile-mutation.ts +119 -0
  196. package/src/storage-append-profile.test.ts +114 -0
  197. package/src/storage.ts +17 -48
  198. package/src/testing/orchestrator-lite.ts +1 -1
  199. package/src/threading.test.ts +153 -0
  200. package/src/threading.ts +95 -45
  201. package/src/types.ts +2 -0
  202. package/dist/chunk-2ZSRACNK.js.map +0 -1
  203. package/dist/chunk-6D3IWYNY.js.map +0 -1
  204. package/dist/chunk-NM2II57L.js.map +0 -1
  205. package/dist/chunk-R7WYAXPO.js.map +0 -1
  206. package/dist/chunk-SEA56G6S.js.map +0 -1
  207. package/dist/chunk-XVQVF2TK.js.map +0 -1
  208. /package/dist/{chunk-KISOMGY2.js.map → chunk-3RHU5MD7.js.map} +0 -0
  209. /package/dist/{chunk-3LSTL6EY.js.map → chunk-4MPAKYZU.js.map} +0 -0
  210. /package/dist/{chunk-OVL5AD3N.js.map → chunk-4NV7O76Q.js.map} +0 -0
  211. /package/dist/{chunk-BH7NVSEC.js.map → chunk-4SOWMA55.js.map} +0 -0
  212. /package/dist/{chunk-GROHOWR5.js.map → chunk-5MGKGEL7.js.map} +0 -0
  213. /package/dist/{chunk-ZPLQ27MY.js.map → chunk-5UM4H64E.js.map} +0 -0
  214. /package/dist/{chunk-2DNFRLLL.js.map → chunk-5Y6VA6X2.js.map} +0 -0
  215. /package/dist/{chunk-GRAUQXQB.js.map → chunk-6WKWL53V.js.map} +0 -0
  216. /package/dist/{chunk-HOR4RMUM.js.map → chunk-75DCOS6I.js.map} +0 -0
  217. /package/dist/{chunk-25QK3LR2.js.map → chunk-7O2MC42R.js.map} +0 -0
  218. /package/dist/{chunk-7ETWIBTO.js.map → chunk-ADYP7HXE.js.map} +0 -0
  219. /package/dist/{chunk-FUDKGJDN.js.map → chunk-AVGDYYMA.js.map} +0 -0
  220. /package/dist/{chunk-ECF627KC.js.map → chunk-AYJQ644F.js.map} +0 -0
  221. /package/dist/{chunk-T22FBDG3.js.map → chunk-B3MWELZ3.js.map} +0 -0
  222. /package/dist/{chunk-DRTSPRWL.js.map → chunk-B4DXREWC.js.map} +0 -0
  223. /package/dist/{chunk-5UEGL5BF.js.map → chunk-CI3MAAIY.js.map} +0 -0
  224. /package/dist/{chunk-7JYK7NNY.js.map → chunk-CTH2CQCB.js.map} +0 -0
  225. /package/dist/{chunk-UVHXLVFJ.js.map → chunk-EURPSF4Y.js.map} +0 -0
  226. /package/dist/{chunk-VPEA63FC.js.map → chunk-EWJZZDRG.js.map} +0 -0
  227. /package/dist/{chunk-BOZQAUAJ.js.map → chunk-EXQJMFRF.js.map} +0 -0
  228. /package/dist/{chunk-UH6ROXFJ.js.map → chunk-GGD2YDS6.js.map} +0 -0
  229. /package/dist/{chunk-5JABRT5G.js.map → chunk-GHHQLSSY.js.map} +0 -0
  230. /package/dist/{chunk-N4T5FW6U.js.map → chunk-GJ4HU6LK.js.map} +0 -0
  231. /package/dist/{chunk-HRRAOTOQ.js.map → chunk-H5DIRCZP.js.map} +0 -0
  232. /package/dist/{chunk-434E4BTO.js.map → chunk-HH73M65H.js.map} +0 -0
  233. /package/dist/{chunk-LIGZO7XN.js.map → chunk-HPB5TF55.js.map} +0 -0
  234. /package/dist/{chunk-E4QIX2OB.js.map → chunk-IAGVM7EY.js.map} +0 -0
  235. /package/dist/{chunk-YJQJRXUI.js.map → chunk-IG3EZ3TF.js.map} +0 -0
  236. /package/dist/{chunk-KNMSD2MQ.js.map → chunk-IVUVQPTQ.js.map} +0 -0
  237. /package/dist/{chunk-XOFIJGB2.js.map → chunk-IZBIDPMY.js.map} +0 -0
  238. /package/dist/{chunk-HFEGRMFG.js.map → chunk-JLGBJ4AX.js.map} +0 -0
  239. /package/dist/{chunk-BBHKN2CN.js.map → chunk-JLMOH5JA.js.map} +0 -0
  240. /package/dist/{chunk-TPCRDPUY.js.map → chunk-KZSGBWOJ.js.map} +0 -0
  241. /package/dist/{chunk-SHHZWSSE.js.map → chunk-KZZ5EZ7O.js.map} +0 -0
  242. /package/dist/{chunk-FKGLJJVS.js.map → chunk-LN4X2NDA.js.map} +0 -0
  243. /package/dist/{chunk-5ZHFPUBC.js.map → chunk-MOIJWWNA.js.map} +0 -0
  244. /package/dist/{chunk-KO224R36.js.map → chunk-NCZGGGWI.js.map} +0 -0
  245. /package/dist/{chunk-7ZTGMEXF.js.map → chunk-O5ZINWW3.js.map} +0 -0
  246. /package/dist/{chunk-WWH7GJ42.js.map → chunk-OCK4D5PC.js.map} +0 -0
  247. /package/dist/{chunk-G6L3REL3.js.map → chunk-OPFVXM2D.js.map} +0 -0
  248. /package/dist/{chunk-FR7PK7ED.js.map → chunk-P4TV3TQ2.js.map} +0 -0
  249. /package/dist/{chunk-P2EM5LKH.js.map → chunk-PMDOGXA2.js.map} +0 -0
  250. /package/dist/{chunk-MRF5V5CD.js.map → chunk-QGFR73SN.js.map} +0 -0
  251. /package/dist/{chunk-HG3CG557.js.map → chunk-RLVI2M5Q.js.map} +0 -0
  252. /package/dist/{chunk-HHL4MX3W.js.map → chunk-SXTVZ6JL.js.map} +0 -0
  253. /package/dist/{chunk-CLSKGTAT.js.map → chunk-TECK5IVY.js.map} +0 -0
  254. /package/dist/{chunk-SNS3DWG6.js.map → chunk-TVE7W67N.js.map} +0 -0
  255. /package/dist/{chunk-KYSSUY66.js.map → chunk-U2CAGMUD.js.map} +0 -0
  256. /package/dist/{chunk-XLCEFUAU.js.map → chunk-UVVFPCPX.js.map} +0 -0
  257. /package/dist/{chunk-KQJUEQ2E.js.map → chunk-UW6LBFYN.js.map} +0 -0
  258. /package/dist/{chunk-JL5HXL25.js.map → chunk-V6SBA5O7.js.map} +0 -0
  259. /package/dist/{chunk-B7VD36TI.js.map → chunk-WHROVQ5Q.js.map} +0 -0
  260. /package/dist/{chunk-LDHR465K.js.map → chunk-Y3XPSGNF.js.map} +0 -0
  261. /package/dist/{chunk-JPY3RMG2.js.map → chunk-ZORS5BG5.js.map} +0 -0
  262. /package/dist/{chunk-KXY4HW2Y.js.map → chunk-ZXF3O7P4.js.map} +0 -0
@@ -0,0 +1,122 @@
1
+ import path from "node:path";
2
+ import { serializeMutations, withHeldFileLock } from "../utils/serialize-mutations.js";
3
+ import { ContentHashIndex, type StorageManager } from "../index.js";
4
+ import type { MemoryFile } from "../types.js";
5
+ import { normalizeStoredHashSource } from "./extraction-persist-citation.js";
6
+ import { stripAttributesSuffix } from "../structured-attributes.js";
7
+ import { log } from "../logger.js";
8
+
9
+ /**
10
+ * Fact-dedup commit window (codex P2, #1526): with extractionQueueConcurrency
11
+ * > 1 (or daemon + external drain workers), two sessions can both pass the
12
+ * content-hash check at persist time and both write, producing duplicate
13
+ * active copies — the persist-time checks run well before the write, and
14
+ * writeMemory mints a fresh id per write. This module serializes the short
15
+ * "final recheck → write" window per storage dir (in-process chain +
16
+ * fail-closed cross-process fact-dedup.lock, mirroring the profile/meta lock
17
+ * shape: bounded, no LLM or corpus-scale work planned inside, throw BEFORE
18
+ * any read/write when the lock is not acquired). The persist-time check stays
19
+ * OUTSIDE the window exactly like the consolidation LLM call stays outside
20
+ * commitConsolidatedProfile.
21
+ */
22
+ export interface FactDedupProbe {
23
+ hasContentHashDedup(storage: StorageManager, key: string): Promise<boolean>;
24
+ }
25
+
26
+ interface FactDedupMatchOptions {
27
+ readonly category: string;
28
+ readonly contentHashDedupKey: string;
29
+ readonly connector?: string;
30
+ readonly citationEnabled: boolean;
31
+ readonly citationTemplate: string;
32
+ }
33
+
34
+ function matchesActiveConnectorDuplicate(memory: MemoryFile, opts: FactDedupMatchOptions): boolean {
35
+ if (memory.frontmatter.category !== opts.category) return false;
36
+ if ((memory.frontmatter.status ?? "active") !== "active") return false;
37
+ // Thread 5 (QPDE5): for procedures the hash is keyed on the full
38
+ // body (title + steps via buildProcedurePersistBody), so compare
39
+ // against contentHashDedupKey — not the canonical title-only form.
40
+ // Thread 3 (QO42V): facts with structuredAttributes get an appended
41
+ // [Attributes: ...] suffix in the stored body that the hash key lacks;
42
+ // strip it before comparing so same-connector enriched facts dedup
43
+ // correctly.
44
+ if (normalizeStoredHashSource(stripAttributesSuffix(memory.content ?? ""), opts.citationEnabled, opts.citationTemplate) !==
45
+ ContentHashIndex.normalizeContent(opts.contentHashDedupKey)) return false;
46
+ return (memory.frontmatter.sourceConnector?.trim() || undefined) === opts.connector;
47
+ }
48
+
49
+ /**
50
+ * Connector-aware duplicate confirm: same final semantics as the persist-time
51
+ * check — an authoritative index miss is a clean NOT-duplicate, a hash hit or
52
+ * a non-authoritative/failed lookup must be confirmed against the hot+cold
53
+ * corpus (same category, active, same normalized content, same connector),
54
+ * and a corpus-scan failure fails OPEN so an unverifiable state cannot
55
+ * silently drop content.
56
+ */
57
+ export async function confirmActiveFactDuplicate(
58
+ probe: FactDedupProbe,
59
+ storage: StorageManager,
60
+ opts: FactDedupMatchOptions,
61
+ ): Promise<boolean> {
62
+ let needsCorpusConfirm: boolean;
63
+ try {
64
+ if (!(await probe.hasContentHashDedup(storage, opts.contentHashDedupKey))) {
65
+ if (await storage.isFactContentHashAuthoritative()) return false;
66
+ needsCorpusConfirm = true;
67
+ } else {
68
+ needsCorpusConfirm = true;
69
+ }
70
+ } catch {
71
+ // Dedup lookup failed (e.g. the authoritative rebuild could not run). Do
72
+ // NOT fail-open into a possible duplicate — confirm against the corpus.
73
+ needsCorpusConfirm = true;
74
+ }
75
+ try {
76
+ // #2016 cold-tier finding: the authoritative content-hash rebuild unions
77
+ // the HOT and COLD tiers, so a cold-only active copy must still confirm
78
+ // the duplicate and suppress the redundant hot write.
79
+ const [hotMems, coldMems] = await Promise.all([
80
+ storage.readAllMemories(),
81
+ storage.readAllColdMemories(),
82
+ ]);
83
+ return hotMems.some((m) => matchesActiveConnectorDuplicate(m, opts)) ||
84
+ coldMems.some((m) => matchesActiveConnectorDuplicate(m, opts));
85
+ } catch (err) {
86
+ log.warn(
87
+ `connector-aware dedup scan failed for storage ${storage.dir}; writing fail-open: ${err instanceof Error ? err.message : String(err)}`,
88
+ );
89
+ return false;
90
+ }
91
+ }
92
+
93
+ export type FactDedupWindowOutcome<T> = { suppressed: true } | { suppressed: false; result: T };
94
+
95
+ /**
96
+ * Run `write` inside the per-dir fact-dedup window: recheck `recheck` (the
97
+ * SAME confirm used at persist time, now under the lock) and suppress as a
98
+ * duplicate when a concurrent session committed the same content first;
99
+ * otherwise write. Lock contention throws (fail closed) — never an unlocked
100
+ * write; the extraction retry path retains the accepted turns.
101
+ */
102
+ export async function persistUnderFactDedupWindow<T>(
103
+ storageDir: string,
104
+ recheck: () => Promise<boolean>,
105
+ write: () => Promise<T>,
106
+ ): Promise<FactDedupWindowOutcome<T>> {
107
+ return serializeMutations(`fact-dedup:${storageDir}`, () =>
108
+ withHeldFileLock(
109
+ path.join(storageDir, "fact-dedup.lock"),
110
+ // Bounded: the window holds one corpus confirm + one memory write, not a
111
+ // scan-length lock (catalog round 5 adoption note).
112
+ { staleMs: 5_000, maxWaitMs: 2_000, pollMs: 50 },
113
+ async (acquired) => {
114
+ if (!acquired) {
115
+ throw new Error(`fact write skipped: fact-dedup.lock not acquired (${storageDir})`);
116
+ }
117
+ if (await recheck()) return { suppressed: true };
118
+ return { suppressed: false, result: await write() };
119
+ },
120
+ ),
121
+ );
122
+ }
@@ -0,0 +1,89 @@
1
+ import test from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { ProviderCircuitBreaker } from "./provider-breaker.js";
4
+
5
+ const CONFIG = { failureThreshold: 3, cooldownMs: 5_000, authCooldownMs: 60_000 };
6
+
7
+ test("half-open probe ownership is single-flight under parallel callers", () => {
8
+ const breaker = new ProviderCircuitBreaker();
9
+ breaker.failure("provider_retryable", 1_000, CONFIG);
10
+ breaker.failure("provider_retryable", 1_100, CONFIG);
11
+ breaker.failure("provider_retryable", 1_200, CONFIG); // threshold → open
12
+ assert.deepEqual(breaker.acquire(1_300).suppressed, true, "open suppresses during cooldown");
13
+
14
+ // Cooldown expires: the FIRST caller takes the probe…
15
+ const probe = breaker.acquire(6_200);
16
+ assert.equal(probe.probe, true, "expired breaker hands one probe out");
17
+ // …siblings stay suppressed for as long as the probe is held — no wall-clock
18
+ // expiry, however long the provider attempt runs (timeouts + retries).
19
+ assert.deepEqual(breaker.acquire(600_000).suppressed, true, "a live probe keeps suppressing");
20
+ assert.deepEqual(breaker.acquire(3_600_000).suppressed, true, "no timer expiry while held");
21
+
22
+ // Probe attempt dies WITHOUT a terminal outcome (deadline/abort): explicit
23
+ // release lets the next attempt re-probe.
24
+ breaker.releaseProbe(probe.token);
25
+ const reprobe = breaker.acquire(3_600_001);
26
+ assert.equal(reprobe.probe, true, "released probe is re-acquired");
27
+ assert.deepEqual(breaker.acquire(3_600_002).suppressed, true, "re-probe is also single-flight");
28
+
29
+ // Terminal outcomes release ownership: the OWNER's failed probe re-opens
30
+ // immediately (cooldown to 6_500 + 5_000).
31
+ breaker.failure("provider_retryable", 6_500, CONFIG, reprobe.token);
32
+ assert.deepEqual(breaker.acquire(7_000).suppressed, true, "failed probe re-opens at once");
33
+ assert.deepEqual(breaker.acquire(12_000).suppressed, false, "cooldown expiry admits a fresh probe");
34
+ breaker.success();
35
+ assert.deepEqual(breaker.acquire(12_001).probe, false, "successful probe closes the breaker");
36
+ });
37
+
38
+ test("a forced attempt's failure never releases another attempt's live probe", () => {
39
+ const breaker = new ProviderCircuitBreaker();
40
+ breaker.failure("provider_retryable", 1_000, CONFIG);
41
+ breaker.failure("provider_retryable", 1_100, CONFIG);
42
+ breaker.failure("provider_retryable", 1_200, CONFIG); // threshold → open
43
+ const probe = breaker.acquire(6_200);
44
+ assert.equal(probe.probe, true, "ordinary attempt owns the probe");
45
+
46
+ // A FORCED attempt bypassed acquire, fails while the probe is in flight:
47
+ // it must not release the owner or masquerade as the probe result.
48
+ breaker.failure("provider_retryable", 6_300, CONFIG /* no token */);
49
+ assert.deepEqual(breaker.acquire(6_400), { suppressed: true, probe: false }, "probe stays single-flight");
50
+
51
+ // Only the owner's failure settles it: re-open at once.
52
+ breaker.failure("provider_retryable", 6_500, CONFIG, probe.token);
53
+ assert.deepEqual(breaker.acquire(6_600), { suppressed: true, probe: false }, "failed probe re-opens immediately");
54
+
55
+ // A released probe is re-acquired only via its own token path.
56
+ const fresh = breaker.acquire(12_000);
57
+ assert.equal(fresh.probe, true);
58
+ breaker.releaseProbe(fresh.token);
59
+ const reprobe = breaker.acquire(12_001);
60
+ assert.equal(reprobe.probe, true, "released probe is re-acquired by the next attempt");
61
+ // A non-owner release with a foreign token is a no-op.
62
+ breaker.releaseProbe({ __probeOwner: true });
63
+ assert.deepEqual(breaker.acquire(12_002), { suppressed: true, probe: false });
64
+ });
65
+
66
+ test("an unowned forced outcome neither trips the half-open threshold nor settles a held probe", () => {
67
+ const breaker = new ProviderCircuitBreaker();
68
+ breaker.failure("provider_retryable", 1_000, CONFIG);
69
+ breaker.failure("provider_retryable", 1_100, CONFIG);
70
+ breaker.failure("provider_retryable", 1_200, CONFIG); // threshold → open
71
+ const probe = breaker.acquire(6_200);
72
+ assert.equal(probe.probe, true, "ordinary attempt owns the probe");
73
+
74
+ // Forced failures bypass acquire; even crossing the threshold they must
75
+ // not reopen over a held probe (PRRT_kwDORJXyws6zxG6V).
76
+ breaker.failure("provider_retryable", 6_250, CONFIG /* no token */);
77
+ breaker.failure("provider_retryable", 6_300, CONFIG /* no token */);
78
+ breaker.failure("provider_retryable", 6_350, CONFIG /* no token */);
79
+ assert.deepEqual(breaker.acquire(6_400), { suppressed: true, probe: false }, "probe stays single-flight");
80
+
81
+ // A forced success without the owner token must not settle the probe or
82
+ // close the breaker mid-probe (PRRT_kwDORJXyws6zxG6u).
83
+ breaker.success();
84
+ assert.deepEqual(breaker.acquire(6_450), { suppressed: true, probe: false }, "probe survives an unowned success");
85
+
86
+ // The owner's success settles it for real.
87
+ breaker.success(probe.token);
88
+ assert.deepEqual(breaker.acquire(6_500).suppressed, false, "owner success closes the breaker");
89
+ });
@@ -0,0 +1,158 @@
1
+ import { log } from "../logger.js";
2
+ import type { ExtractionFailureClass } from "../types.js";
3
+
4
+ export interface ProviderBreakerConfig {
5
+ readonly failureThreshold: number;
6
+ readonly cooldownMs: number;
7
+ readonly authCooldownMs: number;
8
+ }
9
+
10
+ export interface ProbeOwnerToken {
11
+ readonly __probeOwner: true;
12
+ }
13
+
14
+ export interface ProviderBreakerSnapshot {
15
+ readonly state: "closed" | "open" | "half_open";
16
+ readonly openUntilMs: number;
17
+ readonly consecutiveFailures: number;
18
+ readonly lastReason: string;
19
+ }
20
+
21
+ export interface ProbeAcquisition {
22
+ /** True when the attempt is suppressed (breaker open, or the half-open
23
+ * probe is owned by another attempt). */
24
+ readonly suppressed: boolean;
25
+ /** True when THIS attempt owns the single half-open probe and must
26
+ * terminate it via {@link failure}/{@link success} (with its token), or
27
+ * release it via {@link releaseProbe} on any early exit (deadline, abort,
28
+ * throw). */
29
+ readonly probe: boolean;
30
+ /** Opaque ownership token; only the matching token settles or releases the
31
+ * probe, so attempts that bypass the gate (forced extractions) can never
32
+ * release another attempt's in-flight probe. */
33
+ readonly token?: ProbeOwnerToken;
34
+ }
35
+
36
+ /**
37
+ * Provider circuit breaker with single-flight half-open probe OWNERSHIP
38
+ * (codex P2 on #3311): under extractionQueueConcurrency > 1 several sessions
39
+ * can pass an expired `open` breaker in the same window, and a wall-clock
40
+ * reservation either expires under a live long provider attempt (600s
41
+ * timeouts) or pins the breaker when an attempt dies without settling. So the
42
+ * probe is OWNED, not timed: exactly one attempt holds it from the
43
+ * open→half_open flip until its terminal `failure()`/`success()`, and any
44
+ * early exit releases it explicitly. A released-but-unfinished probe lets the
45
+ * next attempt re-probe; a retry backoff still throttles that namespace.
46
+ */
47
+ export class ProviderCircuitBreaker {
48
+ private consecutiveFailures = 0;
49
+ private openUntilMs = 0;
50
+ private state: ProviderBreakerSnapshot["state"] = "closed";
51
+ private lastReason = "";
52
+ private probeHeld = false;
53
+ private probeToken: ProbeOwnerToken | null = null;
54
+
55
+ acquire(now: number): ProbeAcquisition {
56
+ if (now < this.openUntilMs) return { suppressed: true, probe: false };
57
+ if (this.state === "open") {
58
+ this.state = "half_open";
59
+ this.probeHeld = true;
60
+ const token: ProbeOwnerToken = { __probeOwner: true };
61
+ this.probeToken = token;
62
+ this.logTransition();
63
+ return { suppressed: false, probe: true, token }; // THIS attempt is the probe.
64
+ }
65
+ if (this.state === "half_open") {
66
+ if (this.probeHeld) return { suppressed: true, probe: false };
67
+ this.probeHeld = true; // previous owner released without settling; re-probe.
68
+ const token: ProbeOwnerToken = { __probeOwner: true };
69
+ this.probeToken = token;
70
+ return { suppressed: false, probe: true, token };
71
+ }
72
+ return { suppressed: false, probe: false };
73
+ }
74
+
75
+ /** Release an owned probe on an attempt that exits without a terminal
76
+ * outcome (deadline, abort, throw). Only the matching owner releases;
77
+ * idempotent; state stays half_open so the next attempt re-probes. */
78
+ releaseProbe(token?: ProbeOwnerToken): void {
79
+ if (token === undefined || this.probeToken !== token) return;
80
+ this.probeToken = null;
81
+ this.probeHeld = false;
82
+ }
83
+
84
+ failure(cls: ExtractionFailureClass, now: number, config: ProviderBreakerConfig, probeToken?: ProbeOwnerToken): void {
85
+ // Only the probe OWNER's failure settles the half-open probe: a failure
86
+ // from an attempt that bypassed acquire (forced extraction) must not
87
+ // release another attempt's in-flight probe or masquerade as its result.
88
+ const owned = probeToken !== undefined && this.probeToken === probeToken;
89
+ if (owned) {
90
+ this.probeToken = null;
91
+ this.probeHeld = false;
92
+ }
93
+ this.consecutiveFailures += 1;
94
+ const trip =
95
+ // An unowned auth_config failure (forced extraction) must not reopen
96
+ // over a held half-open probe — same ownership rule as the threshold
97
+ // term; the auth cooldown still applies once the probe settles.
98
+ (cls === "auth_config" && (this.state !== "half_open" || owned)) ||
99
+ // A failed half-open probe re-opens immediately (standard breaker
100
+ // semantics). Without this, an auth_config-opened breaker (which trips
101
+ // below the threshold) would get stuck half_open after a transient
102
+ // probe failure and stop suppressing (cursor review).
103
+ (owned && this.state === "half_open") ||
104
+ // PRRT_kwDORJXyws6zxG6V: an unowned (forced-extraction) failure must
105
+ // not cross the threshold while another attempt owns the half-open
106
+ // probe — reopening here would strand the owner's token and let the
107
+ // next cooldown mint a second concurrent probe. The failure count and
108
+ // retry bookkeeping still accumulate for when the state leaves
109
+ // half_open.
110
+ (this.state !== "half_open" && this.consecutiveFailures >= config.failureThreshold);
111
+ if (!trip) return;
112
+ const cooldown = cls === "auth_config" ? config.authCooldownMs : config.cooldownMs;
113
+ this.openUntilMs = now + Math.max(0, cooldown);
114
+ this.lastReason = cls;
115
+ if (this.state !== "open") {
116
+ this.state = "open";
117
+ this.logTransition();
118
+ }
119
+ }
120
+
121
+ success(probeToken?: ProbeOwnerToken): void {
122
+ const wasDisturbed =
123
+ this.state !== "closed" || this.consecutiveFailures > 0 || this.openUntilMs > 0;
124
+ if (!wasDisturbed) return;
125
+ // PRRT_kwDORJXyws6zxG6u: while a half-open probe is held, only its OWNER
126
+ // may settle it. An unowned (forced-extraction) success must not clear
127
+ // another run's probe or close the breaker mid-probe — the next ordinary
128
+ // acquisition could then start a second concurrent probe.
129
+ if (this.probeHeld && !(probeToken !== undefined && this.probeToken === probeToken)) return;
130
+ const wasOpen = this.state !== "closed";
131
+ this.probeToken = null;
132
+ this.probeHeld = false;
133
+ this.consecutiveFailures = 0;
134
+ this.openUntilMs = 0;
135
+ this.lastReason = "";
136
+ this.state = "closed";
137
+ if (wasOpen) this.logTransition();
138
+ }
139
+
140
+ snapshot(): ProviderBreakerSnapshot {
141
+ return {
142
+ state: this.state,
143
+ openUntilMs: this.openUntilMs,
144
+ consecutiveFailures: this.consecutiveFailures,
145
+ lastReason: this.lastReason,
146
+ };
147
+ }
148
+
149
+ private logTransition(): void {
150
+ // Once per state change (not per attempt) — AGENTS.md observability rule.
151
+ log.warn("extraction: provider circuit breaker transition", {
152
+ state: this.state,
153
+ consecutiveFailures: this.consecutiveFailures,
154
+ lastReason: this.lastReason,
155
+ openUntilMs: this.openUntilMs,
156
+ });
157
+ }
158
+ }
@@ -932,8 +932,8 @@ export class TurnIngestionCoordinator {
932
932
  }
933
933
  if (settled) return;
934
934
  const preemptable = options.preemptable === true && options.preemptRunning !== true;
935
- const accepted = this.deps.extractionQueueCoordinator.enqueue(async () => {
936
- await this.deps.extractionQueueCoordinator.runPreemptable(preemptable, options.abortSignal, async (abortSignal) => {
935
+ const accepted = this.deps.extractionQueueCoordinator.enqueue(async (preemptHandle) => {
936
+ await this.deps.extractionQueueCoordinator.runPreemptable(preemptHandle, preemptable, options.abortSignal, async (abortSignal) => {
937
937
  if (settled) return;
938
938
  if (
939
939
  typeof extractionDeadlineMs === "number" &&
@@ -1082,14 +1082,14 @@ export class TurnIngestionCoordinator {
1082
1082
  deadlineMs: extractionDeadlineMs,
1083
1083
  bufferKey,
1084
1084
  onDurableCommit: options.onDurableCommit,
1085
- onBeforePersist: () => this.deps.extractionQueueCoordinator.endPreemptable(),
1085
+ onBeforePersist: () => preemptHandle?.disarm(),
1086
1086
  abortSignal,
1087
1087
  failOnExtractionFailure: options.failOnExtractionFailure === true,
1088
1088
  writeNamespaceOverride: options.writeNamespaceOverride,
1089
1089
  principalOverride: options.principalOverride,
1090
1090
  scopeProfileWritePlan: effectiveScopePlan,
1091
1091
  forceExtractionAttempt: options.forceExtractionAttempt === true,
1092
- }, preemptable);
1092
+ }, preemptable, preemptHandle);
1093
1093
  settleTask(undefined, result);
1094
1094
  } catch (err) {
1095
1095
  const reason = abortSignal.reason;
@@ -1099,7 +1099,7 @@ export class TurnIngestionCoordinator {
1099
1099
  }
1100
1100
  }
1101
1101
  });
1102
- }, { front: options.preemptRunning === true });
1102
+ }, { front: options.preemptRunning === true, key: bufferKey });
1103
1103
  if (!accepted) {
1104
1104
  settleTask(new Error("extraction queue is stopped"));
1105
1105
  return;
@@ -1,7 +1,7 @@
1
1
  import assert from "node:assert/strict";
2
2
  import test from "node:test";
3
3
 
4
- import { ExtractionQueueCoordinator } from "./orchestration/extraction-queue-coordinator.js";
4
+ import { ExtractionQueueCoordinator, type ExtractionPreemptHandle, type ExtractionQueueTask } from "./orchestration/extraction-queue-coordinator.js";
5
5
  import { initLogger, type LoggerBackend } from "./logger.js";
6
6
  import { abortError } from "./abort-error.js";
7
7
 
@@ -227,17 +227,17 @@ test("a front enqueue aborts the running automatic task and runs before queued w
227
227
  const coordinator = createCoordinator();
228
228
  const order: string[] = [];
229
229
  const started = Promise.withResolvers<void>();
230
- coordinator.enqueue(async () => {
231
- const preempt = new AbortController();
232
- coordinator.beginPreemptable(() => preempt.abort());
230
+ coordinator.enqueue(async (preempt) => {
231
+ const controller = new AbortController();
232
+ preempt?.arm(() => controller.abort());
233
233
  started.resolve();
234
234
  try {
235
- await new Promise<void>((_resolve, reject) => {
236
- preempt.signal.addEventListener("abort", () => reject(abortError("preempted")), { once: true });
237
- });
235
+ const { promise, reject } = Promise.withResolvers<void>();
236
+ controller.signal.addEventListener("abort", () => reject(abortError("preempted")), { once: true });
237
+ await promise;
238
238
  order.push("automatic-finished");
239
239
  } finally {
240
- coordinator.endPreemptable();
240
+ preempt?.disarm();
241
241
  }
242
242
  });
243
243
  await started.promise;
@@ -270,3 +270,174 @@ test("a front enqueue does not abort a task that did not register as preemptable
270
270
  assert.deepEqual(order, ["protected", "flush"]);
271
271
  });
272
272
 
273
+
274
+ // ── Bounded-parallel queue (extractionQueueConcurrency) ─────────────────────
275
+
276
+ function createParallelCoordinator(limit: number): ExtractionQueueCoordinator {
277
+ return new ExtractionQueueCoordinator(limit);
278
+ }
279
+
280
+ /** A parked queue task with deterministic start/abort/release signals. */
281
+ function makeParkedTask(preemptable: boolean) {
282
+ const started = Promise.withResolvers<void>();
283
+ const release = Promise.withResolvers<void>();
284
+ let didStart = false;
285
+ let outcome: "parked" | "finished" | "aborted" = "parked";
286
+ const task: ExtractionQueueTask = async (preempt?: ExtractionPreemptHandle) => {
287
+ didStart = true;
288
+ const controller = new AbortController();
289
+ if (preemptable) {
290
+ preempt?.arm(() => controller.abort(abortError("preempted")));
291
+ controller.signal.addEventListener("abort", () => {
292
+ outcome = "aborted";
293
+ release.resolve();
294
+ }, { once: true });
295
+ }
296
+ started.resolve();
297
+ await release.promise;
298
+ if (outcome === "parked") outcome = "finished";
299
+ preempt?.disarm();
300
+ };
301
+ return {
302
+ task,
303
+ startedPromise: started.promise,
304
+ release: () => release.resolve(),
305
+ get started() {
306
+ return didStart;
307
+ },
308
+ get outcome() {
309
+ return outcome;
310
+ },
311
+ };
312
+ }
313
+
314
+ test("independent keys run concurrently up to the configured limit", async () => {
315
+ const coordinator = createParallelCoordinator(2);
316
+ const first = makeParkedTask(false);
317
+ const second = makeParkedTask(false);
318
+ const third = makeParkedTask(false);
319
+ coordinator.enqueue(first.task, { key: "session-a" });
320
+ coordinator.enqueue(second.task, { key: "session-b" });
321
+ coordinator.enqueue(third.task, { key: "session-c" });
322
+ await first.startedPromise;
323
+ await second.startedPromise;
324
+ assert.equal(third.started, false, "the third task must still be queued, not running: both slots are taken");
325
+ first.release();
326
+ await third.startedPromise;
327
+ second.release();
328
+ third.release();
329
+ assert.equal(await coordinator.waitForIdle(5_000), true);
330
+ assert.equal(first.outcome, "finished");
331
+ assert.equal(second.outcome, "finished");
332
+ assert.equal(third.outcome, "finished");
333
+ assert.equal(coordinator.isProcessing, false);
334
+ });
335
+
336
+ test("tasks sharing a buffer key never run concurrently even with free capacity", async () => {
337
+ const coordinator = createParallelCoordinator(2);
338
+ const busy = makeParkedTask(false);
339
+ const queuedSameKey = makeParkedTask(false);
340
+ const otherKey = makeParkedTask(false);
341
+ coordinator.enqueue(busy.task, { key: "s1" });
342
+ await busy.startedPromise;
343
+ coordinator.enqueue(queuedSameKey.task, { key: "s1" });
344
+ coordinator.enqueue(otherKey.task, { key: "s2" });
345
+ await otherKey.startedPromise;
346
+ assert.equal(queuedSameKey.started, false, "the busy session's next task must not have started: its own key is still busy");
347
+ busy.release();
348
+ await queuedSameKey.startedPromise;
349
+ queuedSameKey.release();
350
+ otherKey.release();
351
+ assert.equal(await coordinator.waitForIdle(5_000), true);
352
+ assert.equal(queuedSameKey.outcome, "finished");
353
+ assert.equal(otherKey.outcome, "finished");
354
+ });
355
+
356
+ test("the default limit stays serial", async () => {
357
+ const coordinator = createCoordinator();
358
+ assert.equal(coordinator.maxConcurrency, 1);
359
+ const first = makeParkedTask(false);
360
+ const second = makeParkedTask(false);
361
+ coordinator.enqueue(first.task, { key: "s1" });
362
+ coordinator.enqueue(second.task, { key: "s2" });
363
+ await first.startedPromise;
364
+ assert.equal(second.started, false, "limit 1 never starts a second task concurrently");
365
+ first.release();
366
+ await second.startedPromise;
367
+ second.release();
368
+ assert.equal(await coordinator.waitForIdle(5_000), true);
369
+ assert.equal(second.outcome, "finished");
370
+ });
371
+
372
+ test("a failing task does not stall the queue", async () => {
373
+ const { entries } = installCapturingLogger();
374
+ const coordinator = createParallelCoordinator(2);
375
+ const parked = makeParkedTask(false);
376
+ coordinator.enqueue(async () => {
377
+ throw new Error("extraction exploded");
378
+ }, { key: "s1" });
379
+ coordinator.enqueue(parked.task, { key: "s2" });
380
+ await parked.startedPromise;
381
+ parked.release();
382
+ assert.equal(await coordinator.waitForIdle(5_000), true);
383
+ assert.equal(parked.outcome, "finished");
384
+ assert.ok(entries.some((entry) => entry.level === "error" && /background extraction task failed/.test(entry.message)));
385
+ });
386
+
387
+ test("a front enqueue aborts exactly one armed running task when two run", async () => {
388
+ const coordinator = createParallelCoordinator(2);
389
+ const first = makeParkedTask(true);
390
+ const second = makeParkedTask(true);
391
+ let flushRan = false;
392
+ coordinator.enqueue(first.task, { key: "s1" });
393
+ coordinator.enqueue(second.task, { key: "s2" });
394
+ await first.startedPromise;
395
+ await second.startedPromise;
396
+ coordinator.enqueue(async () => {
397
+ flushRan = true;
398
+ }, { front: true });
399
+ assert.equal(second.outcome, "aborted", "the most recently armed task is the one preempted");
400
+ assert.equal(first.outcome, "parked", "the unrelated running extraction is not cancelled");
401
+ first.release();
402
+ assert.equal(await coordinator.waitForIdle(5_000), true);
403
+ assert.equal(flushRan, true);
404
+ assert.equal(first.outcome, "finished");
405
+ });
406
+
407
+ test("a front enqueue prefers the requested key's own running task", async () => {
408
+ const coordinator = createParallelCoordinator(2);
409
+ const other = makeParkedTask(true);
410
+ const mine = makeParkedTask(true);
411
+ let flushRan = false;
412
+ coordinator.enqueue(other.task, { key: "s-other" });
413
+ coordinator.enqueue(mine.task, { key: "s-mine" });
414
+ await other.startedPromise;
415
+ await mine.startedPromise;
416
+ coordinator.enqueue(async () => {
417
+ flushRan = true;
418
+ }, { front: true, key: "s-mine" });
419
+ assert.equal(mine.outcome, "aborted", "the flush preempts its own session's running extraction");
420
+ assert.equal(other.outcome, "parked", "the unrelated running extraction keeps going");
421
+ other.release();
422
+ assert.equal(await coordinator.waitForIdle(5_000), true);
423
+ assert.equal(flushRan, true);
424
+ assert.equal(other.outcome, "finished");
425
+ });
426
+
427
+ test("pauseAndDrain waits for concurrent accepted work and rejects new producers", async () => {
428
+ const coordinator = createParallelCoordinator(2);
429
+ const first = makeParkedTask(false);
430
+ const second = makeParkedTask(false);
431
+ coordinator.enqueue(first.task, { key: "s1" });
432
+ coordinator.enqueue(second.task, { key: "s2" });
433
+ await first.startedPromise;
434
+ await second.startedPromise;
435
+ const drain = coordinator.pauseAndDrain(5_000);
436
+ assert.equal(coordinator.enqueue(async () => {}, { key: "s3" }), false);
437
+ first.release();
438
+ second.release();
439
+ assert.equal(await drain, true);
440
+ assert.equal(first.outcome, "finished");
441
+ assert.equal(second.outcome, "finished");
442
+ assert.equal(coordinator.isProcessing, false);
443
+ });