@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,481 @@
1
+ /**
2
+ * Daemon-owned extraction backlog scheduler.
3
+ *
4
+ * ONE bounded in-process loop that owns ALL persisted extraction backlog:
5
+ * at startup (existing drain claims + buffered sessions), on every poll tick
6
+ * (new persisted work), and by retrying keys that are still pending — with
7
+ * bounded memory (per-key COUNTS only; candidate scans never retain turn
8
+ * payloads) and bounded outstanding work (the extraction queue's own
9
+ * maxConcurrency gates submissions, so live foreground work keeps its share
10
+ * of the queue).
11
+ *
12
+ * Claim authority: an existing claim — including an EMPTY ack tombstone — is
13
+ * authoritative over the live buffer's in-memory copy. A tombstoned key is
14
+ * never re-claimed or counted as work; new ingress consumes the tombstone
15
+ * through the native save handover, which is what makes the key a candidate
16
+ * again. Claim creation is fenced: the key is reserved synchronously on the
17
+ * extraction queue BEFORE the async claim decision, so a queued live task for
18
+ * the same key cannot start past the claimed-session gate until the decision
19
+ * settles; live capture continues via the native merge-into-claim protocol.
20
+ *
21
+ * Failed, deferred, or policy-denied outcomes keep their turns durable in the
22
+ * claim and re-submit after backoff (exponential from `retryBaseMs` to
23
+ * `retryMaxMs`, reset by progress; denials wait the full max) — never a busy
24
+ * retry. `stop()` aborts in-flight drains at their chunk boundary and leaves
25
+ * every claim retryable; a restarted daemon's scheduler adopts them.
26
+ */
27
+
28
+ import { hostname } from "node:os";
29
+ import { log } from "../logger.js";
30
+ import type { Orchestrator } from "../orchestrator.js";
31
+ import type { BufferDrainClaims, DrainLease } from "../storage/buffer-drain-claims.js";
32
+ import { BufferDrainRunner } from "./buffer-drain-runner.js";
33
+ import type { DrainRunnerEvent, DrainSessionOutcome } from "./buffer-drain-runner.js";
34
+ import type { ExtractionQueueCoordinator } from "./extraction-queue-coordinator.js";
35
+
36
+ /** What the scheduler needs from its host (structurally satisfied by the
37
+ * orchestrator wiring); injectable so tests drive ticks deterministically. */
38
+ export interface BacklogSchedulerDeps {
39
+ queue: Pick<ExtractionQueueCoordinator, "tryReserveKey" | "releaseKeyReservation" | "maxConcurrency">;
40
+ claims: Pick<BufferDrainClaims, "list" | "listPage" | "claimFromSessionFile" | "isClaimed">;
41
+ /** Live per-key pending counts (counts only — never turn payloads). May be
42
+ * async: hosts with a lazily-loaded buffer load it here (idempotent). */
43
+ liveBufferCounts():
44
+ | Map<string, { turns: number; retainedTurns: number }>
45
+ | Promise<Map<string, { turns: number; retainedTurns: number }>>;
46
+ /** A fresh lease for claims this scheduler takes. */
47
+ lease(): DrainLease;
48
+ /** Drain one key to quiescence (BufferDrainRunner.drainSession). */
49
+ drainKey(
50
+ key: string,
51
+ options: { ownerId: string; signal: AbortSignal; onEvent?: (event: DrainRunnerEvent) => void }
52
+ ): Promise<DrainSessionOutcome>;
53
+ /** Test seam: monotonic-ish clock for backoff windows. */
54
+ now?(): number;
55
+ }
56
+
57
+ export interface BacklogSchedulerOptions {
58
+ ownerId?: string;
59
+ /** Scan cadence in ms (startup + new-work + retry checks). */
60
+ pollMs?: number;
61
+ retryBaseMs?: number;
62
+ retryMaxMs?: number;
63
+ now?: () => number;
64
+ }
65
+
66
+ export interface BacklogSchedulerCounters {
67
+ /** Keys submitted to the drain (claimed or adopted) this process lifetime. */
68
+ claimed: number;
69
+ /** Outcomes with genuinely empty claims (durable outputs preceded ack). */
70
+ processed: number;
71
+ /** Sum of durable outputs (memories + entities) across processed drains. */
72
+ durableOutputs: number;
73
+ /** Re-submissions after a failed/deferred/denied outcome. */
74
+ retried: number;
75
+ denied: { keys: number; turns: number };
76
+ failed: number;
77
+ }
78
+
79
+ export type BacklogKeyState = "inflight" | "backoff" | "denied";
80
+
81
+ export interface BacklogKeySnapshot {
82
+ key: string;
83
+ state: BacklogKeyState;
84
+ turnsRemaining?: number;
85
+ reason?: string;
86
+ }
87
+
88
+ export interface BacklogSchedulerSnapshot {
89
+ counters: BacklogSchedulerCounters;
90
+ inFlight: string[];
91
+ reservedKeys: string[];
92
+ keys: BacklogKeySnapshot[];
93
+ }
94
+
95
+ interface KeyState {
96
+ state: BacklogKeyState;
97
+ nextAttemptMs: number;
98
+ /** Last observed remaining turns; progress resets the backoff exponent. */
99
+ turnsRemaining: number;
100
+ reason?: string;
101
+ backoffExponent: number;
102
+ }
103
+
104
+ const DEFAULT_POLL_MS = 15_000;
105
+ const DEFAULT_RETRY_BASE_MS = 30_000;
106
+ const DEFAULT_RETRY_MAX_MS = 15 * 60_000;
107
+
108
+ export class BacklogScheduler {
109
+ private readonly deps: BacklogSchedulerDeps;
110
+ private readonly options: Required<Omit<BacklogSchedulerOptions, "now">> & Pick<BacklogSchedulerOptions, "now">;
111
+ private readonly keys = new Map<string, KeyState>();
112
+ private readonly inFlight = new Map<string, { controller: AbortController; settled: Promise<void> }>();
113
+ private readonly reserved = new Set<string>();
114
+ private readonly counters: BacklogSchedulerCounters = {
115
+ claimed: 0,
116
+ processed: 0,
117
+ durableOutputs: 0,
118
+ retried: 0,
119
+ denied: { keys: 0, turns: 0 },
120
+ failed: 0,
121
+ };
122
+ private claimCursor: string | undefined;
123
+ private timer: ReturnType<typeof setInterval> | undefined;
124
+ private ticking = false;
125
+ private stopped = true;
126
+
127
+ constructor(deps: BacklogSchedulerDeps, options: BacklogSchedulerOptions = {}) {
128
+ this.deps = deps;
129
+ this.options = {
130
+ ownerId: options.ownerId ?? `daemon-drain-${process.pid}`,
131
+ pollMs: options.pollMs ?? DEFAULT_POLL_MS,
132
+ retryBaseMs: options.retryBaseMs ?? DEFAULT_RETRY_BASE_MS,
133
+ retryMaxMs: options.retryMaxMs ?? DEFAULT_RETRY_MAX_MS,
134
+ now: options.now,
135
+ };
136
+ }
137
+
138
+ /** Idempotent. Starts the poll timer (unref'd) and runs an initial tick. */
139
+ start(): void {
140
+ if (!this.stopped) return;
141
+ this.stopped = false;
142
+ this.timer = setInterval(() => void this.tick(), this.options.pollMs);
143
+ this.timer.unref?.();
144
+ void this.tick();
145
+ }
146
+
147
+ /** Idempotent. Stops the timer, aborts in-flight drains (their claims stay
148
+ * retryable) and waits for them to settle. */
149
+ async stop(): Promise<void> {
150
+ if (this.stopped) return;
151
+ this.stopped = true;
152
+ if (this.timer) {
153
+ clearInterval(this.timer);
154
+ this.timer = undefined;
155
+ }
156
+ const aborts: Promise<void>[] = [];
157
+ for (const entry of this.inFlight.values()) {
158
+ entry.controller.abort(new Error("backlog scheduler stopping"));
159
+ aborts.push(entry.settled);
160
+ }
161
+ await Promise.allSettled(aborts);
162
+ }
163
+
164
+ /** Bounded snapshot for ops/watchers: counters + per-key states + live sets. */
165
+ snapshot(): BacklogSchedulerSnapshot {
166
+ return {
167
+ counters: {
168
+ claimed: this.counters.claimed,
169
+ processed: this.counters.processed,
170
+ durableOutputs: this.counters.durableOutputs,
171
+ retried: this.counters.retried,
172
+ denied: { ...this.counters.denied },
173
+ failed: this.counters.failed,
174
+ },
175
+ inFlight: [...this.inFlight.keys()],
176
+ reservedKeys: [...this.reserved],
177
+ keys: [...this.keys.entries()].map(([key, state]) => ({
178
+ key,
179
+ state: state.state,
180
+ turnsRemaining: state.turnsRemaining,
181
+ reason: state.reason,
182
+ })),
183
+ };
184
+ }
185
+
186
+ /** One scan+submit pass (tests drive this instead of the timer). Waits out
187
+ * any in-progress tick first so the pass always actually scans. */
188
+ async tickForTest(): Promise<void> {
189
+ // setImmediate: yields to the event loop so pending I/O (a slow first
190
+ // scan) can finish; a microtask spin would starve it.
191
+ while (this.ticking) await new Promise((resolve) => setImmediate(resolve));
192
+ await this.tick();
193
+ }
194
+
195
+ /** Await the settle (slot release) of everything currently in flight,
196
+ * optionally excluding one still-gated key (tests). */
197
+ async settleForTest(exclude?: string): Promise<void> {
198
+ for (;;) {
199
+ const pending = [...this.inFlight.entries()].filter(([key]) => key !== exclude).map(([, entry]) => entry.settled);
200
+ if (pending.length === 0) return;
201
+ await Promise.allSettled(pending);
202
+ }
203
+ }
204
+
205
+ private now(): number {
206
+ return this.options.now?.() ?? Date.now();
207
+ }
208
+
209
+ private async tick(): Promise<void> {
210
+ if (this.ticking || this.stopped) return;
211
+ this.ticking = true;
212
+ const cursorBeforeTick = this.claimCursor;
213
+ try {
214
+ // stop() may land while discovery's I/O is pending: re-check before
215
+ // submitting anything, and again per candidate.
216
+ // A discovery failure (EACCES/EIO on the claims dir) must not reject
217
+ // into a detached timer callback: it would be an unhandled rejection
218
+ // that terminates the server. Log, skip this pass, retry next poll.
219
+ let candidates: { key: string; needsClaim: boolean }[];
220
+ try {
221
+ candidates = await this.discoverCandidates();
222
+ } catch (err) {
223
+ // Discovery may have listed a page before it failed: the page cursor
224
+ // already moved, so restore it — otherwise those claims wait for the
225
+ // cursor to wrap instead of the next poll.
226
+ this.claimCursor = cursorBeforeTick;
227
+ log.warn(
228
+ `backlog drain: discovery failed; retrying next poll: ${err instanceof Error ? err.message : String(err)}`
229
+ );
230
+ return;
231
+ }
232
+ if (this.stopped) return;
233
+ const dropped = this.submitCandidates(candidates);
234
+ // The concurrency bound stopped submission mid-page: rewind the page
235
+ // cursor so the unsubmitted claims are re-listed on the next tick
236
+ // (already-submitted ones are gated by inFlight and key backoff).
237
+ if (dropped) this.claimCursor = cursorBeforeTick;
238
+ } finally {
239
+ this.ticking = false;
240
+ }
241
+ }
242
+
243
+ /** Bounded discovery: ONE page of existing claims with pending turns */
244
+ private async discoverCandidates(): Promise<{ key: string; needsClaim: boolean }[]> {
245
+ const out: { key: string; needsClaim: boolean }[] = [];
246
+ const seen = new Set<string>();
247
+ const pageLimit = Math.max(this.submissionLimit() * 4, 16);
248
+ const page = await this.deps.claims.listPage(this.claimCursor, pageLimit);
249
+ for (const listed of page.entries) {
250
+ seen.add(listed.key);
251
+ out.push({ key: listed.key, needsClaim: false });
252
+ }
253
+ this.claimCursor = page.nextCursor;
254
+ for (const [key, counts] of await this.deps.liveBufferCounts()) {
255
+ if (counts.turns + counts.retainedTurns <= 0) continue;
256
+ if (seen.has(key) || this.deps.claims.isClaimed(key)) continue;
257
+ seen.add(key);
258
+ out.push({ key, needsClaim: true });
259
+ }
260
+ return out;
261
+ }
262
+
263
+ /** Submission bound: the queue's own concurrency limit, read with the
264
+ * documented "0 disables" contract — a zero limit submits nothing. */
265
+ private submissionLimit(): number {
266
+ if (this.deps.queue.maxConcurrency <= 0) return 0;
267
+ return this.deps.queue.maxConcurrency;
268
+ }
269
+
270
+ /** Submit discovery candidates within the queue's concurrency bound. */
271
+ private submitCandidates(candidates: { key: string; needsClaim: boolean }[]): boolean {
272
+ let dropped = false;
273
+ for (const candidate of candidates) {
274
+ if (this.stopped || this.inFlight.size >= this.submissionLimit()) {
275
+ dropped = true;
276
+ break;
277
+ }
278
+ if (!this.candidateEligible(candidate)) continue;
279
+ // Fence the claim decision: reserve the key synchronously BEFORE the
280
+ // async rename so no queued task for it can pass the claimed gate in
281
+ // the meantime. Release happens when the decision settles.
282
+ if (candidate.needsClaim) {
283
+ if (!this.deps.queue.tryReserveKey(candidate.key)) continue;
284
+ this.reserved.add(candidate.key);
285
+ }
286
+ void this.submit(candidate.key, candidate.needsClaim);
287
+ }
288
+ return dropped;
289
+ }
290
+
291
+ /** inFlight is the authority for "already submitted" — key state only
292
+ * exists once an outcome landed (backoff/denied gating below). */
293
+ private candidateEligible(candidate: { key: string; needsClaim: boolean }): boolean {
294
+ if (this.stopped || this.inFlight.size >= this.submissionLimit()) return false;
295
+ if (this.inFlight.has(candidate.key)) return false;
296
+ const state = this.keys.get(candidate.key);
297
+ return !(state && this.now() < state.nextAttemptMs);
298
+ }
299
+
300
+ /** Claim (when needed), then drain one key; the reservation spans the async
301
+ * claim decision, the queue entry carries the key afterwards. */
302
+ private async submit(key: string, needsClaim: boolean): Promise<void> {
303
+ const controller = new AbortController();
304
+ const { promise: settled, resolve: settle } = Promise.withResolvers<void>();
305
+ this.inFlight.set(key, { controller, settled });
306
+ try {
307
+ if (!(await this.claimForSubmit(key, needsClaim))) return;
308
+ // Stopped between the claim decision and the task: leave the claim
309
+ // with its lease (it expires; the next start adopts) — never drain.
310
+ if (this.stopped) return;
311
+ await this.drainClaimedKey(key, controller.signal);
312
+ } catch (err) {
313
+ // Claim/drain failures are scheduler-level: the claim content is
314
+ // untouched — count failed and back off (never an unhandled rejection
315
+ // in the detached submission).
316
+ this.counters.failed += 1;
317
+ const message = err instanceof Error ? err.message : String(err);
318
+ // SAFETY: a thrown non-Error (incl. null) carries no retry hint; the
319
+ // optional chain reads the hint off Error throws only.
320
+ this.setBackoff(key, (err as { turnsRemaining?: number } | null | undefined)?.turnsRemaining ?? 0, message);
321
+ log.warn(`backlog drain: ${key} submission failed: ${message}`);
322
+ } finally {
323
+ this.inFlight.delete(key);
324
+ this.reserved.delete(key);
325
+ this.deps.queue.releaseKeyReservation(key);
326
+ settle();
327
+ }
328
+ }
329
+
330
+ /** after all — the key goes quiet without a task. "claimed": ours. */
331
+ private async claimForSubmit(key: string, needsClaim: boolean): Promise<boolean> {
332
+ if (!needsClaim) return true;
333
+ try {
334
+ const outcome = await this.deps.claims.claimFromSessionFile(key, this.deps.lease());
335
+ if (outcome === "empty") {
336
+ this.keys.delete(key);
337
+ return false;
338
+ }
339
+ return true;
340
+ } finally {
341
+ this.reserved.delete(key);
342
+ this.deps.queue.releaseKeyReservation(key);
343
+ }
344
+ }
345
+
346
+ /** Drain one claimed key. drainKey reports failures as outcomes; a thrown
347
+ * error still means the claim content is intact — count it failed and
348
+ * back off. */
349
+ private async drainClaimedKey(key: string, signal: AbortSignal): Promise<void> {
350
+ this.counters.claimed += 1;
351
+ try {
352
+ const outcome = await this.deps.drainKey(key, {
353
+ ownerId: this.options.ownerId,
354
+ signal,
355
+ });
356
+ this.applyOutcome(key, outcome);
357
+ } catch (err) {
358
+ this.counters.failed += 1;
359
+ const message = err instanceof Error ? err.message : String(err);
360
+ // SAFETY: a thrown non-Error (incl. null) carries no retry hint; the
361
+ // optional chain reads the hint off Error throws only.
362
+ this.setBackoff(key, (err as { turnsRemaining?: number } | null | undefined)?.turnsRemaining ?? 0, message);
363
+ log.warn(`backlog drain: ${key} threw: ${message}`);
364
+ }
365
+ }
366
+
367
+ private applyOutcome(key: string, outcome: DrainSessionOutcome): void {
368
+ const deniedTurns = outcome.denied?.reduce((sum, entry) => sum + entry.turns, 0) ?? 0;
369
+ const previous = this.keys.get(key);
370
+ const progressed = outcome.turnsRemaining < (previous?.turnsRemaining ?? Number.POSITIVE_INFINITY);
371
+ if (outcome.status === "drained") {
372
+ // The claim is now an empty tombstone: authoritative, nothing pending.
373
+ // New ingress consumes it via the native save path and re-candidates.
374
+ this.keys.delete(key);
375
+ this.counters.processed += 1;
376
+ this.counters.durableOutputs += outcome.durableOutputCount;
377
+ log.info(
378
+ `backlog drain: ${key} drained (${outcome.turnsAtStart} turn(s), ${outcome.durableOutputCount} durable output(s))`
379
+ );
380
+ return;
381
+ }
382
+ this.recordNonDrainedCounters(previous, outcome, deniedTurns);
383
+ const denied = deniedTurns > 0 && outcome.turnsRemaining > 0;
384
+ this.keys.set(key, this.nextKeyState(previous, outcome, denied, progressed));
385
+ log.info(
386
+ `backlog drain: ${key} ${outcome.status} (${outcome.turnsRemaining} turn(s) remaining` +
387
+ `${denied ? `, denied: ${deniedTurns}` : ""}${outcome.reason ? `, ${outcome.reason}` : ""})`
388
+ );
389
+ }
390
+
391
+ /** Denied backlog counters for a non-drained outcome. */
392
+ private recordNonDrainedCounters(
393
+ previous: KeyState | undefined,
394
+ outcome: DrainSessionOutcome,
395
+ deniedTurns: number
396
+ ): void {
397
+ if (deniedTurns > 0) {
398
+ this.counters.denied.keys += 1;
399
+ this.counters.denied.turns += deniedTurns;
400
+ }
401
+ if (outcome.status === "failed") this.counters.failed += 1;
402
+ else if (previous !== undefined && (previous.state === "backoff" || previous.state === "denied"))
403
+ this.counters.retried += 1;
404
+ }
405
+
406
+ /** The post-drain key state: denial waits the full max window (its content
407
+ * is valid but currently unauthorized); progress resets the exponent. */
408
+ private nextKeyState(
409
+ previous: KeyState | undefined,
410
+ outcome: DrainSessionOutcome,
411
+ denied: boolean,
412
+ progressed: boolean
413
+ ): KeyState {
414
+ return {
415
+ state: denied ? "denied" : "backoff",
416
+ nextAttemptMs: this.now() + this.backoffMs(previous, progressed && !denied),
417
+ turnsRemaining: outcome.turnsRemaining,
418
+ reason: outcome.reason,
419
+ backoffExponent: this.nextBackoffExponent(denied, progressed, previous),
420
+ };
421
+ }
422
+
423
+ private nextBackoffExponent(denied: boolean, progressed: boolean, previous: KeyState | undefined): number {
424
+ return denied || !progressed ? (previous?.backoffExponent ?? 0) + 1 : 0;
425
+ }
426
+
427
+ private backoffMs(previous: KeyState | undefined, progressed: boolean): number {
428
+ if (previous?.state === "denied") return this.options.retryMaxMs;
429
+ const exponent = progressed ? 0 : (previous?.backoffExponent ?? 0) + 1;
430
+ return Math.min(this.options.retryBaseMs * 2 ** exponent, this.options.retryMaxMs);
431
+ }
432
+
433
+ private setBackoff(key: string, turnsRemaining: number, reason: string): void {
434
+ const previous = this.keys.get(key);
435
+ this.keys.set(key, {
436
+ state: "backoff",
437
+ nextAttemptMs: this.now() + this.backoffMs(previous, false),
438
+ turnsRemaining,
439
+ reason,
440
+ backoffExponent: (previous?.backoffExponent ?? 0) + 1,
441
+ });
442
+ }
443
+ }
444
+
445
+ /** Lease lifetime for claims the scheduler takes (matches the runner default). */
446
+ const SCHEDULER_LEASE_MS = 120_000;
447
+
448
+ /** Wire the scheduler to an orchestrator's own queue, claim store, live buffer
449
+ * counts and drain runner — the daemon's single ownership point. */
450
+ export function wireBacklogScheduler(
451
+ orchestrator: Orchestrator,
452
+ options: BacklogSchedulerOptions = {}
453
+ ): BacklogScheduler {
454
+ const ownerId = options.ownerId ?? `daemon-drain-${process.pid}`;
455
+ const runner = new BufferDrainRunner(orchestrator);
456
+ return new BacklogScheduler(
457
+ {
458
+ queue: orchestrator.extractionQueueCoordinator,
459
+ claims: orchestrator.storage.bufferDrainClaims(),
460
+ liveBufferCounts: async () => {
461
+ // The daemon's buffer may be lazy: ensure the persisted state is in
462
+ // memory before counting (load is guarded/idempotent).
463
+ await orchestrator.buffer.load();
464
+ return orchestrator.buffer.entryCounts();
465
+ },
466
+ lease: () => {
467
+ const now = new Date().toISOString();
468
+ return {
469
+ ownerId,
470
+ pid: process.pid,
471
+ host: hostname(),
472
+ acquiredAt: now,
473
+ heartbeatAt: now,
474
+ leaseMs: SCHEDULER_LEASE_MS,
475
+ };
476
+ },
477
+ drainKey: (key, drainOptions) => runner.drainSession(key, drainOptions),
478
+ },
479
+ { ...options, ownerId }
480
+ );
481
+ }