@remnic/core 10.0.70 → 10.0.72

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 (231) hide show
  1. package/dist/access-admin-ops-surface.js +14 -14
  2. package/dist/access-authorization-probe.js +15 -15
  3. package/dist/access-boundary.js +14 -14
  4. package/dist/access-cli.js +58 -58
  5. package/dist/access-extraction-force-flush.js +14 -14
  6. package/dist/access-http-briefing.js +15 -15
  7. package/dist/access-http-query.js +16 -16
  8. package/dist/access-http.js +31 -31
  9. package/dist/access-identity-continuity-surface.js +14 -14
  10. package/dist/access-lcm-surface.js +14 -14
  11. package/dist/access-mcp.js +24 -24
  12. package/dist/access-namespace-preflight.js +14 -14
  13. package/dist/access-observe-write-surface.js +14 -14
  14. package/dist/access-operations-batch.js +16 -16
  15. package/dist/access-operations.js +23 -23
  16. package/dist/access-recall-concurrency.js +14 -14
  17. package/dist/access-recall-response.js +14 -14
  18. package/dist/access-recall-surface.js +14 -14
  19. package/dist/access-service.js +14 -14
  20. package/dist/access-surface-catalog.js +16 -16
  21. package/dist/active-recall.js +3 -3
  22. package/dist/briefing.js +4 -4
  23. package/dist/buffer-turn-helpers.d.ts +10 -1
  24. package/dist/buffer-turn-helpers.d.ts.map +1 -1
  25. package/dist/buffer-turn-helpers.js +3 -1
  26. package/dist/buffer.d.ts +4 -0
  27. package/dist/buffer.d.ts.map +1 -1
  28. package/dist/buffer.js +3 -3
  29. package/dist/causal-consolidation.js +5 -5
  30. package/dist/{chunk-B7YAIHAV.js → chunk-23BA3USG.js} +10 -10
  31. package/dist/{chunk-3F7N4I5I.js → chunk-2R44BBPK.js} +2 -2
  32. package/dist/{chunk-BW33YTF3.js → chunk-2WPWEVAH.js} +2 -2
  33. package/dist/{chunk-EF3HNVL4.js → chunk-4PMFSYH7.js} +2 -2
  34. package/dist/{chunk-J5TVHCDG.js → chunk-5ORJHH43.js} +2 -2
  35. package/dist/{chunk-MK7HXGM6.js → chunk-6KBCUIZP.js} +2 -2
  36. package/dist/{chunk-OX6GY22Q.js → chunk-7P5VBRBS.js} +2 -2
  37. package/dist/{chunk-3AASGFR4.js → chunk-7WFY2L5J.js} +3 -3
  38. package/dist/{chunk-YLMTFRZR.js → chunk-AGRMXXVA.js} +2 -2
  39. package/dist/{chunk-WIW4LOZL.js → chunk-AZNE2H6D.js} +2 -2
  40. package/dist/{chunk-GT4G2S2Y.js → chunk-BCBY5GYZ.js} +2 -2
  41. package/dist/{chunk-EIZ4MK33.js → chunk-BTU77RB5.js} +2 -2
  42. package/dist/{chunk-ZS5LREUZ.js → chunk-DC3DWF2M.js} +2 -2
  43. package/dist/{chunk-LTST4R7U.js → chunk-DE4U5PTV.js} +49 -45
  44. package/dist/chunk-DE4U5PTV.js.map +1 -0
  45. package/dist/{chunk-OAQO5AZJ.js → chunk-DSSTOV5M.js} +440 -267
  46. package/dist/chunk-DSSTOV5M.js.map +1 -0
  47. package/dist/{chunk-VDYRXI3E.js → chunk-EGKDONTC.js} +9 -3
  48. package/dist/chunk-EGKDONTC.js.map +1 -0
  49. package/dist/{chunk-FM6UKZAE.js → chunk-EQ6EMJRG.js} +2 -2
  50. package/dist/{chunk-TSZP7GGJ.js → chunk-FGGLVCHI.js} +2 -2
  51. package/dist/{chunk-K7LLGCPH.js → chunk-GXAZ4Q2S.js} +2 -2
  52. package/dist/{chunk-2GV24KFD.js → chunk-H2X32F2K.js} +2 -2
  53. package/dist/{chunk-2YPIFJHL.js → chunk-ICBUTKLL.js} +2 -2
  54. package/dist/{chunk-3W37NITP.js → chunk-IQXQ3KWR.js} +2 -2
  55. package/dist/{chunk-HNF7IMYQ.js → chunk-J72ZY7NU.js} +2 -2
  56. package/dist/{chunk-6QS5NQJH.js → chunk-JGLZ46JS.js} +2 -2
  57. package/dist/{chunk-MRN2MKB7.js → chunk-JY4LXMZR.js} +2 -2
  58. package/dist/{chunk-UJ2FP2RV.js → chunk-KY3KJUNN.js} +2 -2
  59. package/dist/{chunk-64U42JL5.js → chunk-LHMCZ6VW.js} +10 -10
  60. package/dist/{chunk-YRMSHA7H.js → chunk-LN7CWLAU.js} +2 -2
  61. package/dist/{chunk-XR4QR3JT.js → chunk-LYOCAUI6.js} +3 -3
  62. package/dist/{chunk-VA3GNAWQ.js → chunk-LZIOY7ZU.js} +4 -5
  63. package/dist/chunk-LZIOY7ZU.js.map +1 -0
  64. package/dist/{chunk-GMSRLDVR.js → chunk-N2Y6COEM.js} +2 -2
  65. package/dist/{chunk-XDUP6SWG.js → chunk-OGQGDOYK.js} +2 -2
  66. package/dist/{chunk-U7MIMS2Y.js → chunk-OUBEJWVI.js} +17 -1
  67. package/dist/chunk-OUBEJWVI.js.map +1 -0
  68. package/dist/{chunk-5FMGPV34.js → chunk-OWZXDBV4.js} +2 -2
  69. package/dist/{chunk-SQO5SEKA.js → chunk-PJAMFIDE.js} +3 -3
  70. package/dist/{chunk-I6HRHJRA.js → chunk-QIOQI7QZ.js} +2 -2
  71. package/dist/{chunk-OAKOAOEG.js → chunk-QUPTDKSX.js} +22 -22
  72. package/dist/{chunk-MCFI6G7U.js → chunk-QVZZE6VH.js} +3 -3
  73. package/dist/{chunk-LYTKNK5O.js → chunk-S37O4S6E.js} +2 -2
  74. package/dist/{chunk-ILD7N3XC.js → chunk-S73ZVU37.js} +2 -2
  75. package/dist/{chunk-5VQSKBNF.js → chunk-U6IL4724.js} +2 -2
  76. package/dist/{chunk-D3AGBN6G.js → chunk-UAOJVQXC.js} +2 -2
  77. package/dist/{chunk-MKMIY3E7.js → chunk-UE5JYPU6.js} +7 -7
  78. package/dist/{chunk-K47G2CBP.js → chunk-USX5YIKB.js} +5 -5
  79. package/dist/{chunk-YIG4HPY4.js → chunk-VK5HKVQ7.js} +2 -2
  80. package/dist/{chunk-ZOBYTRBU.js → chunk-VNTQU6KB.js} +47 -2
  81. package/dist/chunk-VNTQU6KB.js.map +1 -0
  82. package/dist/{chunk-THFSCUFC.js → chunk-VSJ3ARFS.js} +2 -2
  83. package/dist/{chunk-4BOBW6H4.js → chunk-W4V7P5HP.js} +2 -2
  84. package/dist/{chunk-DM3UZUCN.js → chunk-WNQ3B3Q5.js} +2 -2
  85. package/dist/{chunk-MFKBYXBY.js → chunk-WYOHQJKC.js} +6 -6
  86. package/dist/{chunk-66PEACZP.js → chunk-X4DHAXFO.js} +3 -3
  87. package/dist/{chunk-ZYEWTVNQ.js → chunk-XPZM7GP6.js} +7 -7
  88. package/dist/{chunk-2BBMCMJS.js → chunk-YPYSC3TX.js} +2 -2
  89. package/dist/{chunk-JGQEF33K.js → chunk-ZBET3W26.js} +2 -2
  90. package/dist/chunk-ZODKV27J.js +443 -0
  91. package/dist/chunk-ZODKV27J.js.map +1 -0
  92. package/dist/cli.js +43 -43
  93. package/dist/coding/export-okf-codegraph.js +3 -3
  94. package/dist/compounding/engine.js +5 -5
  95. package/dist/config.js +3 -3
  96. package/dist/connectors/codex-materialize-runner.js +4 -4
  97. package/dist/connectors/index.js +4 -4
  98. package/dist/convergence-refresh.js +6 -6
  99. package/dist/corpus-watermark.js +6 -6
  100. package/dist/entity-retrieval.js +4 -4
  101. package/dist/external-wiki-access.js +15 -15
  102. package/dist/extraction-window-drain.d.ts.map +1 -1
  103. package/dist/extraction-window-drain.js +1 -1
  104. package/dist/extraction.js +7 -7
  105. package/dist/harmonic-retrieval.js +4 -4
  106. package/dist/index.js +58 -58
  107. package/dist/location/index.js +6 -6
  108. package/dist/maintenance/memory-governance.js +4 -4
  109. package/dist/maintenance/rebuild-memory-lifecycle-ledger.js +4 -4
  110. package/dist/maintenance/rebuild-memory-projection-runner.js +6 -6
  111. package/dist/maintenance/rebuild-memory-projection.js +5 -5
  112. package/dist/memory-browse-operations.js +15 -15
  113. package/dist/namespaces/migrate.js +5 -5
  114. package/dist/namespaces/storage.js +4 -4
  115. package/dist/operator-doctor-corpus.js +7 -7
  116. package/dist/operator-toolkit.js +12 -12
  117. package/dist/orchestration/backlog-scheduler.d.ts +151 -0
  118. package/dist/orchestration/backlog-scheduler.d.ts.map +1 -0
  119. package/dist/orchestration/backlog-scheduler.js +891 -0
  120. package/dist/orchestration/backlog-scheduler.js.map +1 -0
  121. package/dist/orchestration/buffer-drain-runner.d.ts +31 -27
  122. package/dist/orchestration/buffer-drain-runner.d.ts.map +1 -1
  123. package/dist/orchestration/buffer-drain-runner.js +64 -269
  124. package/dist/orchestration/buffer-drain-runner.js.map +1 -1
  125. package/dist/orchestration/extraction-drain.d.ts +50 -17
  126. package/dist/orchestration/extraction-drain.d.ts.map +1 -1
  127. package/dist/orchestration/extraction-queue-coordinator.d.ts +19 -1
  128. package/dist/orchestration/extraction-queue-coordinator.d.ts.map +1 -1
  129. package/dist/orchestration/extraction-run.d.ts +8 -0
  130. package/dist/orchestration/extraction-run.d.ts.map +1 -1
  131. package/dist/orchestration/extraction-task-queueing.d.ts +44 -0
  132. package/dist/orchestration/extraction-task-queueing.d.ts.map +1 -0
  133. package/dist/orchestration/maintenance.js +14 -14
  134. package/dist/orchestration/turn-ingestion.d.ts +11 -5
  135. package/dist/orchestration/turn-ingestion.d.ts.map +1 -1
  136. package/dist/orchestrator.d.ts +13 -0
  137. package/dist/orchestrator.d.ts.map +1 -1
  138. package/dist/orchestrator.js +59 -59
  139. package/dist/recall-navigation-operations.js +15 -15
  140. package/dist/recall-navigation.js +14 -14
  141. package/dist/resume-bundles.js +3 -3
  142. package/dist/semantic-consolidation.js +5 -5
  143. package/dist/semantic-rule-promotion.js +4 -4
  144. package/dist/semantic-rule-verifier.js +4 -4
  145. package/dist/source-agent-qualifier.js +6 -6
  146. package/dist/standup/build.js +4 -4
  147. package/dist/storage/buffer-drain-claims.d.ts +15 -0
  148. package/dist/storage/buffer-drain-claims.d.ts.map +1 -1
  149. package/dist/storage/buffer-drain-claims.js +2 -2
  150. package/dist/storage/buffer-session-files.js +2 -2
  151. package/dist/storage.js +3 -3
  152. package/dist/support-passport/index.js +17 -17
  153. package/dist/transfer/export-okf.js +4 -4
  154. package/dist/verified-recall.js +4 -4
  155. package/dist/who-knows-cli.js +5 -5
  156. package/dist/who-knows.js +4 -4
  157. package/package.json +12 -2
  158. package/src/backlog-scheduler.test.ts +806 -0
  159. package/src/buffer-drain-handover.test.ts +363 -18
  160. package/src/buffer-drain-inprocess.test.ts +355 -0
  161. package/src/buffer-drain-model-boundary.test.ts +1062 -0
  162. package/src/buffer-drop-extraction-chunk.test.ts +237 -22
  163. package/src/buffer-turn-helpers.ts +26 -1
  164. package/src/buffer.ts +8 -0
  165. package/src/extraction-window-drain.ts +7 -7
  166. package/src/orchestration/backlog-scheduler.ts +481 -0
  167. package/src/orchestration/buffer-drain-runner.ts +545 -180
  168. package/src/orchestration/extraction-drain.ts +287 -108
  169. package/src/orchestration/extraction-queue-coordinator.ts +51 -3
  170. package/src/orchestration/extraction-run.test.ts +41 -0
  171. package/src/orchestration/extraction-run.ts +24 -5
  172. package/src/orchestration/extraction-task-queueing.ts +87 -0
  173. package/src/orchestration/turn-ingestion.ts +46 -59
  174. package/src/orchestrator-extraction-queue.test.ts +46 -2
  175. package/src/orchestrator-flush.test.ts +4 -1
  176. package/src/orchestrator.ts +47 -21
  177. package/src/storage/buffer-drain-claims.ts +55 -0
  178. package/dist/chunk-LTST4R7U.js.map +0 -1
  179. package/dist/chunk-OAQO5AZJ.js.map +0 -1
  180. package/dist/chunk-U7MIMS2Y.js.map +0 -1
  181. package/dist/chunk-VA3GNAWQ.js.map +0 -1
  182. package/dist/chunk-VDYRXI3E.js.map +0 -1
  183. package/dist/chunk-ZOBYTRBU.js.map +0 -1
  184. /package/dist/{chunk-B7YAIHAV.js.map → chunk-23BA3USG.js.map} +0 -0
  185. /package/dist/{chunk-3F7N4I5I.js.map → chunk-2R44BBPK.js.map} +0 -0
  186. /package/dist/{chunk-BW33YTF3.js.map → chunk-2WPWEVAH.js.map} +0 -0
  187. /package/dist/{chunk-EF3HNVL4.js.map → chunk-4PMFSYH7.js.map} +0 -0
  188. /package/dist/{chunk-J5TVHCDG.js.map → chunk-5ORJHH43.js.map} +0 -0
  189. /package/dist/{chunk-MK7HXGM6.js.map → chunk-6KBCUIZP.js.map} +0 -0
  190. /package/dist/{chunk-OX6GY22Q.js.map → chunk-7P5VBRBS.js.map} +0 -0
  191. /package/dist/{chunk-3AASGFR4.js.map → chunk-7WFY2L5J.js.map} +0 -0
  192. /package/dist/{chunk-YLMTFRZR.js.map → chunk-AGRMXXVA.js.map} +0 -0
  193. /package/dist/{chunk-WIW4LOZL.js.map → chunk-AZNE2H6D.js.map} +0 -0
  194. /package/dist/{chunk-GT4G2S2Y.js.map → chunk-BCBY5GYZ.js.map} +0 -0
  195. /package/dist/{chunk-EIZ4MK33.js.map → chunk-BTU77RB5.js.map} +0 -0
  196. /package/dist/{chunk-ZS5LREUZ.js.map → chunk-DC3DWF2M.js.map} +0 -0
  197. /package/dist/{chunk-FM6UKZAE.js.map → chunk-EQ6EMJRG.js.map} +0 -0
  198. /package/dist/{chunk-TSZP7GGJ.js.map → chunk-FGGLVCHI.js.map} +0 -0
  199. /package/dist/{chunk-K7LLGCPH.js.map → chunk-GXAZ4Q2S.js.map} +0 -0
  200. /package/dist/{chunk-2GV24KFD.js.map → chunk-H2X32F2K.js.map} +0 -0
  201. /package/dist/{chunk-2YPIFJHL.js.map → chunk-ICBUTKLL.js.map} +0 -0
  202. /package/dist/{chunk-3W37NITP.js.map → chunk-IQXQ3KWR.js.map} +0 -0
  203. /package/dist/{chunk-HNF7IMYQ.js.map → chunk-J72ZY7NU.js.map} +0 -0
  204. /package/dist/{chunk-6QS5NQJH.js.map → chunk-JGLZ46JS.js.map} +0 -0
  205. /package/dist/{chunk-MRN2MKB7.js.map → chunk-JY4LXMZR.js.map} +0 -0
  206. /package/dist/{chunk-UJ2FP2RV.js.map → chunk-KY3KJUNN.js.map} +0 -0
  207. /package/dist/{chunk-64U42JL5.js.map → chunk-LHMCZ6VW.js.map} +0 -0
  208. /package/dist/{chunk-YRMSHA7H.js.map → chunk-LN7CWLAU.js.map} +0 -0
  209. /package/dist/{chunk-XR4QR3JT.js.map → chunk-LYOCAUI6.js.map} +0 -0
  210. /package/dist/{chunk-GMSRLDVR.js.map → chunk-N2Y6COEM.js.map} +0 -0
  211. /package/dist/{chunk-XDUP6SWG.js.map → chunk-OGQGDOYK.js.map} +0 -0
  212. /package/dist/{chunk-5FMGPV34.js.map → chunk-OWZXDBV4.js.map} +0 -0
  213. /package/dist/{chunk-SQO5SEKA.js.map → chunk-PJAMFIDE.js.map} +0 -0
  214. /package/dist/{chunk-I6HRHJRA.js.map → chunk-QIOQI7QZ.js.map} +0 -0
  215. /package/dist/{chunk-OAKOAOEG.js.map → chunk-QUPTDKSX.js.map} +0 -0
  216. /package/dist/{chunk-MCFI6G7U.js.map → chunk-QVZZE6VH.js.map} +0 -0
  217. /package/dist/{chunk-LYTKNK5O.js.map → chunk-S37O4S6E.js.map} +0 -0
  218. /package/dist/{chunk-ILD7N3XC.js.map → chunk-S73ZVU37.js.map} +0 -0
  219. /package/dist/{chunk-5VQSKBNF.js.map → chunk-U6IL4724.js.map} +0 -0
  220. /package/dist/{chunk-D3AGBN6G.js.map → chunk-UAOJVQXC.js.map} +0 -0
  221. /package/dist/{chunk-MKMIY3E7.js.map → chunk-UE5JYPU6.js.map} +0 -0
  222. /package/dist/{chunk-K47G2CBP.js.map → chunk-USX5YIKB.js.map} +0 -0
  223. /package/dist/{chunk-YIG4HPY4.js.map → chunk-VK5HKVQ7.js.map} +0 -0
  224. /package/dist/{chunk-THFSCUFC.js.map → chunk-VSJ3ARFS.js.map} +0 -0
  225. /package/dist/{chunk-4BOBW6H4.js.map → chunk-W4V7P5HP.js.map} +0 -0
  226. /package/dist/{chunk-DM3UZUCN.js.map → chunk-WNQ3B3Q5.js.map} +0 -0
  227. /package/dist/{chunk-MFKBYXBY.js.map → chunk-WYOHQJKC.js.map} +0 -0
  228. /package/dist/{chunk-66PEACZP.js.map → chunk-X4DHAXFO.js.map} +0 -0
  229. /package/dist/{chunk-ZYEWTVNQ.js.map → chunk-XPZM7GP6.js.map} +0 -0
  230. /package/dist/{chunk-2BBMCMJS.js.map → chunk-YPYSC3TX.js.map} +0 -0
  231. /package/dist/{chunk-JGQEF33K.js.map → chunk-ZBET3W26.js.map} +0 -0
@@ -0,0 +1,1062 @@
1
+ // Model-boundary integration tests for the claim drain: a REAL loopback
2
+ // OpenAI-compatible model boundary stands in for the extraction model so the
3
+ // ACTUAL chat input (renderer + truncation included) is what gets asserted —
4
+ // no extraction seam swap, no stub bookkeeping.
5
+ //
6
+ // 1. Large pinned accepted claim group spanning the configured window
7
+ // budget: every source marker (early/middle/last) must appear in the
8
+ // actual chat inputs and every request must respect the budget the
9
+ // drain chunked on — no truncate-and-ack.
10
+ // 2. Legacy unpinned native claim groups under an ACTIVE scope profile
11
+ // whose write layer is unwritable: the CURRENT profile gate must refuse
12
+ // the write (a flattened plan=null would bypass it and persist) and the
13
+ // canonical turns stay claimed.
14
+ // 3. A malformed/null extraction result is a retained failure: the claim's
15
+ // canonical turns survive repeated attempts above the drop threshold.
16
+ // 4. An unpinned legacy drain under an ACTIVE profile that IS writable: the
17
+ // fact lands in the profile's write namespace. Flattening the group to
18
+ // plan=null bypasses the profile gate and persists it in the
19
+ // principal's default namespace instead.
20
+ import assert from "node:assert/strict";
21
+ import { mkdtemp, readFile, readdir, rm } from "node:fs/promises";
22
+ import http from "node:http";
23
+ import os from "node:os";
24
+ import path from "node:path";
25
+ import test from "node:test";
26
+
27
+ import { Orchestrator, parseConfig } from "./index.js";
28
+ import { namespaceIdentityToken } from "./namespaces/identity.js";
29
+ import { resolveScopeProfilePlan } from "./namespaces/scope-profiles.js";
30
+ import { BufferDrainRunner } from "./orchestration/buffer-drain-runner.js";
31
+ import type { ExtractionRunResult } from "./orchestration/extraction-run.js";
32
+ import type { BufferTurn, PluginConfig } from "./types.js";
33
+
34
+ interface ModelBoundary {
35
+ url: string;
36
+ /** Prompt strings actually sent as the extraction user input. */
37
+ prompts: string[];
38
+ close(): Promise<void>;
39
+ }
40
+
41
+ /** Loopback OpenAI-compatible boundary: records every completion prompt. */
42
+ async function startModelBoundary(): Promise<ModelBoundary> {
43
+ const prompts: string[] = [];
44
+ const server = http.createServer((req, res) => {
45
+ let raw = "";
46
+ req.on("data", (chunk: Buffer) => {
47
+ raw += chunk;
48
+ });
49
+ req.on("end", () => {
50
+ if (req.method === "POST") {
51
+ try {
52
+ // SAFETY: the loopback model posts OpenAI-compatible JSON; the shape
53
+ // named here is the only part the probe reads.
54
+ const parsed = JSON.parse(raw) as { messages?: { role?: string; content?: string }[] };
55
+ const user =
56
+ parsed.messages
57
+ ?.filter((m) => m.role === "user")
58
+ .map((m) => m.content ?? "")
59
+ .join("\n") ?? "";
60
+ if (user) prompts.push(user);
61
+ // The model returns the user text VERBATIM as a grounded, quoted
62
+ // fact — the shape source grounding requires to keep a fact.
63
+ const extractionJson = JSON.stringify({
64
+ facts: [{ content: user.trim(), category: "fact", confidence: 0.9, tags: [], quote: user.trim() }],
65
+ entities: [],
66
+ relationships: [],
67
+ questions: [],
68
+ profileUpdates: [],
69
+ });
70
+ res.writeHead(200, { "content-type": "application/json" });
71
+ res.end(JSON.stringify({ choices: [{ message: { role: "assistant", content: extractionJson } }] }));
72
+ return;
73
+ } catch {
74
+ // non-JSON probe request: fall through to the plain response
75
+ }
76
+ }
77
+ res.writeHead(200, { "content-type": "application/json" });
78
+ res.end(JSON.stringify({ choices: [{ message: { role: "assistant", content: "{}" } }] }));
79
+ });
80
+ });
81
+ await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
82
+ const address = server.address();
83
+ const port = address instanceof Object ? address.port : 0;
84
+ return {
85
+ url: `http://127.0.0.1:${port}`,
86
+ prompts,
87
+ close: async () => {
88
+ await new Promise<void>((resolve) => server.close(() => resolve()));
89
+ },
90
+ };
91
+ }
92
+
93
+ async function waitFor(predicate: () => boolean, timeoutMs = 30_000): Promise<boolean> {
94
+ // Real pipeline (daemon poll cadence + model round-trips) — platform clock.
95
+ const started = Date.now();
96
+ while (Date.now() - started < timeoutMs) {
97
+ if (predicate()) return true;
98
+ await new Promise((resolve) => setTimeout(resolve, 200));
99
+ }
100
+ return predicate();
101
+ }
102
+
103
+ async function findFactPath(dir: string, needle: string): Promise<string | undefined> {
104
+ let entries;
105
+ try {
106
+ entries = await readdir(dir, { withFileTypes: true });
107
+ } catch {
108
+ return undefined;
109
+ }
110
+ for (const entry of entries) {
111
+ const child = path.join(dir, entry.name);
112
+ if (entry.isDirectory()) {
113
+ const found = await findFactPath(child, needle);
114
+ if (found) return found;
115
+ } else if (
116
+ entry.name.endsWith(".md") &&
117
+ (await readFile(child, "utf8").then(
118
+ (c) => c.includes(needle),
119
+ () => false
120
+ ))
121
+ ) {
122
+ return child;
123
+ }
124
+ }
125
+ return undefined;
126
+ }
127
+
128
+ async function hasFactFile(dir: string, needle: string): Promise<boolean> {
129
+ let entries;
130
+ try {
131
+ entries = await readdir(dir, { withFileTypes: true });
132
+ } catch {
133
+ return false;
134
+ }
135
+ for (const entry of entries) {
136
+ const child = path.join(dir, entry.name);
137
+ if (entry.isDirectory()) {
138
+ if (await hasFactFile(child, needle)) return true;
139
+ } else if (
140
+ entry.name.endsWith(".md") &&
141
+ (await readFile(child, "utf8").then(
142
+ (c) => c.includes(needle),
143
+ () => false
144
+ ))
145
+ ) {
146
+ return true;
147
+ }
148
+ }
149
+ return false;
150
+ }
151
+
152
+ /**
153
+ * SAFETY: test seams bridge to orchestrator-private internals whose shape is
154
+ * locally declared and exercised by the test itself; this is the single
155
+ * trusted cast point for those seams.
156
+ */
157
+ function asTestSeam<T>(cause: unknown): T {
158
+ // SAFETY: the caller supplies the locally-declared seam contract; the cast
159
+ // re-labels the orchestrator-private instance to that contract.
160
+ return cause as T;
161
+ }
162
+
163
+ async function initializedOrchestrator(config: PluginConfig): Promise<Orchestrator> {
164
+ const orchestrator = new Orchestrator(config);
165
+ await orchestrator.initialize();
166
+ return orchestrator;
167
+ }
168
+
169
+ /** The verdict fields the drain boundary reads off an extraction result. */
170
+ interface DrainExtractionStubResult {
171
+ status: "completed" | "skipped";
172
+ reason?: string;
173
+ persistedCount: number;
174
+ durableOutputCount: number;
175
+ }
176
+
177
+ const NAMESPACED_FIXTURE = {
178
+ namespacesEnabled: true,
179
+ defaultNamespace: "default",
180
+ principalFromSessionKeyMode: "prefix",
181
+ namespacePolicies: [
182
+ { name: "alice", readPrincipals: ["alice"], writePrincipals: ["alice"] },
183
+ { name: "default", readPrincipals: ["default"], writePrincipals: ["default"] },
184
+ ],
185
+ };
186
+
187
+ test("a large pinned accepted claim group sends every turn to the ACTUAL chat input within the budget", async () => {
188
+ const memoryDir = await mkdtemp(path.join(os.tmpdir(), "drain-budget-boundary-"));
189
+ const model = await startModelBoundary();
190
+ let orchestrator: Orchestrator | undefined;
191
+ try {
192
+ const config = parseConfig({
193
+ ...NAMESPACED_FIXTURE,
194
+ memoryDir,
195
+ openaiApiKey: "sk-test",
196
+ qmdEnabled: false,
197
+ workspaceDir: path.join(memoryDir, "workspace"),
198
+ // FINITE window budget for the local path so chunking is exercised
199
+ // against the REAL renderer/truncation, not a stubbed seam.
200
+ modelSource: "local",
201
+ localLlmEnabled: true,
202
+ localLlmModel: "test-local-model",
203
+ localLlmUrl: model.url,
204
+ localLlmMaxContext: 2_000,
205
+ localLlmTimeoutMs: 30_000,
206
+ extractionMinChars: 0,
207
+ extractionMinUserTurns: 1,
208
+ });
209
+ orchestrator = new Orchestrator(config);
210
+ // The real daemon initializes before serving; the model-input budget
211
+ // (model registry) only exists after that.
212
+ await orchestrator.initialize();
213
+ const runner = new BufferDrainRunner(orchestrator);
214
+
215
+ const contents = [
216
+ "EARLY-MARKER budget probe with padding padding padding",
217
+ ...Array.from({ length: 120 }, (_, i) => `MIDDLE-MARKER-${i} budget probe filler filler filler filler`),
218
+ "LAST-MARKER budget probe tail padding padding",
219
+ ];
220
+ for (const [index, content] of contents.entries()) {
221
+ await orchestrator.buffer.addTurn("budget-sess", {
222
+ role: "user",
223
+ // Connector-tagged: the renderer prepends a "Source: <connector>"
224
+ // header for these, so the conversation-only budget measure must
225
+ // include the same gate/margin the engine applies.
226
+ sourceConnector: "omp",
227
+ content,
228
+ timestamp: `2026-11-01T00:${String(Math.floor(index / 60)).padStart(2, "0")}:${String(index % 60).padStart(2, "0")}.000Z`,
229
+ sessionKey: "budget-sess",
230
+ // A pinned ACCEPTED capture in the mix: the exact class that used to
231
+ // bypass chunking and truncate-and-ack.
232
+ acceptedCapture: true,
233
+ writeNamespaceOverride: "alice",
234
+ principalOverride: "alice",
235
+ });
236
+ }
237
+ await orchestrator.buffer.save();
238
+ assert.equal(await runner.claim("budget-sess", "drain-budget"), true);
239
+
240
+ const outcome = await runner.drainSession("budget-sess", { ownerId: "drain-budget" });
241
+ assert.equal(outcome.status, "drained", `expected a full drain, got ${outcome.reason ?? "deferred"}`);
242
+
243
+ // ACTUAL chat inputs: ALL source markers reached the model, and every
244
+ // request's USER conversation respects the EXACT house budget helper
245
+ // (ModelRegistry.calculateContextSizes with the configured maxContext —
246
+ // the same derivation extractionWindowBudgetChars uses). System
247
+ // instructions are legitimately outside the conversation budget.
248
+ const { ModelRegistry } = await import("./model-registry.js");
249
+ const maxInputChars = new ModelRegistry(memoryDir).calculateContextSizes("test-local-model", 2_000).maxInputChars;
250
+ assert.ok(
251
+ maxInputChars > 0 && Number.isFinite(maxInputChars),
252
+ "the house budget helper yields a finite input budget"
253
+ );
254
+ const allPrompts = model.prompts.join("\n");
255
+ for (const marker of contents) {
256
+ assert.ok(allPrompts.includes(marker), `source marker never reached the actual chat input: ${marker}`);
257
+ }
258
+ // The recorded user message = fixed instruction block + the conversation
259
+ // after the "Conversation:" label. The helper budget governs exactly that
260
+ // conversation portion — assert it directly, self-derived per prompt.
261
+ for (const [index, prompt] of model.prompts.entries()) {
262
+ const label = "Conversation:\n";
263
+ const labelIndex = prompt.lastIndexOf(label);
264
+ assert.ok(labelIndex >= 0, `model call ${index} lacks the conversation section`);
265
+ const conversationChars = prompt.length - (labelIndex + label.length);
266
+ assert.ok(
267
+ conversationChars <= maxInputChars,
268
+ `model call ${index} conversation ${conversationChars} exceeds the helper budget ${maxInputChars}`
269
+ );
270
+ }
271
+ assert.ok(
272
+ model.prompts.length >= 2,
273
+ `the window actually chunked (prompts=${model.prompts.length}, renderedSample=${model.prompts[0]?.length ?? 0})`
274
+ );
275
+ const claim = await runner.claims.load("budget-sess");
276
+ assert.equal(claim?.entry.turns.length ?? 0, 0, "the claim empties only after every chunk extracted");
277
+ } finally {
278
+ await model.close();
279
+ await orchestrator?.destroy().catch(() => undefined);
280
+ await rm(memoryDir, { recursive: true, force: true });
281
+ }
282
+ });
283
+
284
+ test("legacy unpinned claim groups refuse under an active profile with no writable layer and retain their claim", async () => {
285
+ const memoryDir = await mkdtemp(path.join(os.tmpdir(), "drain-profile-legacy-"));
286
+ const model = await startModelBoundary();
287
+ let orchestrator: Orchestrator | undefined;
288
+ try {
289
+ const config: PluginConfig = parseConfig({
290
+ ...NAMESPACED_FIXTURE,
291
+ memoryDir,
292
+ debug: true,
293
+ openaiApiKey: "sk-test",
294
+ // The controlled boundary serves the extraction — no network fallback.
295
+ openaiBaseUrl: model.url,
296
+ qmdEnabled: false,
297
+ workspaceDir: path.join(memoryDir, "workspace"),
298
+ extractionMinChars: 0,
299
+ extractionMinUserTurns: 1,
300
+ // House scope-profile fixture (orchestrator-flush "no writable layer"
301
+ // shape): an ACTIVE profile whose write layer cannot resolve —
302
+ // userProject without any project context is unwritable. The current
303
+ // policy therefore REFUSES this scope's write.
304
+ scopeProfiles: {
305
+ "denied-profile": {
306
+ readOrder: ["userProject"],
307
+ writeDefault: "userProject",
308
+ promotionTargets: [],
309
+ autoPromote: { enabled: false, targets: [], categories: [], minConfidenceTier: "speculative" },
310
+ },
311
+ },
312
+ defaultScopeProfile: "denied-profile",
313
+ });
314
+ orchestrator = new Orchestrator(config);
315
+ // The real daemon initializes before serving; the model-input budget
316
+ // (model registry) only exists after that.
317
+ await orchestrator.initialize();
318
+ const runner = new BufferDrainRunner(orchestrator);
319
+
320
+ await orchestrator.buffer.addTurn("alice:legacy", {
321
+ role: "user",
322
+ content: "legacy unpinned profile probe",
323
+ timestamp: "2026-11-01T00:00:00.000Z",
324
+ sessionKey: "alice:legacy",
325
+ });
326
+ await orchestrator.buffer.save();
327
+ assert.equal(await runner.claim("alice:legacy", "drain-legacy"), true);
328
+
329
+ const outcome = await runner.drainSession("alice:legacy", { ownerId: "drain-legacy" });
330
+
331
+ // Current policy refuses the scope: the drain must NOT flatten the
332
+ // legacy group to plan=null (which would bypass the profile gate and
333
+ // write the fact into the principal's default namespace) — it must
334
+ // refuse, keep the canonical turn in the claim, and report the denial.
335
+ assert.equal(
336
+ outcome.status,
337
+ "deferred",
338
+ `a denied scope is pending backlog, not drained (${outcome.reason ?? ""})`
339
+ );
340
+ assert.equal(outcome.reason, "scope_profile_no_writable_layer");
341
+ assert.deepEqual(outcome.denied, [{ reason: "scope_profile_no_writable_layer", turns: 1 }]);
342
+
343
+ // The refusal happens at the policy gate, before any model call — a
344
+ // flattened plan=null WOULD have called the model and persisted.
345
+ assert.equal(model.prompts.length, 0, "the denied scope must never reach the model");
346
+ assert.equal(
347
+ await hasFactFile(memoryDir, "legacy unpinned profile probe"),
348
+ false,
349
+ "a refused scope writes no fact anywhere"
350
+ );
351
+
352
+ // Canonical data untouched: the turn stays claimed and pending.
353
+ const claim = await runner.claims.load("alice:legacy");
354
+ assert.equal(claim?.entry.turns.length ?? 0, 1, "the denied turn stays in the claim");
355
+ assert.equal(claim?.entry.turns[0]?.content, "legacy unpinned profile probe");
356
+ } finally {
357
+ await model.close();
358
+ await orchestrator?.destroy().catch(() => undefined);
359
+ await rm(memoryDir, { recursive: true, force: true });
360
+ }
361
+ });
362
+
363
+ test("an unpinned legacy drain writes under the ACTIVE profile namespace, never the principal default", async () => {
364
+ const memoryDir = await mkdtemp(path.join(os.tmpdir(), "drain-profile-namespace-"));
365
+ const model = await startModelBoundary();
366
+ let orchestrator: Orchestrator | undefined;
367
+ try {
368
+ const config: PluginConfig = parseConfig({
369
+ ...NAMESPACED_FIXTURE,
370
+ // The principal resolves to the "default" namespace (prefix mode), and
371
+ // BOTH the default and the shared namespace are writable for it — so
372
+ // the write DESTINATION depends on which plan the drain forwards, not
373
+ // on who is allowed to write.
374
+ sharedNamespace: "shared",
375
+ namespacePolicies: [
376
+ { name: "alice", readPrincipals: ["alice"], writePrincipals: ["alice"] },
377
+ { name: "default", readPrincipals: ["default"], writePrincipals: ["default"] },
378
+ { name: "shared", readPrincipals: ["default"], writePrincipals: ["default"] },
379
+ ],
380
+ memoryDir,
381
+ debug: true,
382
+ openaiApiKey: "sk-test",
383
+ // The controlled boundary serves the extraction — no network fallback.
384
+ openaiBaseUrl: model.url,
385
+ qmdEnabled: false,
386
+ workspaceDir: path.join(memoryDir, "workspace"),
387
+ extractionMinChars: 0,
388
+ extractionMinUserTurns: 1,
389
+ // House scope-profile shape (orchestrator-flush profile fixture): an
390
+ // ACTIVE profile whose serverShared layer IS writable for the
391
+ // principal, so the CURRENT profile decides where the fact lands.
392
+ scopeProfiles: {
393
+ "sharedProfile": {
394
+ readOrder: ["serverShared"],
395
+ writeDefault: "serverShared",
396
+ promotionTargets: ["serverShared"],
397
+ autoPromote: { enabled: false, targets: [], categories: [], minConfidenceTier: "speculative" },
398
+ },
399
+ },
400
+ defaultScopeProfile: "sharedProfile",
401
+ });
402
+ orchestrator = new Orchestrator(config);
403
+ await orchestrator.initialize();
404
+ const runner = new BufferDrainRunner(orchestrator);
405
+
406
+ await orchestrator.buffer.addTurn("bob:legacy", {
407
+ role: "user",
408
+ content: "profile namespace probe",
409
+ timestamp: "2026-11-01T00:00:00.000Z",
410
+ sessionKey: "bob:legacy",
411
+ });
412
+ await orchestrator.buffer.save();
413
+ assert.equal(await runner.claim("bob:legacy", "drain-namespace"), true);
414
+
415
+ const outcome = await runner.drainSession("bob:legacy", { ownerId: "drain-namespace" });
416
+ assert.equal(outcome.status, "drained", `expected a full drain, got ${outcome.reason ?? "deferred"}`);
417
+
418
+ const factPath = await findFactPath(memoryDir, "profile namespace probe");
419
+ assert.ok(factPath, "the drained fact was persisted");
420
+ // The ACTIVE profile's write namespace owns the fact. Flattening the
421
+ // unpinned legacy group to plan=null bypasses the profile gate and
422
+ // persists the same fact in the principal's DEFAULT namespace instead.
423
+ const plan = resolveScopeProfilePlan({ config, principal: "default", codingContext: null, codingOverlay: null });
424
+ assert.equal(plan?.writeNamespace, "shared", "the active profile writes to the shared namespace");
425
+ assert.ok(
426
+ factPath.includes(path.join("namespaces", namespaceIdentityToken("shared"))),
427
+ `the fact belongs to the ACTIVE profile namespace, not the default one: ${factPath}`
428
+ );
429
+ assert.equal(
430
+ factPath.includes(path.join("namespaces", namespaceIdentityToken("default"))),
431
+ false,
432
+ `a flattened plan=null must not write into the default namespace: ${factPath}`
433
+ );
434
+ } finally {
435
+ await model.close();
436
+ await orchestrator?.destroy().catch(() => undefined);
437
+ await rm(memoryDir, { recursive: true, force: true });
438
+ }
439
+ });
440
+
441
+ test("a malformed extraction result keeps the canonical claim turns above the failure threshold", async () => {
442
+ const memoryDir = await mkdtemp(path.join(os.tmpdir(), "drain-invalid-result-"));
443
+ let orchestrator: Orchestrator | undefined;
444
+ try {
445
+ const config = parseConfig({
446
+ ...NAMESPACED_FIXTURE,
447
+ memoryDir,
448
+ openaiApiKey: "sk-test",
449
+ qmdEnabled: false,
450
+ workspaceDir: path.join(memoryDir, "workspace"),
451
+ extractionMinChars: 0,
452
+ extractionMinUserTurns: 1,
453
+ // Drive the malformed attempts ABOVE the drop threshold: the no-drop
454
+ // claim drain must never drop or side-file the content regardless.
455
+ extractionChunkDropAfterFailures: 1,
456
+ });
457
+ orchestrator = await initializedOrchestrator(config);
458
+ const runner = new BufferDrainRunner(orchestrator);
459
+
460
+ await orchestrator.buffer.addTurn("invalid-result-sess", {
461
+ role: "user",
462
+ content: "malformed model output probe",
463
+ timestamp: "2026-11-01T00:00:00.000Z",
464
+ sessionKey: "invalid-result-sess",
465
+ });
466
+ await orchestrator.buffer.save();
467
+ assert.equal(await runner.claim("invalid-result-sess", "drain-invalid"), true);
468
+
469
+ // The actual run boundary receives null and malformed collection results
470
+ // from the extractor — both real failure shapes the shared callee must
471
+ // retain (never clear). The engine field is orchestrator-private; the
472
+ // stub goes through this named test seam.
473
+ const extractorResults: unknown[] = [
474
+ null,
475
+ { facts: "not-an-array", entities: [], questions: [], profileUpdates: [] },
476
+ null,
477
+ ];
478
+ const extractionSeam = asTestSeam<{ extraction: { extract: () => Promise<ExtractionRunResult | null> } }>(
479
+ orchestrator
480
+ );
481
+ extractionSeam.extraction = {
482
+ // SAFETY: the queue re-drives malformed/null extractor output through
483
+ // the boundary; the stub result is cast to the named verdict shape.
484
+ extract: async () => extractorResults.shift() as ExtractionRunResult | null,
485
+ };
486
+
487
+ for (let attempt = 1; attempt <= 3; attempt += 1) {
488
+ const outcome = await runner.drainSession("invalid-result-sess", { ownerId: "drain-invalid" });
489
+ assert.notEqual(outcome.status, "drained", `attempt ${attempt}: a failed extraction is not a drain`);
490
+ assert.equal(outcome.reason, "invalid_extraction_result", `attempt ${attempt}: the failure reason surfaces`);
491
+ const claim = await runner.claims.load("invalid-result-sess");
492
+ assert.equal(claim?.entry.turns.length ?? 0, 1, `attempt ${attempt}: the canonical turn stays in the claim`);
493
+ assert.equal(
494
+ claim?.entry.turns[0]?.content,
495
+ "malformed model output probe",
496
+ `attempt ${attempt}: content intact`
497
+ );
498
+ }
499
+ // No-drop: no recovery side file may move the content aside.
500
+ await assert.rejects(() => readdir(path.join(memoryDir, "state", "dropped")), { code: "ENOENT" });
501
+ assert.equal(await hasFactFile(memoryDir, "malformed model output probe"), false);
502
+ } finally {
503
+ await orchestrator?.destroy().catch(() => undefined);
504
+ await rm(memoryDir, { recursive: true, force: true });
505
+ }
506
+ });
507
+
508
+ test("a live task queued while its session becomes claimed settles as claimed, never extracting the stale snapshot", async () => {
509
+ const memoryDir = await mkdtemp(path.join(os.tmpdir(), "drain-claim-race-"));
510
+ const model = await startModelBoundary();
511
+ let orchestrator: Orchestrator | undefined;
512
+ try {
513
+ const config = parseConfig({
514
+ ...NAMESPACED_FIXTURE,
515
+ memoryDir,
516
+ openaiApiKey: "sk-test",
517
+ openaiBaseUrl: model.url,
518
+ qmdEnabled: false,
519
+ workspaceDir: path.join(memoryDir, "workspace"),
520
+ extractionMinChars: 0,
521
+ extractionMinUserTurns: 1,
522
+ extractionQueueConcurrency: 1,
523
+ });
524
+ orchestrator = new Orchestrator(config);
525
+ await orchestrator.initialize();
526
+
527
+ let engineCalls = 0;
528
+ const extractionSeam = asTestSeam<{ extraction: { extract: () => Promise<DrainExtractionStubResult | null> } }>(
529
+ orchestrator
530
+ );
531
+ extractionSeam.extraction = {
532
+ extract: async (): Promise<DrainExtractionStubResult | null> => {
533
+ engineCalls += 1;
534
+ return { status: "completed", persistedCount: 0, durableOutputCount: 0 };
535
+ },
536
+ };
537
+
538
+ const probe = {
539
+ role: "user" as const,
540
+ content: "claim race probe",
541
+ timestamp: "2026-11-01T00:00:00.000Z",
542
+ sessionKey: "race-sess",
543
+ };
544
+ await orchestrator.buffer.addTurn("race-sess", probe);
545
+ await orchestrator.buffer.save();
546
+
547
+ // Occupy the queue's only slot, then enqueue the LIVE task behind it: the
548
+ // enqueue-time claim gate passes (the session is unclaimed) and the task
549
+ // waits. While it waits, the scheduler's claim decision completes.
550
+ let releaseBarrier: () => void = () => undefined;
551
+ // A KEYED task occupies the concurrency-1 slot: raw tasks bypass the slot,
552
+ // which would let the live task start before the claim decision.
553
+ void orchestrator.extractionQueueCoordinator.enqueue(
554
+ async () => {
555
+ await new Promise<void>((done) => {
556
+ releaseBarrier = done;
557
+ });
558
+ },
559
+ { key: "race-barrier" }
560
+ );
561
+
562
+ const settled = Promise.withResolvers<{ reason?: string }>();
563
+ // Test seam: enqueueing a LIVE buffered extraction directly is the only
564
+ // way to interleave it with the claim decision; the coordinator method is
565
+ // orchestrator-private.
566
+ const queueSeam = asTestSeam<{
567
+ queueBufferedExtraction: (
568
+ turns: unknown[],
569
+ reason: string,
570
+ options: {
571
+ bufferKey: string;
572
+ skipDedupeCheck: boolean;
573
+ clearBufferAfterExtraction: boolean;
574
+ onTaskSettled: (error?: Error, result?: { reason?: string }) => void;
575
+ }
576
+ ) => Promise<void>;
577
+ }>(orchestrator);
578
+ void queueSeam
579
+ .queueBufferedExtraction([probe], "trigger_mode", {
580
+ bufferKey: "race-sess",
581
+ skipDedupeCheck: true,
582
+ clearBufferAfterExtraction: true,
583
+ onTaskSettled: (error, result) => settled.resolve({ reason: result?.reason }),
584
+ })
585
+ .catch(() => undefined);
586
+ const runner = new BufferDrainRunner(orchestrator);
587
+ assert.equal(
588
+ await runner.claim("race-sess", "race-owner"),
589
+ true,
590
+ "the scheduler's claim decision completes while the live task waits"
591
+ );
592
+
593
+ releaseBarrier();
594
+ const settledResult = await settled.promise;
595
+ assert.equal(
596
+ settledResult.reason,
597
+ "claimed_by_buffer_drain",
598
+ "the queued live task must re-apply the claim gate at execution time"
599
+ );
600
+ assert.equal(engineCalls, 0, "the stale live snapshot must never reach the model");
601
+ assert.equal(model.prompts.length, 0);
602
+ } finally {
603
+ await model.close();
604
+ await orchestrator?.destroy().catch(() => undefined);
605
+ await rm(memoryDir, { recursive: true, force: true });
606
+ }
607
+ });
608
+
609
+ test("a planless accepted claim group pins null: it stays unprofiled under the CURRENT profile", async () => {
610
+ const memoryDir = await mkdtemp(path.join(os.tmpdir(), "drain-plan-null-"));
611
+ const model = await startModelBoundary();
612
+ let orchestrator: Orchestrator | undefined;
613
+ try {
614
+ // Active profile with NO writable layer (house "denied" shape): if the
615
+ // drain failed to pin null for this planless ACCEPTED capture, the
616
+ // current profile would refuse the write and the capture would strand.
617
+ const config: PluginConfig = parseConfig({
618
+ ...NAMESPACED_FIXTURE,
619
+ memoryDir,
620
+ openaiApiKey: "sk-test",
621
+ openaiBaseUrl: model.url,
622
+ qmdEnabled: false,
623
+ workspaceDir: path.join(memoryDir, "workspace"),
624
+ extractionMinChars: 0,
625
+ extractionMinUserTurns: 1,
626
+ scopeProfiles: {
627
+ "denied-profile": {
628
+ readOrder: ["userProject"],
629
+ writeDefault: "userProject",
630
+ promotionTargets: [],
631
+ autoPromote: { enabled: false, targets: [], categories: [], minConfidenceTier: "speculative" },
632
+ },
633
+ },
634
+ defaultScopeProfile: "denied-profile",
635
+ });
636
+ orchestrator = await initializedOrchestrator(config);
637
+ const runner = new BufferDrainRunner(orchestrator);
638
+
639
+ // NO writeNamespaceOverride: the null pin (not an explicit-namespace
640
+ // override) is what keeps this capture outside the current profile gate.
641
+ await orchestrator.buffer.addTurn("planless-sess", {
642
+ role: "user",
643
+ content: "planless accepted capture probe",
644
+ timestamp: "2026-11-01T00:00:00.000Z",
645
+ sessionKey: "planless-sess",
646
+ acceptedCapture: true,
647
+ });
648
+ await orchestrator.buffer.save();
649
+ assert.equal(await runner.claim("planless-sess", "drain-null"), true);
650
+
651
+ const outcome = await runner.drainSession("planless-sess", { ownerId: "drain-null" });
652
+ assert.equal(outcome.status, "drained", "the pinned null keeps the accepted capture unprofiled and writable");
653
+ assert.ok(
654
+ model.prompts.some((p) => p.includes("planless accepted capture probe")),
655
+ "the capture reached the model"
656
+ );
657
+ assert.ok(
658
+ await hasFactFile(memoryDir, "planless accepted capture probe"),
659
+ "the fact persisted to the pinned namespace"
660
+ );
661
+ const claim = await runner.claims.load("planless-sess");
662
+ assert.equal(claim?.entry.turns.length ?? 0, 0, "the claim empties after extraction");
663
+ } finally {
664
+ await model.close();
665
+ await orchestrator?.destroy().catch(() => undefined);
666
+ await rm(memoryDir, { recursive: true, force: true });
667
+ }
668
+ });
669
+
670
+ test("a pinned accepted claim group forwards its captured plan; flattening it to null loses the identity gate", async () => {
671
+ const memoryDir = await mkdtemp(path.join(os.tmpdir(), "drain-plan-pin-"));
672
+ const model = await startModelBoundary();
673
+ let orchestrator: Orchestrator | undefined;
674
+ try {
675
+ const config: PluginConfig = parseConfig({
676
+ ...NAMESPACED_FIXTURE,
677
+ memoryDir,
678
+ openaiApiKey: "sk-test",
679
+ openaiBaseUrl: model.url,
680
+ qmdEnabled: false,
681
+ workspaceDir: path.join(memoryDir, "workspace"),
682
+ extractionMinChars: 0,
683
+ extractionMinUserTurns: 1,
684
+ scopeProfiles: {
685
+ "pinned-profile": {
686
+ readOrder: ["userGlobal"],
687
+ writeDefault: "userGlobal",
688
+ promotionTargets: [],
689
+ autoPromote: { enabled: false, targets: [], categories: [], minConfidenceTier: "speculative" },
690
+ },
691
+ },
692
+ defaultScopeProfile: "pinned-profile",
693
+ });
694
+ orchestrator = await initializedOrchestrator(config);
695
+ const runner = new BufferDrainRunner(orchestrator);
696
+
697
+ // The capture pinned a plan captured under an OLDER policy: the CURRENT
698
+ // resolution differs, so forwarding the plan (never flattening it to
699
+ // null) is what lets the identity re-check refuse the scope.
700
+ const resolved = resolveScopeProfilePlan({ config, principal: "alice", codingContext: null, codingOverlay: null });
701
+ assert.ok(resolved, "the active profile resolves a plan");
702
+ const stalePlan = { ...resolved, writeNamespace: "stale-captured-ns" };
703
+
704
+ await orchestrator.buffer.addTurn("alice:pinned", {
705
+ role: "user",
706
+ content: "pinned plan identity probe",
707
+ timestamp: "2026-11-01T00:00:00.000Z",
708
+ sessionKey: "alice:pinned",
709
+ acceptedCapture: true,
710
+ writeNamespaceOverride: "alice",
711
+ principalOverride: "alice",
712
+ scopeProfileWritePlan: stalePlan,
713
+ });
714
+ await orchestrator.buffer.save();
715
+ assert.equal(await runner.claim("alice:pinned", "drain-pin"), true);
716
+
717
+ const outcome = await runner.drainSession("alice:pinned", { ownerId: "drain-pin" });
718
+ assert.equal(outcome.status, "deferred", "a stale pinned plan is refused, not drained");
719
+ assert.equal(outcome.reason, "scope_profile_revoked");
720
+ assert.deepEqual(outcome.denied, [{ reason: "scope_profile_revoked", turns: 1 }]);
721
+ assert.equal(model.prompts.length, 0, "the stale-pinned scope never reaches the model");
722
+ assert.equal(await hasFactFile(memoryDir, "pinned plan identity probe"), false);
723
+ const claim = await runner.claims.load("alice:pinned");
724
+ assert.equal(claim?.entry.turns.length ?? 0, 1, "the denied turn stays claimed");
725
+ } finally {
726
+ await model.close();
727
+ await orchestrator?.destroy().catch(() => undefined);
728
+ await rm(memoryDir, { recursive: true, force: true });
729
+ }
730
+ });
731
+
732
+ test("explicit capture mode denies ordinary backlog turns and still drains accepted captures", async () => {
733
+ const memoryDir = await mkdtemp(path.join(os.tmpdir(), "drain-explicit-mode-"));
734
+ const model = await startModelBoundary();
735
+ let orchestrator: Orchestrator | undefined;
736
+ try {
737
+ const config = parseConfig({
738
+ ...NAMESPACED_FIXTURE,
739
+ memoryDir,
740
+ captureMode: "explicit",
741
+ openaiApiKey: "sk-test",
742
+ openaiBaseUrl: model.url,
743
+ qmdEnabled: false,
744
+ workspaceDir: path.join(memoryDir, "workspace"),
745
+ extractionMinChars: 0,
746
+ extractionMinUserTurns: 1,
747
+ });
748
+ orchestrator = await initializedOrchestrator(config);
749
+ const runner = new BufferDrainRunner(orchestrator);
750
+
751
+ await orchestrator.buffer.addTurn("ordinary-sess", {
752
+ role: "user",
753
+ content: "ordinary explicit-mode probe",
754
+ timestamp: "2026-11-01T00:00:00.000Z",
755
+ sessionKey: "ordinary-sess",
756
+ });
757
+ await orchestrator.buffer.addTurn("accepted-sess", {
758
+ role: "user",
759
+ content: "accepted explicit-mode probe",
760
+ timestamp: "2026-11-01T00:00:00.000Z",
761
+ sessionKey: "accepted-sess",
762
+ acceptedCapture: true,
763
+ writeNamespaceOverride: "alice",
764
+ principalOverride: "alice",
765
+ });
766
+ await orchestrator.buffer.save();
767
+
768
+ // Ordinary conversation backlog: denied, retained, never extracted.
769
+ assert.equal(await runner.claim("ordinary-sess", "drain-explicit"), true);
770
+ const ordinary = await runner.drainSession("ordinary-sess", { ownerId: "drain-explicit" });
771
+ assert.equal(ordinary.status, "deferred", "explicit mode keeps ordinary turns pending");
772
+ assert.deepEqual(ordinary.denied, [{ reason: "capture_mode_explicit", turns: 1 }]);
773
+ const ordinaryClaim = await runner.claims.load("ordinary-sess");
774
+ assert.equal(ordinaryClaim?.entry.turns[0]?.content, "ordinary explicit-mode probe");
775
+
776
+ // A deliberately accepted capture still drains under explicit mode.
777
+ assert.equal(await runner.claim("accepted-sess", "drain-explicit"), true);
778
+ const accepted = await runner.drainSession("accepted-sess", { ownerId: "drain-explicit" });
779
+ assert.equal(accepted.status, "drained", "accepted captures drain under explicit mode");
780
+ assert.ok(await hasFactFile(memoryDir, "accepted explicit-mode probe"), "the accepted capture persisted");
781
+
782
+ // The ordinary scope never reached the model.
783
+ assert.equal(
784
+ model.prompts.some((p) => p.includes("ordinary explicit-mode probe")),
785
+ false,
786
+ "ordinary turns must not be extracted in explicit mode"
787
+ );
788
+ } finally {
789
+ await model.close();
790
+ await orchestrator?.destroy().catch(() => undefined);
791
+ await rm(memoryDir, { recursive: true, force: true });
792
+ }
793
+ });
794
+
795
+ test("a mixed accepted+ordinary group splits: the accepted capture drains, the ordinary turn stays denied", async () => {
796
+ const memoryDir = await mkdtemp(path.join(os.tmpdir(), "drain-mixed-group-"));
797
+ const model = await startModelBoundary();
798
+ let orchestrator: Orchestrator | undefined;
799
+ try {
800
+ const config = parseConfig({
801
+ ...NAMESPACED_FIXTURE,
802
+ memoryDir,
803
+ captureMode: "explicit",
804
+ openaiApiKey: "sk-test",
805
+ openaiBaseUrl: model.url,
806
+ qmdEnabled: false,
807
+ workspaceDir: path.join(memoryDir, "workspace"),
808
+ extractionMinChars: 0,
809
+ extractionMinUserTurns: 1,
810
+ });
811
+ orchestrator = await initializedOrchestrator(config);
812
+ const runner = new BufferDrainRunner(orchestrator);
813
+
814
+ // IDENTICAL scope pins (same namespace + principal), differing ONLY in
815
+ // acceptedCapture: the scope group key must not merge them — otherwise
816
+ // the accepted capture's group drags the ordinary turn through the gate.
817
+ await orchestrator.buffer.addTurn("mixed-sess", {
818
+ role: "user",
819
+ content: "accepted mixed probe",
820
+ timestamp: "2026-11-01T00:00:00.000Z",
821
+ sessionKey: "mixed-sess",
822
+ acceptedCapture: true,
823
+ writeNamespaceOverride: "alice",
824
+ principalOverride: "alice",
825
+ });
826
+ await orchestrator.buffer.addTurn("mixed-sess", {
827
+ role: "user",
828
+ content: "ordinary mixed probe",
829
+ timestamp: "2026-11-01T00:00:05.000Z",
830
+ sessionKey: "mixed-sess",
831
+ writeNamespaceOverride: "alice",
832
+ principalOverride: "alice",
833
+ });
834
+ await orchestrator.buffer.save();
835
+ assert.equal(await runner.claim("mixed-sess", "drain-mixed"), true);
836
+
837
+ const outcome = await runner.drainSession("mixed-sess", { ownerId: "drain-mixed" });
838
+ assert.equal(outcome.status, "deferred", "the ordinary turn keeps the session pending");
839
+ assert.deepEqual(outcome.denied, [{ reason: "capture_mode_explicit", turns: 1 }]);
840
+
841
+ // The accepted capture drained and persisted.
842
+ assert.ok(await hasFactFile(memoryDir, "accepted mixed probe"), "the accepted capture persisted");
843
+
844
+ // The ordinary turn's canonical copy stays in the claim, untouched.
845
+ const claim = await runner.claims.load("mixed-sess");
846
+ assert.deepEqual(
847
+ claim?.entry.turns.map((turn) => turn.content),
848
+ ["ordinary mixed probe"],
849
+ "exactly the ordinary turn stays claimed"
850
+ );
851
+
852
+ // The ordinary content never reached the model.
853
+ assert.equal(
854
+ model.prompts.some((p) => p.includes("ordinary mixed probe")),
855
+ false,
856
+ "ordinary content must not be extracted in explicit mode"
857
+ );
858
+ } finally {
859
+ await model.close();
860
+ await orchestrator?.destroy().catch(() => undefined);
861
+ await rm(memoryDir, { recursive: true, force: true });
862
+ }
863
+ });
864
+
865
+ test("scan reports claimed, unclaimed, and orphaned tombstone sessions", async () => {
866
+ const memoryDir = await mkdtemp(path.join(os.tmpdir(), "drain-scan-"));
867
+ let orchestrator: Orchestrator | undefined;
868
+ try {
869
+ const config = parseConfig({
870
+ ...NAMESPACED_FIXTURE,
871
+ memoryDir,
872
+ openaiApiKey: "sk-test",
873
+ qmdEnabled: false,
874
+ workspaceDir: path.join(memoryDir, "workspace"),
875
+ extractionMinChars: 0,
876
+ extractionMinUserTurns: 1,
877
+ });
878
+ orchestrator = await initializedOrchestrator(config);
879
+ const runner = new BufferDrainRunner(orchestrator);
880
+
881
+ const turn = (key: string, content: string): BufferTurn => ({
882
+ role: "user",
883
+ content,
884
+ timestamp: "2026-11-01T00:00:00.000Z",
885
+ sessionKey: key,
886
+ });
887
+ await orchestrator.buffer.addTurn("scan-unclaimed", turn("scan-unclaimed", "unclaimed body"));
888
+ await orchestrator.buffer.addTurn("scan-claimed", turn("scan-claimed", "claimed body"));
889
+ await orchestrator.buffer.save();
890
+
891
+ const before = await runner.scan();
892
+ assert.deepEqual(
893
+ before.find((entry) => entry.key === "scan-unclaimed"),
894
+ { key: "scan-unclaimed", turns: 1, claimed: false }
895
+ );
896
+
897
+ assert.equal(await runner.claim("scan-claimed", "scan-owner"), true);
898
+ const claimed = await runner.scan();
899
+ const claimedEntry = claimed.find((entry) => entry.key === "scan-claimed");
900
+ assert.equal(claimedEntry?.claimed, true, "a claimed session reports claimed");
901
+ assert.equal(claimedEntry?.turns, 1);
902
+
903
+ // Draining the claimed session leaves an ack tombstone: still listed so
904
+ // an operator sees the drained claim exists (the live copy is gone).
905
+ await runner.drainSession("scan-claimed", { ownerId: "scan-owner" });
906
+ const after = await runner.scan();
907
+ assert.ok(
908
+ after.some((entry) => entry.key === "scan-claimed"),
909
+ "the claimed key stays visible across the scan"
910
+ );
911
+ assert.ok(after.some((entry) => entry.key === "scan-unclaimed"));
912
+ } finally {
913
+ await orchestrator?.destroy().catch(() => undefined);
914
+ await rm(memoryDir, { recursive: true, force: true });
915
+ }
916
+ });
917
+
918
+ test("drainSession skips a key with no claim, a busy lease, and an emptied claim", async () => {
919
+ const memoryDir = await mkdtemp(path.join(os.tmpdir(), "drain-skip-paths-"));
920
+ let orchestrator: Orchestrator | undefined;
921
+ try {
922
+ const config = parseConfig({
923
+ ...NAMESPACED_FIXTURE,
924
+ memoryDir,
925
+ openaiApiKey: "sk-test",
926
+ qmdEnabled: false,
927
+ workspaceDir: path.join(memoryDir, "workspace"),
928
+ extractionMinChars: 0,
929
+ extractionMinUserTurns: 1,
930
+ });
931
+ orchestrator = await initializedOrchestrator(config);
932
+ const runner = new BufferDrainRunner(orchestrator);
933
+ const claims = runner.claims;
934
+
935
+ // Not claimed: nothing to drain.
936
+ const notClaimed = await runner.drainSession("ghost-sess", { ownerId: "drain-skips" });
937
+ assert.equal(notClaimed.status, "skipped");
938
+ assert.equal(notClaimed.reason, "not_claimed");
939
+
940
+ // Busy: another drainer holds the lease.
941
+ await orchestrator.buffer.addTurn("busy-sess", {
942
+ role: "user",
943
+ content: "busy body",
944
+ timestamp: "2026-11-01T00:00:00.000Z",
945
+ sessionKey: "busy-sess",
946
+ });
947
+ await orchestrator.buffer.save();
948
+ assert.equal(await runner.claim("busy-sess", "first-owner"), true);
949
+ const busy = await runner.drainSession("busy-sess", { ownerId: "second-owner" });
950
+ assert.equal(busy.status, "skipped");
951
+ assert.equal(busy.reason, "claimed_by_another_drain");
952
+ assert.equal(busy.turnsRemaining, 1);
953
+
954
+ // Emptied claim: the ack verdict is honored (drained). The emptied entry
955
+ // is written by the CURRENT lease holder (first-owner).
956
+ const claimsEntry = await claims.load("busy-sess");
957
+ await claims.writeEntry(
958
+ "busy-sess",
959
+ "first-owner",
960
+ {
961
+ ...claimsEntry!.entry,
962
+ turns: [],
963
+ retainedTurns: undefined,
964
+ lastExtractionAt: new Date().toISOString(),
965
+ extractionCount: (claimsEntry!.entry.extractionCount ?? 0) + 1,
966
+ },
967
+ claimsEntry!.entry
968
+ );
969
+ await claims.releaseLease("busy-sess", "first-owner");
970
+ const emptied = await runner.drainSession("busy-sess", { ownerId: "second-owner" });
971
+ assert.equal(emptied.status, "drained", "an emptied claim acks cleanly");
972
+ } finally {
973
+ await orchestrator?.destroy().catch(() => undefined);
974
+ await rm(memoryDir, { recursive: true, force: true });
975
+ }
976
+ });
977
+
978
+ test("an aborted drain waits for the in-flight persist before releasing the lease", async () => {
979
+ const memoryDir = await mkdtemp(path.join(os.tmpdir(), "drain-abort-persist-"));
980
+ const model = await startModelBoundary();
981
+ let orchestrator: Orchestrator | undefined;
982
+ try {
983
+ const config = parseConfig({
984
+ ...NAMESPACED_FIXTURE,
985
+ memoryDir,
986
+ openaiApiKey: "sk-test",
987
+ openaiBaseUrl: model.url,
988
+ qmdEnabled: false,
989
+ workspaceDir: path.join(memoryDir, "workspace"),
990
+ extractionMinChars: 0,
991
+ extractionMinUserTurns: 1,
992
+ });
993
+ orchestrator = await initializedOrchestrator(config);
994
+ const runner = new BufferDrainRunner(orchestrator);
995
+
996
+ // The persist runs until the abort lands: the abort settles the task
997
+ // (abort listener) while the persist is still in flight.
998
+ let persistStarted: () => void = () => undefined;
999
+ let persistCompleted = false;
1000
+ const sequence: string[] = [];
1001
+ const persistStartedPromise = new Promise<void>((resolve) => {
1002
+ persistStarted = resolve;
1003
+ });
1004
+ const persistSeam = asTestSeam<{
1005
+ persistExtraction: (...cause: unknown[]) => Promise<{
1006
+ persistedIds: string[];
1007
+ memoryPathById: Map<string, string>;
1008
+ deferredCount: number;
1009
+ pendingReviewIds: string[];
1010
+ }>;
1011
+ }>(orchestrator);
1012
+ persistSeam.persistExtraction = async () => {
1013
+ persistStarted();
1014
+ await new Promise<void>((resolve) => setTimeout(resolve, 150));
1015
+ persistCompleted = true;
1016
+ sequence.push("persist-done");
1017
+ return { persistedIds: ["persisted-1"], memoryPathById: new Map(), deferredCount: 0, pendingReviewIds: [] };
1018
+ };
1019
+
1020
+ // Releasing the lease while the gated persist is still in flight is
1021
+ // exactly the lease-loss window under review.
1022
+ const claimsStore = runner.claims;
1023
+ const realRelease = claimsStore.releaseLease.bind(claimsStore);
1024
+ claimsStore.releaseLease = async (leaseKey: string, ownerId: string) => {
1025
+ sequence.push(persistCompleted ? "release" : "release-early");
1026
+ return realRelease(leaseKey, ownerId);
1027
+ };
1028
+
1029
+ await orchestrator.buffer.addTurn("abort-persist-sess", {
1030
+ role: "user",
1031
+ content: "abort persist probe",
1032
+ timestamp: "2026-11-01T00:00:00.000Z",
1033
+ sessionKey: "abort-persist-sess",
1034
+ acceptedCapture: true,
1035
+ writeNamespaceOverride: "alice",
1036
+ principalOverride: "alice",
1037
+ });
1038
+ await orchestrator.buffer.save();
1039
+ assert.equal(await runner.claim("abort-persist-sess", "drain-abort"), true);
1040
+
1041
+ const controller = new AbortController();
1042
+ const drain = runner.drainSession("abort-persist-sess", { ownerId: "drain-abort", signal: controller.signal });
1043
+ await persistStartedPromise;
1044
+ controller.abort(new Error("shutdown"));
1045
+ const outcome = await drain;
1046
+
1047
+ // The lease-loss window: releasing the lease while the gated persist is
1048
+ // still in flight (release-early) lets the closure's post-persist claim
1049
+ // write fail its ownership check, stranding already-persisted turns.
1050
+ assert.ok(
1051
+ !sequence.includes("release-early"),
1052
+ `the lease must not be released while the queued closure is still persisting (sequence: ${JSON.stringify(sequence)})`
1053
+ );
1054
+ assert.equal(sequence[0], "persist-done", "the in-flight persist completes before any lease bookkeeping");
1055
+ assert.equal(persistCompleted, true);
1056
+ assert.notEqual(outcome.reason, "not_claimed", "the drain accounted for the aborted session");
1057
+ } finally {
1058
+ await model.close();
1059
+ await orchestrator?.destroy().catch(() => undefined);
1060
+ await rm(memoryDir, { recursive: true, force: true });
1061
+ }
1062
+ });