@remnic/core 10.0.97 → 10.0.99

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 (47) hide show
  1. package/dist/access-admin-ops-surface.js +1 -1
  2. package/dist/access-cli.js +1 -1
  3. package/dist/access-extraction-force-flush.js +1 -1
  4. package/dist/access-http-briefing.js +1 -1
  5. package/dist/access-http-query.js +1 -1
  6. package/dist/access-http.js +1 -1
  7. package/dist/access-identity-continuity-surface.js +1 -1
  8. package/dist/access-lcm-surface.js +1 -1
  9. package/dist/access-mcp.js +1 -1
  10. package/dist/access-observe-write-surface.js +1 -1
  11. package/dist/access-operations-batch.js +1 -1
  12. package/dist/access-operations.js +1 -1
  13. package/dist/access-recall-concurrency.js +1 -1
  14. package/dist/access-recall-response.js +1 -1
  15. package/dist/access-recall-surface.js +1 -1
  16. package/dist/access-service.js +1 -1
  17. package/dist/{chunk-AMRGZOIP.js → chunk-UBJ5RPDM.js} +2 -2
  18. package/dist/{chunk-3ETADXEP.js → chunk-VZMRKYNR.js} +40 -3
  19. package/dist/chunk-VZMRKYNR.js.map +1 -0
  20. package/dist/cli.js +1 -1
  21. package/dist/explicit-capture.js +1 -1
  22. package/dist/index.js +1 -1
  23. package/dist/orchestration/backlog-scheduler.d.ts +6 -1
  24. package/dist/orchestration/backlog-scheduler.d.ts.map +1 -1
  25. package/dist/orchestration/backlog-scheduler.js +20 -4
  26. package/dist/orchestration/backlog-scheduler.js.map +1 -1
  27. package/dist/orchestration/buffer-drain-runner.js +2 -2
  28. package/dist/orchestration/orchestrator-init.d.ts +3 -0
  29. package/dist/orchestration/orchestrator-init.d.ts.map +1 -1
  30. package/dist/orchestration/task-exit.d.ts +15 -0
  31. package/dist/orchestration/task-exit.d.ts.map +1 -0
  32. package/dist/orchestration/turn-ingestion.d.ts +1 -7
  33. package/dist/orchestration/turn-ingestion.d.ts.map +1 -1
  34. package/dist/orchestrator.d.ts.map +1 -1
  35. package/dist/orchestrator.js +1 -1
  36. package/dist/recall-navigation.js +1 -1
  37. package/package.json +2 -2
  38. package/src/backlog-scheduler.test.ts +48 -0
  39. package/src/orchestration/backlog-scheduler.ts +23 -2
  40. package/src/orchestration/extraction-queue-bounded-retention.test.ts +20 -0
  41. package/src/orchestration/orchestrator-init.ts +25 -0
  42. package/src/orchestration/task-exit.ts +27 -0
  43. package/src/orchestration/turn-ingestion.ts +7 -9
  44. package/src/orchestrator-init.test.ts +132 -1
  45. package/src/orchestrator.ts +3 -0
  46. package/dist/chunk-3ETADXEP.js.map +0 -1
  47. /package/dist/{chunk-AMRGZOIP.js.map → chunk-UBJ5RPDM.js.map} +0 -0
@@ -70,6 +70,8 @@ export interface BacklogSchedulerOptions {
70
70
  pollMs?: number;
71
71
  retryBaseMs?: number;
72
72
  retryMaxMs?: number;
73
+ /** Bound on `stop()`'s wait for aborted drains to settle. */
74
+ stopSettleTimeoutMs?: number;
73
75
  now?: () => number;
74
76
  }
75
77
 
@@ -114,6 +116,9 @@ interface KeyState {
114
116
  const DEFAULT_POLL_MS = 15_000;
115
117
  const DEFAULT_RETRY_BASE_MS = 30_000;
116
118
  const DEFAULT_RETRY_MAX_MS = 15 * 60_000;
119
+ // Aborted drains normally settle within seconds (a chunk boundary); this only
120
+ // bounds the pathological case, well inside the daemon's stop budget.
121
+ const DEFAULT_STOP_SETTLE_TIMEOUT_MS = 30_000;
117
122
 
118
123
  export class BacklogScheduler {
119
124
  private readonly deps: BacklogSchedulerDeps;
@@ -141,6 +146,7 @@ export class BacklogScheduler {
141
146
  pollMs: options.pollMs ?? DEFAULT_POLL_MS,
142
147
  retryBaseMs: options.retryBaseMs ?? DEFAULT_RETRY_BASE_MS,
143
148
  retryMaxMs: options.retryMaxMs ?? DEFAULT_RETRY_MAX_MS,
149
+ stopSettleTimeoutMs: options.stopSettleTimeoutMs ?? DEFAULT_STOP_SETTLE_TIMEOUT_MS,
144
150
  now: options.now,
145
151
  };
146
152
  }
@@ -155,7 +161,10 @@ export class BacklogScheduler {
155
161
  }
156
162
 
157
163
  /** Idempotent. Stops the timer, aborts in-flight drains (their claims stay
158
- * retryable) and waits for them to settle. */
164
+ * retryable) and waits up to `stopSettleTimeoutMs` for them to settle. A
165
+ * drain that ignores abort is named in a warning and left behind: the
166
+ * caller's later shutdown phases (orchestrator flush and dispose) must
167
+ * still run before the service manager's kill. */
159
168
  async stop(): Promise<void> {
160
169
  if (this.stopped) return;
161
170
  this.stopped = true;
@@ -168,7 +177,19 @@ export class BacklogScheduler {
168
177
  entry.controller.abort(new Error("backlog scheduler stopping"));
169
178
  aborts.push(entry.settled);
170
179
  }
171
- await Promise.allSettled(aborts);
180
+ const bound = Promise.withResolvers<"timeout">();
181
+ const boundTimer = setTimeout(() => bound.resolve("timeout"), this.options.stopSettleTimeoutMs);
182
+ try {
183
+ const result = await Promise.race([Promise.allSettled(aborts), bound.promise]);
184
+ if (result === "timeout") {
185
+ log.warn(
186
+ `backlog scheduler stop: ${this.inFlight.size} drain(s) ignored abort for ${this.options.stopSettleTimeoutMs}ms; ` +
187
+ `continuing shutdown (stuck keys: ${[...this.inFlight.keys()].join(", ")})`,
188
+ );
189
+ }
190
+ } finally {
191
+ clearTimeout(boundTimer);
192
+ }
172
193
  }
173
194
 
174
195
  /** Bounded snapshot for ops/watchers: counters + per-key states + live sets. */
@@ -299,6 +299,26 @@ test("abort while queued: settled task starts, skips extraction, queue drains",
299
299
  assert.equal(settled.length, 1, "queued task settles exactly once from the abort");
300
300
  });
301
301
 
302
+ test("abort while queued: onTaskExit fires at once, not after the queue head finishes", async () => {
303
+ const h = makeHarness();
304
+ const controller = new AbortController();
305
+ // Occupies the single worker; the drain stays blocked until release().
306
+ await h.coordinator.processTurn("user", "running", "k");
307
+ let exited = 0;
308
+ await h.coordinator.queueBufferedExtraction([makeTurn("queued then aborted")], "trigger_mode", {
309
+ bufferKey: "k",
310
+ preemptable: true,
311
+ abortSignal: controller.signal,
312
+ onTaskExit: () => { exited += 1; },
313
+ });
314
+ controller.abort();
315
+ // A claim-lease owner waiting on this exit must not be held by an unrelated
316
+ // running extraction: nothing of this task ever ran.
317
+ assert.equal(exited, 1, "queued task that never started reports exit on abort");
318
+ await h.release();
319
+ assert.equal(exited, 1, "the later no-op closure run does not report a second exit");
320
+ });
321
+
302
322
  test("sharing bound: distinct reference slots equal the union, not the snapshot sum", async () => {
303
323
  const h = makeHarness();
304
324
  const turns = 30;
@@ -115,6 +115,7 @@ export class OrchestratorInitCoordinator {
115
115
  | { epoch: number; controller: AbortController }
116
116
  | undefined;
117
117
  private readonly startupStorageWrites = new Set<Promise<void>>();
118
+ private readonly startupWarmups = new Set<Promise<void>>();
118
119
 
119
120
  constructor(
120
121
  private readonly deps: OrchestratorInitDeps,
@@ -138,6 +139,11 @@ export class OrchestratorInitCoordinator {
138
139
  }
139
140
  }
140
141
 
142
+ /** Wait for background startup warm-ups; destroy() only, never the deferredReady gate. */
143
+ async drainStartupWarmups(): Promise<void> {
144
+ await Promise.allSettled([...this.startupWarmups]);
145
+ }
146
+
141
147
  async drainEntityMigrations(): Promise<void> {
142
148
  await Promise.allSettled([...this.startupStorageWrites]);
143
149
  const drains = [this.deps.storage.drainEntityCanonicalIdMigration()];
@@ -657,6 +663,25 @@ export class OrchestratorInitCoordinator {
657
663
 
658
664
  if (signal.aborted || !this.isCurrentStartupEpoch(epoch)) return;
659
665
 
666
+ // Build the default namespace's authoritative fact-hash index now, in the
667
+ // background. The first explicit save otherwise pays the whole hot+cold
668
+ // corpus rebuild (measured 3.1 s CPU, 11 to 33 s wall on jarvis-ml). A save
669
+ // that arrives mid-build joins this same in-flight promise inside
670
+ // StorageManager, so the rebuild never runs twice. Not awaited: the
671
+ // deferredReady gate must not wait on it, and a failure only means the first
672
+ // save builds the index itself, as before.
673
+ const factHashWarm = (async () => {
674
+ const storage = await this.deps.storageRouter.storageFor(this.deps.config.defaultNamespace);
675
+ await storage.isFactContentHashAuthoritative();
676
+ })().catch((err) => {
677
+ log.debug(`fact-hash index startup warm-up failed (non-fatal): ${err}`);
678
+ });
679
+ // Tracked apart from startupStorageWrites: destroy() waits for it so the
680
+ // rebuild never touches state/ after teardown, but the deferredReady gate
681
+ // (which drains startupStorageWrites) must not.
682
+ this.startupWarmups.add(factHashWarm);
683
+ void factHashWarm.finally(() => this.startupWarmups.delete(factHashWarm));
684
+
660
685
  // Warmup: run cheap searches to pre-load QMD embedding models and the
661
686
  // embedding-fallback JSON index so the first real recall is fast.
662
687
  const warmupPromises: Promise<void>[] = [];
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Exit-once bookkeeping for a queued extraction task (`onTaskExit`).
3
+ *
4
+ * A task settled (abort, queue deadline) before its closure ever ran has
5
+ * nothing to exit, so `settled()` reports exit at once; a claim-lease owner
6
+ * waiting on the exit is then not held behind an unrelated running extraction.
7
+ * A closure that did start reports exit when it finishes. Either way the
8
+ * callback fires exactly once.
9
+ */
10
+ export function createTaskExit(onExit?: () => void) {
11
+ let started = false;
12
+ let reported = false;
13
+ const report = (): void => {
14
+ if (reported) return;
15
+ reported = true;
16
+ onExit?.();
17
+ };
18
+ return {
19
+ start: () => {
20
+ started = true;
21
+ },
22
+ settled: () => {
23
+ if (!started) report();
24
+ },
25
+ report,
26
+ };
27
+ }
@@ -60,6 +60,7 @@ import {
60
60
  // The class lives in ../buffer-admission.js next to BufferAdmissionRefusedError;
61
61
  // re-exported here so existing importers keep their import path.
62
62
  import { ReplayAdmissionRefusedError, StorageTargetRequiredError } from "../buffer-admission.js";
63
+ import { createTaskExit } from "./task-exit.js";
63
64
  export { ReplayAdmissionRefusedError, StorageTargetRequiredError };
64
65
 
65
66
  export interface TurnIngestionDeps {
@@ -798,13 +799,7 @@ export class TurnIngestionCoordinator {
798
799
  abortSignal?: AbortSignal;
799
800
  /** Automatic buffered work only. Replay and import must not set this. */
800
801
  preemptable?: boolean;
801
- /**
802
- * Invoked when the queued closure has FULLY exited (after settlement).
803
- * Settlement can fire before the closure exits — e.g. an abort listener
804
- * settles while persistence is still in flight — so callers that
805
- * release resources on settlement (claim leases) can use this to wait
806
- * for the actual task exit first.
807
- */
802
+ /** Fires once when the task is done: closure exited, or settled before it started. */
808
803
  onTaskExit?: () => void;
809
804
  /** Fail-closed force-flush only. Not implied by failOnExtractionFailure. */
810
805
  preemptRunning?: boolean;
@@ -845,6 +840,7 @@ export class TurnIngestionCoordinator {
845
840
  : undefined;
846
841
  let timeout: ReturnType<typeof setTimeout> | undefined;
847
842
  let settled = false;
843
+ const exit = createTaskExit(options.onTaskExit);
848
844
  const clearQueueWaitTimer = (): void => {
849
845
  if (timeout) {
850
846
  clearTimeout(timeout);
@@ -867,6 +863,7 @@ export class TurnIngestionCoordinator {
867
863
  clearQueueWaitTimer();
868
864
  clearAbortListener();
869
865
  options.onTaskSettled?.(error, result);
866
+ exit.settled();
870
867
  return true;
871
868
  };
872
869
  if (options.abortSignal) {
@@ -910,6 +907,7 @@ export class TurnIngestionCoordinator {
910
907
  const queuedTurnsSource: { chain: SnapshotChain } | { direct: BufferTurn[] } =
911
908
  sharingNode ? { chain: sharingNode } : { direct: turnsToExtract };
912
909
  const accepted = this.deps.extractionQueueCoordinator.enqueue(async (preemptHandle) => {
910
+ exit.start();
913
911
  try {
914
912
  await this.deps.extractionQueueCoordinator.runPreemptable(preemptHandle, preemptable, options.abortSignal, async (abortSignal) => {
915
913
  if (settled) return;
@@ -1094,13 +1092,13 @@ export class TurnIngestionCoordinator {
1094
1092
  });
1095
1093
  } finally {
1096
1094
  if (sharingNode) this.snapshotSharing.release(sharingNode);
1097
- options.onTaskExit?.();
1095
+ exit.report();
1098
1096
  }
1099
1097
  }, { front: options.preemptRunning === true, key: bufferKey });
1100
1098
  if (!accepted) {
1101
1099
  if (sharingNode) this.snapshotSharing.release(sharingNode);
1102
1100
  settleTask(new Error("extraction queue is stopped"));
1103
- options.onTaskExit?.();
1101
+ exit.report();
1104
1102
  return;
1105
1103
  }
1106
1104
 
@@ -1,6 +1,6 @@
1
1
  import assert from "node:assert/strict";
2
2
  import { existsSync } from "node:fs";
3
- import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
3
+ import { mkdir, mkdtemp, readdir, readFile, rm, writeFile } from "node:fs/promises";
4
4
  import os from "node:os";
5
5
  import path from "node:path";
6
6
  import test from "node:test";
@@ -1595,3 +1595,134 @@ for (const namespace of ["default", "shared"]) {
1595
1595
  }
1596
1596
  });
1597
1597
  }
1598
+
1599
+ test("daemon start builds the fact-hash index once in the background and a save right after start joins it", async () => {
1600
+ const memoryDir = await mkdtemp(path.join(os.tmpdir(), "remnic-warm-fact-hash-"));
1601
+ const orchestrator = new Orchestrator(
1602
+ parseConfig({
1603
+ memoryDir,
1604
+ workspaceDir: path.join(memoryDir, "workspace"),
1605
+ qmdEnabled: false,
1606
+ namespacesEnabled: true,
1607
+ namespaceCatalogEnabled: false,
1608
+ defaultNamespace: "default",
1609
+ sharedNamespace: "shared",
1610
+ namespacePolicies: [],
1611
+ embeddingFallbackEnabled: false,
1612
+ identityContinuityEnabled: false,
1613
+ transcriptEnabled: false,
1614
+ hourlySummariesEnabled: false,
1615
+ compoundingEnabled: false,
1616
+ knowledgeIndexEnabled: false,
1617
+ }),
1618
+ );
1619
+ let releaseCorpus: (() => void) | undefined;
1620
+ try {
1621
+ const storage = await orchestrator.getStorage("default");
1622
+ const realRead = storage.readAllMemories.bind(storage);
1623
+ let corpusReads = 0;
1624
+ let signalStarted!: () => void;
1625
+ const rebuildStarted = new Promise<void>((resolve) => { signalStarted = resolve; });
1626
+ const corpusHeld = new Promise<void>((resolve) => { releaseCorpus = resolve; });
1627
+ storage.readAllMemories = async () => {
1628
+ corpusReads += 1;
1629
+ signalStarted();
1630
+ await corpusHeld;
1631
+ return realRead();
1632
+ };
1633
+
1634
+ assert.notEqual(await withTestDeadline(orchestrator.initialize()), "deadline", "initialize must not wait on the held rebuild");
1635
+ // SAFETY: test reads the private deferredReady gate, as the neighbouring tests do.
1636
+ assert.notEqual(
1637
+ await withTestDeadline((orchestrator as any).deferredReady),
1638
+ "deadline",
1639
+ "deferredReady must not wait on the held rebuild",
1640
+ );
1641
+ let giveUp: NodeJS.Timeout | undefined;
1642
+ const outcome = await Promise.race([
1643
+ rebuildStarted.then(() => "started" as const),
1644
+ new Promise<"not-started">((resolve) => { giveUp = setTimeout(() => resolve("not-started"), 3_000); }),
1645
+ ]);
1646
+ clearTimeout(giveUp);
1647
+ assert.equal(outcome, "started", "start must begin the fact-hash corpus rebuild before any save arrives");
1648
+
1649
+ // Two saves landing while the warm-up still runs: both wait on that one flight.
1650
+ const saves = Promise.all([storage.lookupContentHash("first save"), storage.lookupContentHash("second save")]);
1651
+ releaseCorpus?.();
1652
+ const results = await withTestDeadline(saves);
1653
+ if (results === "deadline") assert.fail("saves joining the warm-up never settled");
1654
+ assert.equal(corpusReads, 1, "the saves must not start a second corpus rebuild");
1655
+ assert.deepEqual(results.map((r) => r.authoritative), [true, true]);
1656
+ } finally {
1657
+ releaseCorpus?.();
1658
+ await orchestrator.destroy().catch(() => undefined);
1659
+ await rm(memoryDir, { recursive: true, force: true, maxRetries: 3, retryDelay: 50 });
1660
+ }
1661
+ });
1662
+
1663
+ test("destroy persists a buffered turn before it waits on a running fact-hash warm-up", async () => {
1664
+ const memoryDir = await mkdtemp(path.join(os.tmpdir(), "remnic-warm-destroy-flush-"));
1665
+ const marker = "w7p-buffered-turn-marker";
1666
+ const orchestrator = new Orchestrator(
1667
+ parseConfig({
1668
+ memoryDir,
1669
+ workspaceDir: path.join(memoryDir, "workspace"),
1670
+ qmdEnabled: false,
1671
+ namespacesEnabled: true,
1672
+ namespaceCatalogEnabled: false,
1673
+ defaultNamespace: "default",
1674
+ sharedNamespace: "shared",
1675
+ namespacePolicies: [],
1676
+ bufferSaveDebounceMs: 600_000,
1677
+ embeddingFallbackEnabled: false,
1678
+ identityContinuityEnabled: false,
1679
+ transcriptEnabled: false,
1680
+ hourlySummariesEnabled: false,
1681
+ compoundingEnabled: false,
1682
+ knowledgeIndexEnabled: false,
1683
+ }),
1684
+ );
1685
+ let releaseCorpus: (() => void) | undefined;
1686
+ try {
1687
+ const storage = await orchestrator.getStorage("default");
1688
+ const realRead = storage.readAllMemories.bind(storage);
1689
+ let signalStarted!: () => void;
1690
+ const rebuildStarted = new Promise<void>((resolve) => { signalStarted = resolve; });
1691
+ const corpusHeld = new Promise<void>((resolve) => { releaseCorpus = resolve; });
1692
+ storage.readAllMemories = async () => {
1693
+ signalStarted();
1694
+ await corpusHeld;
1695
+ return realRead();
1696
+ };
1697
+ assert.notEqual(await withTestDeadline(orchestrator.initialize()), "deadline", "initialize must not wait on the held rebuild");
1698
+ assert.notEqual(await withTestDeadline(rebuildStarted), "deadline", "the warm-up must start the rebuild");
1699
+
1700
+ await orchestrator.buffer.addTurn("warm-destroy-key", {
1701
+ role: "user",
1702
+ content: marker,
1703
+ timestamp: new Date().toISOString(),
1704
+ });
1705
+ const bufferOnDisk = async (): Promise<boolean> => {
1706
+ for (const name of await readdir(memoryDir, { recursive: true })) {
1707
+ if ((await readFile(path.join(memoryDir, name), "utf8").catch(() => "")).includes(marker)) return true;
1708
+ }
1709
+ return false;
1710
+ };
1711
+ assert.equal(await bufferOnDisk(), false, "precondition: the debounced save has not run");
1712
+
1713
+ const destroying = orchestrator.destroy();
1714
+ const startedAt = Date.now();
1715
+ let persisted = false;
1716
+ while (Date.now() - startedAt < 1_500 && !persisted) {
1717
+ persisted = await bufferOnDisk();
1718
+ if (!persisted) await new Promise((resolve) => setTimeout(resolve, 25));
1719
+ }
1720
+ assert.equal(persisted, true, "the buffered turn must reach disk within 1.5 s of destroy() while the corpus read is held");
1721
+ releaseCorpus?.();
1722
+ assert.notEqual(await withTestDeadline(destroying), "deadline", "destroy must finish once the rebuild is released");
1723
+ } finally {
1724
+ releaseCorpus?.();
1725
+ await orchestrator.destroy().catch(() => undefined);
1726
+ await rm(memoryDir, { recursive: true, force: true, maxRetries: 3, retryDelay: 50 });
1727
+ }
1728
+ });
@@ -1025,6 +1025,9 @@ export class Orchestrator {
1025
1025
  } catch (err) {
1026
1026
  bufferFlushError = err;
1027
1027
  }
1028
+ // After the buffer flush: a long fact-hash warm-up must never delay
1029
+ // persisting unsent turns (a kill during the wait would lose them).
1030
+ await this._orchestratorInitCoordinator?.drainStartupWarmups();
1028
1031
  const extractionDrainError = await drainExtractionAndShutdownDependencyPropagation(
1029
1032
  this.extractionQueueCoordinator,
1030
1033
  this._dependencyPropagationDelivery,