@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
@@ -1,35 +1,21 @@
1
- /**
2
- * Out-of-process extraction-buffer drain (`remnic buffer drain`).
3
- *
4
- * The runner owns ONE claimed session at a time and drives it through the
5
- * SAME extraction pipeline the daemon uses — the same chunking
6
- * (`chunkExtractionWindow` + the model's input budget), the same retry /
7
- * provider-circuit semantics, the same dropped-chunk side-file recovery. It
8
- * adds no extraction logic of its own.
9
- *
10
- * The difference is ownership: the claimed session's turns live in a drain
11
- * claim (a durable file), not in a live buffer. So the runner wires a
12
- * claim-backed `SmartBuffer` into the extraction queue instead of the live
13
- * one. Every chunk that extracts successfully is removed from the claim
14
- * immediately, which makes the drain resumable: a kill at any instant
15
- * leaves the un-extracted turns in the claim, and the next run continues
16
- * from there.
17
- *
18
- * Concurrency lives in the CLI (worker processes, one session each at a
19
- * time): the in-process extraction queue is serial by design, so a single
20
- * runner never runs two sessions' extractions at once.
21
- */
1
+ /** Claim-backed extraction-buffer drain (`remnic buffer drain` worker AND the */
22
2
 
23
- import type { BufferStorage } from "../buffer-persistence.js";
24
- import { BufferDrainClaims, type DrainLease } from "../storage/buffer-drain-claims.js";
25
- import type { BufferEntryState, BufferState } from "../types.js";
26
3
  import { hostname } from "node:os";
27
- import { describeError } from "../buffer-turn-helpers.js";
4
+ import type { BufferStorage } from "../buffer-persistence.js";
5
+ import { type ExtractionScopeGroup, describeError, groupTurnsByExtractionScope } from "../buffer-turn-helpers.js";
6
+ import { SmartBuffer } from "../buffer.js";
7
+ import { resolveMemoryLifecycleCapabilities } from "../capabilities.js";
8
+ import { shouldSkipImplicitExtraction } from "../explicit-capture.js";
28
9
  import { log } from "../logger.js";
10
+ import type { ResolvedScopeProfilePlan } from "../namespaces/scope-profiles.js";
11
+ import type { Orchestrator } from "../orchestrator.js";
12
+ import { headerConnector, renderExtractionConversation, resolveSourceConnector } from "../source-agent-qualifier.js";
13
+ import type { BufferDrainClaims, DrainLease } from "../storage/buffer-drain-claims.js";
14
+ import type { BufferEntryState, BufferState, BufferTurn, PluginConfig } from "../types.js";
15
+ import { ExtractionDrain } from "./extraction-drain.js";
16
+ import type { ExtractionRunResult } from "./extraction-run.js";
29
17
  import { selfDeps } from "./self-deps.js";
30
18
  import { TurnIngestionCoordinator, type TurnIngestionDeps } from "./turn-ingestion.js";
31
- import type { ExtractionRunResult } from "./extraction-run.js";
32
- import type { Orchestrator } from "../orchestrator.js";
33
19
 
34
20
  export type DrainSessionStatus = "drained" | "deferred" | "failed" | "skipped";
35
21
 
@@ -46,8 +32,24 @@ export interface DrainSessionOutcome {
46
32
  durableOutputCount: number;
47
33
  /** Why the session was skipped or deferred (machine-readable). */
48
34
  reason?: string;
35
+ /** Current-policy denials: scope groups whose authorization no longer holds. */
36
+ denied?: { reason: string; turns: number }[];
49
37
  }
50
38
 
39
+ /** Extraction results that mean "current policy refuses this scope": the
40
+ * turns are valid backlog whose captured scope is no longer writable. */
41
+ const DENIED_SCOPE_REASONS: ReadonlySet<string> = new Set([
42
+ "pinned_namespace_unavailable",
43
+ "namespace_write_revoked",
44
+ "scope_profile_revoked",
45
+ // The resolved profile has no writable layer: the same current-policy
46
+ // refusal family, surfaced from inside the shared runExtraction gate.
47
+ "scope_profile_no_writable_layer",
48
+ // captureMode=explicit: ordinary buffered conversation turns never create
49
+ // memories; only deliberately accepted captures drain.
50
+ "capture_mode_explicit",
51
+ ]);
52
+
51
53
  export interface BufferDrainRunnerOptions {
52
54
  /** Identity of this drainer; owns the lease it takes. */
53
55
  ownerId: string;
@@ -73,12 +75,12 @@ const DEFAULT_LEASE_MS = 120_000;
73
75
  * Writes are the drain's progress (what is left to extract), so every
74
76
  * successfully extracted chunk is durably removed before the next one runs.
75
77
  */
76
- class ClaimBufferStorage implements BufferStorage {
78
+ export class ClaimBufferStorage implements BufferStorage {
77
79
  constructor(
78
80
  private readonly claims: BufferDrainClaims,
79
81
  private readonly key: string,
80
82
  private readonly ownerId: string,
81
- private readonly entry: BufferEntryState,
83
+ private readonly entry: BufferEntryState
82
84
  ) {}
83
85
 
84
86
  async loadBuffer(): Promise<BufferState> {
@@ -100,7 +102,12 @@ class ClaimBufferStorage implements BufferStorage {
100
102
  async saveBuffer(state: BufferState): Promise<void> {
101
103
  // Whole-state fallback (the claim covers one session): persist the entry.
102
104
  const entry = state.entries?.[this.key];
103
- await this.claims.writeEntry(this.key, this.ownerId, entry ?? { turns: [], lastExtractionAt: null, extractionCount: 0 }, this.entry);
105
+ await this.claims.writeEntry(
106
+ this.key,
107
+ this.ownerId,
108
+ entry ?? { turns: [], lastExtractionAt: null, extractionCount: 0 },
109
+ this.entry
110
+ );
104
111
  }
105
112
 
106
113
  async saveBufferSession(key: string, entry: BufferEntryState): Promise<void> {
@@ -113,6 +120,471 @@ class ClaimBufferStorage implements BufferStorage {
113
120
  }
114
121
  }
115
122
 
123
+ /** Per-invocation isolated execution context for one claim drain. */
124
+ interface ClaimDrainExecution {
125
+ claimBuffer: SmartBuffer;
126
+ coordinator: TurnIngestionCoordinator;
127
+ executionDrain: ExtractionDrain;
128
+ }
129
+
130
+ /** The scopeProfileWritePlan option a scope group forwards to the queue. */
131
+ interface GroupPlanOption {
132
+ scopeProfileWritePlan?: ResolvedScopeProfilePlan | null;
133
+ }
134
+
135
+ /** Durable counters a drain pass contributed. */
136
+ interface GroupCounts {
137
+ persistedCount: number;
138
+ durableOutputCount: number;
139
+ }
140
+
141
+ /** Pending turns across canonical + retained copies (the honest backlog count). */
142
+ function pendingTurnCount(entry: BufferEntryState | undefined): number {
143
+ if (!entry) return 0;
144
+ return entry.turns.length + (entry.retainedTurns?.length ?? 0);
145
+ }
146
+
147
+ /** Load one claimed session. An unclaimed key is nothing to drain. */
148
+ async function loadClaimedSession(
149
+ claims: BufferDrainClaims,
150
+ key: string
151
+ ): Promise<
152
+ | { kind: "notClaimed"; notClaimed: DrainSessionOutcome }
153
+ | { kind: "claimed"; claim: { entry: BufferEntryState }; turnsAtStart: number }
154
+ > {
155
+ const claim = await claims.load(key);
156
+ if (!claim) {
157
+ return {
158
+ kind: "notClaimed",
159
+ notClaimed: {
160
+ key,
161
+ status: "skipped",
162
+ turnsAtStart: 0,
163
+ turnsRemaining: 0,
164
+ persistedCount: 0,
165
+ durableOutputCount: 0,
166
+ reason: "not_claimed",
167
+ },
168
+ };
169
+ }
170
+ return { kind: "claimed", claim, turnsAtStart: pendingTurnCount(claim.entry) };
171
+ }
172
+
173
+ function busyLeaseOutcome(key: string, turnsAtStart: number): DrainSessionOutcome {
174
+ return {
175
+ key,
176
+ status: "skipped",
177
+ turnsAtStart,
178
+ turnsRemaining: turnsAtStart,
179
+ persistedCount: 0,
180
+ durableOutputCount: 0,
181
+ reason: "claimed_by_another_drain",
182
+ };
183
+ }
184
+
185
+ /** Take the lease unless this drainer already holds it. A busy lease means
186
+ * another drainer owns the claim — skip without touching content. */
187
+ async function adoptLease(
188
+ claims: BufferDrainClaims,
189
+ key: string,
190
+ ownerId: string,
191
+ leaseMs: number
192
+ ): Promise<DrainSessionOutcome | null> {
193
+ const held = await claims.adopt(key, {
194
+ ownerId,
195
+ pid: process.pid,
196
+ host: hostname(),
197
+ acquiredAt: new Date().toISOString(),
198
+ heartbeatAt: new Date().toISOString(),
199
+ leaseMs,
200
+ });
201
+ return held === "busy" ? busyLeaseOutcome(key, pendingTurnCount((await claims.load(key))?.entry)) : null;
202
+ }
203
+
204
+ function assembleOutcome(
205
+ key: string,
206
+ turnsAtStart: number,
207
+ verdict: { status: DrainSessionStatus; reason?: string; turnsRemaining: number },
208
+ counts: { persistedCount: number; durableOutputCount: number },
209
+ denied: Map<string, number>
210
+ ): DrainSessionOutcome {
211
+ const outcome: DrainSessionOutcome = {
212
+ key,
213
+ status: verdict.status,
214
+ turnsAtStart,
215
+ turnsRemaining: verdict.turnsRemaining,
216
+ persistedCount: counts.persistedCount,
217
+ durableOutputCount: counts.durableOutputCount,
218
+ };
219
+ if (denied.size > 0) outcome.denied = [...denied].map(([deniedReason, turns]) => ({ reason: deniedReason, turns }));
220
+ if (verdict.reason !== undefined) outcome.reason = verdict.reason;
221
+ return outcome;
222
+ }
223
+
224
+ /** Ack an EMPTY claim. The ack verdict is the truth: live ingress can merge
225
+ * between the empty read and the ack — a not_empty ack means pending work
226
+ * exists; reload it, release OUR lease, report deferred, never a false
227
+ * drained. */
228
+ async function ackEmptyClaim(
229
+ claims: BufferDrainClaims,
230
+ key: string,
231
+ ownerId: string,
232
+ turnsAtStart: number
233
+ ): Promise<DrainSessionOutcome> {
234
+ const ackOutcome = await claims.ack(key, ownerId);
235
+ if (ackOutcome !== "acked") {
236
+ const reloaded = await claims.load(key);
237
+ const turnsRemaining = pendingTurnCount(reloaded?.entry);
238
+ await claims.releaseLease(key, ownerId);
239
+ return {
240
+ key,
241
+ status: "deferred",
242
+ turnsAtStart,
243
+ turnsRemaining,
244
+ persistedCount: 0,
245
+ durableOutputCount: 0,
246
+ reason: ackOutcome === "not_empty" ? "ack_not_empty" : "ack_not_owner",
247
+ };
248
+ }
249
+ return { key, status: "drained", turnsAtStart, turnsRemaining: 0, persistedCount: 0, durableOutputCount: 0 };
250
+ }
251
+
252
+ /** another drainer took the claim, so this run must stop instead of writing */
253
+ function startLeaseHeartbeat(
254
+ claims: BufferDrainClaims,
255
+ key: string,
256
+ ownerId: string,
257
+ leaseMs: number,
258
+ abort: AbortController
259
+ ): NodeJS.Timeout {
260
+ const heartbeat = setInterval(
261
+ () => {
262
+ void claims
263
+ .heartbeat(key, ownerId, leaseMs)
264
+ .then((state) => {
265
+ if (state === "lost") abort.abort(new Error(`buffer drain: lease lost for session ${key}`));
266
+ })
267
+ .catch((err: Error) => {
268
+ // A heartbeat that cannot be renewed is a lease that cannot be held:
269
+ // stop this run cleanly at the next chunk boundary (the next run
270
+ // resumes it).
271
+ abort.abort(new Error(`buffer drain: lease heartbeat failed for session ${key}: ${describeError(err)}`));
272
+ });
273
+ },
274
+ Math.max(1_000, Math.floor(leaseMs / 3))
275
+ );
276
+ heartbeat.unref?.();
277
+ return heartbeat;
278
+ }
279
+
280
+ /** coordinator bound to it, and the no-drop chunked drain. Built per */
281
+ function buildClaimExecution(
282
+ orchestrator: Orchestrator,
283
+ claims: BufferDrainClaims,
284
+ key: string,
285
+ ownerId: string,
286
+ claim: { entry: BufferEntryState }
287
+ ): ClaimDrainExecution {
288
+ const claimBuffer = new SmartBuffer(orchestrator.config, new ClaimBufferStorage(claims, key, ownerId, claim.entry));
289
+ const isolatedRun = orchestrator.extractionRunCoordinatorForBuffer(claimBuffer);
290
+ const coordinator = new TurnIngestionCoordinator(selfDeps<TurnIngestionDeps>(orchestrator));
291
+ const config = orchestrator.config;
292
+ const ingestionDeps = selfDeps<TurnIngestionDeps>(orchestrator);
293
+ // No-drop: a repeatedly failing chunk stays in the claim as durable
294
+ // retryable backlog — never side-filed, never counted as processed.
295
+ const executionDrain = new ExtractionDrain(
296
+ {
297
+ config: {
298
+ extractionMinUserTurns: config.extractionMinUserTurns,
299
+ extractionMinChars: config.extractionMinChars,
300
+ extractionMaxTurnChars: config.extractionMaxTurnChars,
301
+ extractionChunkDropAfterFailures: config.extractionChunkDropAfterFailures,
302
+ },
303
+ extractionWindowBudgetChars: () => ingestionDeps.extractionWindowBudgetChars?.() ?? Number.POSITIVE_INFINITY,
304
+ // Render-exact chunk sizing: the model clamp bounds the CONVERSATION
305
+ // (truncateLocalExtractionConversation), so measure exactly that —
306
+ // with the SAME source-classification gate extract() applies when it
307
+ // prepends the connector header (header margin included when the
308
+ // engine would render it).
309
+ measureRenderedTurns: (turns) => {
310
+ const sourceConnector = resolveMemoryLifecycleCapabilities(config).extractionScopeClassification
311
+ ? resolveSourceConnector(turns)
312
+ : undefined;
313
+ return renderExtractionConversation(
314
+ turns.map((turn) => ({ ...turn, content: turn.content.trim().slice(0, config.extractionMaxTurnChars) })),
315
+ headerConnector(sourceConnector)
316
+ ).conversation.length;
317
+ },
318
+ runExtraction: (turns, runOptions) => isolatedRun.runExtraction(turns, runOptions),
319
+ getStorage: () => ingestionDeps.getStorage(),
320
+ buffer: claimBuffer,
321
+ },
322
+ { dropFailedChunks: false }
323
+ );
324
+ return { claimBuffer, coordinator, executionDrain };
325
+ }
326
+
327
+ /** Plan semantics for one scope group: a pinned plan forwards as-is; a
328
+ * planless ACCEPTED group pins null (existing flush behavior); unpinned
329
+ * legacy groups pass NO option so runExtraction resolves the CURRENT
330
+ * profile. */
331
+ function planOptionForGroup(group: ExtractionScopeGroup): GroupPlanOption {
332
+ if (group.scopeProfileWritePlan !== undefined) return { scopeProfileWritePlan: group.scopeProfileWritePlan };
333
+ if (group.turns.some((turn) => turn.acceptedCapture === true)) return { scopeProfileWritePlan: null };
334
+ return {};
335
+ }
336
+
337
+ /** captureMode=explicit: ordinary buffered conversation turns never create
338
+ * memories — only deliberately accepted captures drain. */
339
+ function explicitModeDenied(group: ExtractionScopeGroup, config: PluginConfig): boolean {
340
+ return shouldSkipImplicitExtraction(config) && !group.turns.some((turn) => turn.acceptedCapture === true);
341
+ }
342
+
343
+ interface GroupOutcome {
344
+ turns: number;
345
+ result?: ExtractionRunResult;
346
+ }
347
+
348
+ interface ScopeDrainContext {
349
+ config: PluginConfig;
350
+ key: string;
351
+ signal: AbortSignal;
352
+ claimBuffer: SmartBuffer;
353
+ coordinator: TurnIngestionCoordinator;
354
+ executionDrain: ExtractionDrain;
355
+ turns: BufferTurn[];
356
+ }
357
+
358
+ /** Drain a claimed session's turns ONE captured-scope group at a time: each
359
+ * group keeps its own namespace/principal/plan pins, and the shared
360
+ * extraction boundary re-applies CURRENT authorization from the turn's
361
+ * ORIGINAL coding binding before any write. Groups run sequentially (FIFO
362
+ * per key — the queue's same-key exclusion would serialize them anyway); a
363
+ * denied group never blocks or clears the other groups. */
364
+ async function drainScopeGroups(
365
+ ctx: ScopeDrainContext
366
+ ): Promise<{ groupOutcomes: GroupOutcome[]; groupError?: DrainExtractionError }> {
367
+ const groupOutcomes: GroupOutcome[] = [];
368
+ let groupError: DrainExtractionError | undefined;
369
+ for (const group of groupTurnsByExtractionScope(ctx.turns)) {
370
+ if (ctx.signal.aborted) break;
371
+ if (explicitModeDenied(group, ctx.config)) {
372
+ log.warn(
373
+ `buffer drain: session ${ctx.key} denied capture_mode_explicit (${group.turns.length} ordinary turn(s) retained)`
374
+ );
375
+ groupOutcomes.push({
376
+ turns: group.turns.length,
377
+ result: { status: "skipped", reason: "capture_mode_explicit", persistedCount: 0, durableOutputCount: 0 },
378
+ });
379
+ continue;
380
+ }
381
+ const settleGate = Promise.withResolvers<{ cause?: unknown; result?: ExtractionRunResult }>();
382
+ let done = false;
383
+ const onSettle = (cause: unknown, result?: ExtractionRunResult): void => {
384
+ if (done) return;
385
+ done = true;
386
+ settleGate.resolve({ cause, result });
387
+ };
388
+ // Settlement and callback EXIT are tracked separately: the lease is
389
+ // released only after BOTH the queued closure settled and fully exited
390
+ // (an abort settles through its listener while the closure may still be
391
+ // persisting).
392
+ const exited = Promise.withResolvers<void>();
393
+ void ctx.coordinator
394
+ .queueBufferedExtraction(group.turns, "trigger_mode", {
395
+ bufferKey: ctx.key,
396
+ clearBufferAfterExtraction: true,
397
+ clearMatchingTurns: true,
398
+ skipDedupeCheck: true,
399
+ skipCharThreshold: true,
400
+ skipUserTurnThreshold: true,
401
+ // Automatic background work yields to fail-closed foreground flushes
402
+ // (preemptRunning), same as the live trigger path.
403
+ preemptable: true,
404
+ abortSignal: ctx.signal,
405
+ writeNamespaceOverride: group.writeNamespaceOverride,
406
+ principalOverride: group.principalOverride,
407
+ ...planOptionForGroup(group),
408
+ execution: { buffer: ctx.claimBuffer, drain: ctx.executionDrain },
409
+ onTaskSettled: (error, result) => settleGate.resolve({ cause: error, result }),
410
+ onTaskExit: () => exited.resolve(),
411
+ })
412
+ .catch((cause: DrainExtractionError) => onSettle(cause));
413
+ const [verdict] = await Promise.all([settleGate.promise, exited.promise]);
414
+ const settled = verdict;
415
+ if (settled.cause !== undefined) {
416
+ // SAFETY: settle captures whatever the queued closure rejected with;
417
+ // every drain rejection site throws Error values, and a rejection is
418
+ // always a DrainExtractionError in this path.
419
+ groupError = settled.cause as Error;
420
+ break;
421
+ }
422
+ groupOutcomes.push({ turns: group.turns.length, result: settled.result });
423
+ }
424
+ return { groupOutcomes, groupError };
425
+ }
426
+
427
+ /** Denied scope groups: policy refusals leave their turns pending (the group
428
+ * result's turns were NOT cleared), aggregated per reason so a drain is
429
+ * never reported complete while denied backlog remains. */
430
+ function deniedByReason(groupOutcomes: readonly GroupOutcome[]): Map<string, number> {
431
+ const denied = new Map<string, number>();
432
+ for (const entry of groupOutcomes) {
433
+ if (entry.result?.status !== "skipped" || !DENIED_SCOPE_REASONS.has(entry.result.reason ?? "")) continue;
434
+ denied.set(entry.result.reason!, (denied.get(entry.result.reason!) ?? 0) + entry.turns);
435
+ }
436
+ return denied;
437
+ }
438
+
439
+ function lastGroupReason(groupOutcomes: readonly GroupOutcome[]): string | undefined {
440
+ for (let index = groupOutcomes.length - 1; index >= 0; index -= 1) {
441
+ const reason = groupOutcomes[index]?.result?.reason;
442
+ if (reason !== undefined) return reason;
443
+ }
444
+ return undefined;
445
+ }
446
+
447
+ /** the session file is gone for good — but the ack verdict is the truth (live */
448
+ async function settleDrainVerdict(
449
+ claims: BufferDrainClaims,
450
+ key: string,
451
+ ownerId: string,
452
+ groupOutcomes: readonly GroupOutcome[],
453
+ groupError: DrainExtractionError | undefined,
454
+ turnsRemaining: number
455
+ ): Promise<{ status: DrainSessionStatus; reason?: string; turnsRemaining: number }> {
456
+ if (turnsRemaining === 0) return await ackOrReload(claims, key, ownerId);
457
+ if (groupError) {
458
+ await claims.releaseLease(key, ownerId);
459
+ return { status: "failed", reason: describeError(groupError), turnsRemaining };
460
+ }
461
+ await claims.releaseLease(key, ownerId);
462
+ const reason =
463
+ groupOutcomes.find((entry) => entry.result?.reason !== undefined && DENIED_SCOPE_REASONS.has(entry.result.reason!))
464
+ ?.result?.reason ??
465
+ lastGroupReason(groupOutcomes) ??
466
+ "deferred";
467
+ return { status: "deferred", reason, turnsRemaining };
468
+ }
469
+
470
+ /** Ack an emptied claim; a not-acked verdict means live ingress merged (or
471
+ * the lease moved) — reload the honest pending count and release OUR lease,
472
+ * keeping the source. */
473
+ async function ackOrReload(
474
+ claims: BufferDrainClaims,
475
+ key: string,
476
+ ownerId: string
477
+ ): Promise<{ status: DrainSessionStatus; reason?: string; turnsRemaining: number }> {
478
+ const ackOutcome = await claims.ack(key, ownerId);
479
+ if (ackOutcome === "acked") return { status: "drained", turnsRemaining: 0 };
480
+ const reloaded = await claims.load(key);
481
+ const turnsRemaining = pendingTurnCount(reloaded?.entry);
482
+ await claims.releaseLease(key, ownerId);
483
+ return { status: "deferred", reason: ackOutcome === "not_empty" ? "ack_not_empty" : "ack_not_owner", turnsRemaining };
484
+ }
485
+
486
+ function groupCounts(groupOutcomes: readonly GroupOutcome[]): GroupCounts {
487
+ return {
488
+ persistedCount: groupOutcomes.reduce((sum, entry) => sum + (entry.result?.persistedCount ?? 0), 0),
489
+ durableOutputCount: groupOutcomes.reduce((sum, entry) => sum + (entry.result?.durableOutputCount ?? 0), 0),
490
+ };
491
+ }
492
+
493
+ export interface DrainScanEntry {
494
+ key: string;
495
+ turns: number;
496
+ claimed: boolean;
497
+ }
498
+
499
+ /** Session-buffer listings: a claimed session reports its CLAIM's pending
500
+ * count (the live buffer copy may lag the claim), an unclaimed one its
501
+ * buffer entry. */
502
+ async function bufferSessionListings(
503
+ claims: BufferDrainClaims,
504
+ entries: Record<string, BufferEntryState>
505
+ ): Promise<{ listings: DrainScanEntry[]; seen: Set<string> }> {
506
+ const listings: DrainScanEntry[] = [];
507
+ const seen = new Set<string>();
508
+ for (const [key, entry] of Object.entries(entries)) {
509
+ seen.add(key);
510
+ const claim = claims.isClaimed(key) ? await claims.load(key) : null;
511
+ listings.push({
512
+ key,
513
+ turns: claim ? pendingTurnCount(claim.entry) : pendingTurnCount(entry),
514
+ claimed: claim !== null,
515
+ });
516
+ }
517
+ return { listings, seen };
518
+ }
519
+
520
+ /** Claims the buffer state no longer lists (drained by an earlier run whose
521
+ * claim file outlived its session file). */
522
+ async function orphanClaimListings(claims: BufferDrainClaims, seen: Set<string>): Promise<DrainScanEntry[]> {
523
+ const out: DrainScanEntry[] = [];
524
+ for (const listed of await claims.list()) {
525
+ if (seen.has(listed.key)) continue;
526
+ out.push({ key: listed.key, turns: listed.turnCount, claimed: true });
527
+ }
528
+ return out;
529
+ }
530
+
531
+ /** The drain runs on the caller's abort signal combined with this run's own
532
+ * lease-loss controller. */
533
+ function drainAbortSignal(callerSignal: AbortSignal | undefined, abort: AbortController): AbortSignal {
534
+ return callerSignal ? AbortSignal.any([callerSignal, abort.signal]) : abort.signal;
535
+ }
536
+
537
+ /** One heartbeat-protected claim drain: run every scope group, settle the
538
+ * verdict against the claim store, and assemble the honest outcome. The
539
+ * heartbeat is cleared by the caller on every exit path. */
540
+ async function runClaimDrain(
541
+ ctx: {
542
+ orchestrator: Orchestrator;
543
+ claims: BufferDrainClaims;
544
+ key: string;
545
+ ownerId: string;
546
+ claimBuffer: SmartBuffer;
547
+ coordinator: TurnIngestionCoordinator;
548
+ executionDrain: ExtractionDrain;
549
+ turns: BufferTurn[];
550
+ turnsAtStart: number;
551
+ config: PluginConfig;
552
+ signal: AbortSignal;
553
+ },
554
+ options: BufferDrainRunnerOptions,
555
+ heartbeat: NodeJS.Timeout
556
+ ): Promise<DrainSessionOutcome> {
557
+ // An emptied claim acks here: the ack verdict is the truth (drained), or
558
+ // live ingress merged between the read and the ack — deferred, source kept.
559
+ if (ctx.turns.length === 0) {
560
+ const outcome = await ackEmptyClaim(ctx.claims, ctx.key, ctx.ownerId, ctx.turnsAtStart);
561
+ options.onEvent?.({ type: "session_finished", key: ctx.key, outcome });
562
+ return outcome;
563
+ }
564
+ const { groupOutcomes, groupError } = await drainScopeGroups(ctx);
565
+ // Heartbeat cleared on EVERY exit path: a rejected drain must not keep
566
+ // renewing the lease forever (cleared in the caller's finally).
567
+ const remaining = await ctx.claims.load(ctx.key);
568
+ const { status, reason, turnsRemaining } = await settleDrainVerdict(
569
+ ctx.claims,
570
+ ctx.key,
571
+ ctx.ownerId,
572
+ groupOutcomes,
573
+ groupError,
574
+ pendingTurnCount(remaining?.entry)
575
+ );
576
+ const outcome = assembleOutcome(
577
+ ctx.key,
578
+ ctx.turnsAtStart,
579
+ { status, reason, turnsRemaining },
580
+ groupCounts(groupOutcomes),
581
+ deniedByReason(groupOutcomes)
582
+ );
583
+ log.info(`buffer drain: session ${ctx.key} ${status} (${outcome.turnsRemaining} turn(s) remaining)`);
584
+ options.onEvent?.({ type: "session_finished", key: ctx.key, outcome });
585
+ return outcome;
586
+ }
587
+
116
588
  export class BufferDrainRunner {
117
589
  constructor(private readonly orchestrator: Orchestrator) {}
118
590
 
@@ -128,150 +600,59 @@ export class BufferDrainRunner {
128
600
  async drainSession(key: string, options: BufferDrainRunnerOptions): Promise<DrainSessionOutcome> {
129
601
  const claims = this.claims;
130
602
  const leaseMs = options.leaseMs ?? DEFAULT_LEASE_MS;
131
- const lease: DrainLease = {
132
- ownerId: options.ownerId,
133
- pid: process.pid,
134
- host: hostname(),
135
- acquiredAt: new Date().toISOString(),
136
- heartbeatAt: new Date().toISOString(),
137
- leaseMs,
138
- };
139
- const claim = await claims.load(key);
140
- if (!claim) {
141
- return { key, status: "skipped", turnsAtStart: 0, turnsRemaining: 0, persistedCount: 0, durableOutputCount: 0, reason: "not_claimed" };
142
- }
143
- const turnsAtStart = claim.entry.turns.length + (claim.entry.retainedTurns?.length ?? 0);
603
+ const claimed = await loadClaimedSession(claims, key);
604
+ if (claimed.kind === "notClaimed") return claimed.notClaimed;
605
+ const { claim, turnsAtStart } = claimed;
144
606
  options.onEvent?.({ type: "session_started", key, turns: turnsAtStart });
145
607
 
146
608
  // Take the lease unless this drainer already holds it.
147
- const held = await claims.adopt(key, lease);
148
- if (held === "busy") {
149
- const outcome: DrainSessionOutcome = {
150
- key,
151
- status: "skipped",
152
- turnsAtStart,
153
- turnsRemaining: turnsAtStart,
154
- persistedCount: 0,
155
- durableOutputCount: 0,
156
- reason: "claimed_by_another_drain",
157
- };
609
+ const busy = await adoptLease(claims, key, options.ownerId, leaseMs);
610
+ if (busy !== null) {
611
+ const outcome = busy;
158
612
  options.onEvent?.({ type: "session_finished", key, outcome });
159
613
  return outcome;
160
614
  }
161
615
 
162
- // The claim is the buffer for this run: every successful chunk is
163
- // removed from it, so a kill leaves exactly the un-extracted turns.
164
- // The swap covers the whole extraction (its clear path reads the live
165
- // buffer), so progress lands in the claim, not in a session file.
166
- const claimStorage = new ClaimBufferStorage(claims, key, options.ownerId, claim.entry);
167
- const coordinator = new TurnIngestionCoordinator(selfDeps<TurnIngestionDeps>(this.orchestrator));
616
+ // The claim is the buffer for this run: every successful chunk is removed
617
+ // from it, so a kill leaves exactly the un-extracted turns.
618
+ const { claimBuffer, coordinator, executionDrain } = buildClaimExecution(
619
+ this.orchestrator,
620
+ claims,
621
+ key,
622
+ options.ownerId,
623
+ claim
624
+ );
625
+ await claimBuffer.load();
168
626
 
169
627
  // The claim's turns (retained first, like the live buffer's order).
170
628
  const turns = [...(claim.entry.retainedTurns ?? []), ...claim.entry.turns];
171
- if (turns.length === 0) {
172
- await claims.ack(key, options.ownerId);
173
- const outcome: DrainSessionOutcome = {
174
- key,
175
- status: "drained",
176
- turnsAtStart,
177
- turnsRemaining: 0,
178
- persistedCount: 0,
179
- durableOutputCount: 0,
180
- };
181
- options.onEvent?.({ type: "session_finished", key, outcome });
182
- return outcome;
183
- }
184
629
 
185
- // Keep the lease alive while a long extraction runs; a lost lease means
186
- // another drainer took the claim, so this run must stop instead of
187
- // writing progress into a claim it no longer owns.
630
+ // A lost lease means another drainer took the claim: stop instead of
631
+ // writing progress into a claim this run no longer owns.
188
632
  const abort = new AbortController();
189
- const signal = options.signal ? AbortSignal.any([options.signal, abort.signal]) : abort.signal;
190
- const heartbeat = setInterval(() => {
191
- void claims
192
- .heartbeat(key, options.ownerId, leaseMs)
193
- .then((state) => {
194
- if (state === "lost") abort.abort(new Error(`buffer drain: lease lost for session ${key}`));
195
- })
196
- .catch((err: Error) => {
197
- // A heartbeat that cannot be renewed is a lease that cannot be
198
- // held: another drainer may adopt the claim, so stop this run
199
- // cleanly at the next chunk boundary (the next run resumes it).
200
- abort.abort(new Error(`buffer drain: lease heartbeat failed for session ${key}: ${describeError(err)}`));
201
- });
202
- }, Math.max(1_000, Math.floor(leaseMs / 3)));
203
- heartbeat.unref?.();
204
-
205
- const settledResult = await this.orchestrator.withBufferStorage(claimStorage, async () => {
206
- const swapTurns = this.orchestrator.buffer.getTurns(key);
207
- const { promise, resolve } = Promise.withResolvers<{ error?: unknown; result?: ExtractionRunResult }>();
208
- let settled = false;
209
- void coordinator
210
- .queueBufferedExtraction(swapTurns, "trigger_mode", {
211
- bufferKey: key,
212
- clearBufferAfterExtraction: true,
213
- // The drain owns this session's content: the daemon's own dedupe
214
- // gate must not skip it, and the thresholds do not apply to a
215
- // backlog.
216
- skipDedupeCheck: true,
217
- skipCharThreshold: true,
218
- skipUserTurnThreshold: true,
219
- abortSignal: signal,
220
- onTaskSettled: (error, result) => {
221
- if (settled) return;
222
- settled = true;
223
- resolve({ error, result });
224
- },
225
- })
226
- .catch((error: DrainExtractionError) => {
227
- if (settled) return;
228
- settled = true;
229
- resolve({ error });
230
- });
231
- return await promise;
232
- });
233
- clearInterval(heartbeat);
234
- const remaining = await claims.load(key);
235
- const turnsRemaining = remaining
236
- ? remaining.entry.turns.length + (remaining.entry.retainedTurns?.length ?? 0)
237
- : 0;
238
- const result = settledResult.result;
239
- const persistedCount = result?.persistedCount ?? 0;
240
- const durableOutputCount = result?.durableOutputCount ?? 0;
241
-
242
- let status: DrainSessionStatus;
243
- let reason: string | undefined;
244
- if (turnsRemaining === 0) {
245
- // Everything extracted (or dropped with a recovery side-file): the
246
- // claim is finished and the session file is gone for good.
247
- await claims.ack(key, options.ownerId);
248
- status = "drained";
249
- } else if (settledResult.error) {
250
- // A failed run keeps the remaining turns for the next attempt.
251
- await claims.releaseLease(key, options.ownerId);
252
- status = "failed";
253
- reason = describeError(settledResult.error);
254
- } else {
255
- // Deferred: provider breaker / backoff / a chunk still pending. The
256
- // content stays claimed and the next run resumes it.
257
- await claims.releaseLease(key, options.ownerId);
258
- status = "deferred";
259
- reason = result?.reason ?? "deferred";
633
+ const signal = drainAbortSignal(options.signal, abort);
634
+ const heartbeat = startLeaseHeartbeat(claims, key, options.ownerId, leaseMs, abort);
635
+ try {
636
+ return await runClaimDrain(
637
+ {
638
+ orchestrator: this.orchestrator,
639
+ claims,
640
+ key,
641
+ ownerId: options.ownerId,
642
+ claimBuffer,
643
+ coordinator,
644
+ executionDrain,
645
+ turns,
646
+ turnsAtStart,
647
+ config: this.orchestrator.config,
648
+ signal,
649
+ },
650
+ options,
651
+ heartbeat
652
+ );
653
+ } finally {
654
+ clearInterval(heartbeat);
260
655
  }
261
- log.info(
262
- `buffer drain: session ${key} ${status} (${turnsRemaining} turn(s) remaining)`,
263
- );
264
- const outcome: DrainSessionOutcome = {
265
- key,
266
- status,
267
- turnsAtStart,
268
- turnsRemaining,
269
- persistedCount,
270
- durableOutputCount,
271
- };
272
- if (reason !== undefined) outcome.reason = reason;
273
- options.onEvent?.({ type: "session_finished", key, outcome });
274
- return outcome;
275
656
  }
276
657
 
277
658
  /** Claim a session file for the drain. Returns false when it is not claimable. */
@@ -288,25 +669,9 @@ export class BufferDrainRunner {
288
669
  }
289
670
 
290
671
  /** Sessions with buffered turns, claimed or not (CLI scan). */
291
- async scan(): Promise<{ key: string; turns: number; claimed: boolean }[]> {
672
+ async scan(): Promise<DrainScanEntry[]> {
292
673
  const sessions = await this.orchestrator.storage.loadBuffer();
293
- const entries = sessions.entries ?? {};
294
- const out: { key: string; turns: number; claimed: boolean }[] = [];
295
- const seen = new Set<string>();
296
- for (const [key, entry] of Object.entries(entries)) {
297
- seen.add(key);
298
- const claim = this.claims.isClaimed(key) ? await this.claims.load(key) : null;
299
- const turns = claim
300
- ? claim.entry.turns.length + (claim.entry.retainedTurns?.length ?? 0)
301
- : entry.turns.length + (entry.retainedTurns?.length ?? 0);
302
- out.push({ key, turns, claimed: claim !== null });
303
- }
304
- // Claims the buffer state no longer lists (drained by an earlier run
305
- // whose claim file outlived its session file).
306
- for (const listed of await this.claims.list()) {
307
- if (seen.has(listed.key)) continue;
308
- out.push({ key: listed.key, turns: listed.turnCount, claimed: true });
309
- }
310
- return out;
674
+ const { listings, seen } = await bufferSessionListings(this.claims, sessions.entries ?? {});
675
+ return [...listings, ...(await orphanClaimListings(this.claims, seen))];
311
676
  }
312
677
  }