@remnic/core 9.3.746 → 9.3.747

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.
@@ -0,0 +1,712 @@
1
+ /**
2
+ * Orchestrator-init coordinator — extracted from the orchestrator
3
+ * (issue #1526, seam 22).
4
+ *
5
+ * Owns the startup lifecycle:
6
+ * - initialize(): directory/storage/alias/policy bring-up and the
7
+ * init gate that recall() awaits
8
+ * - deferredInitialize(): background QMD probe, warmup, caches, cron
9
+ * wiring, and the deferredReady gate
10
+ * - startupSearchSync(): the initial search-index reconciliation
11
+ *
12
+ * Behavior-preserving move from orchestrator.ts. The async init ORDERING
13
+ * is part of the gateway_start contract — the move keeps every await in
14
+ * place and mutates the orchestrator's own gate fields (initPromise,
15
+ * deferredReady, resolveDeferredReady, deferredInitAbort,
16
+ * deferredSyncSucceeded, …) through live get/set accessors, so
17
+ * stop/start reuse of one Orchestrator instance behaves identically.
18
+ */
19
+
20
+ import { readdir, stat, unlink } from "node:fs/promises";
21
+ import path from "node:path";
22
+ import { SmartBuffer } from "../buffer.js";
23
+ import { resolveIndexingCapabilities, resolveLocalLlmCapabilities, resolveMemoryLifecycleCapabilities, resolveNamespaceCapabilities, resolveQmdCapabilities, resolveRecallAuxiliaryCapabilities, resolveUtilityLearningCapabilities } from "../capabilities.js";
24
+ import { CompoundingEngine } from "../compounding/engine.js";
25
+ import type { ConversationIndexBackend } from "../conversation-index/backend.js";
26
+ import type { CorrectionService } from "../correction/correction-service.js";
27
+ import { EmbeddingFallback } from "../embedding-fallback.js";
28
+ import { ContentHashIndex, StorageManager } from "../index.js";
29
+ import { log } from "../logger.js";
30
+ import { migrateFromEngram } from "../migrate/from-engram.js";
31
+ import { NamespaceCatalog } from "../namespaces/catalog.js";
32
+ import { NamespaceSearchRouter } from "../namespaces/search.js";
33
+ import { NamespaceStorageRouter } from "../namespaces/storage.js";
34
+ import { NegativeExampleStore } from "../negative.js";
35
+ import { MaintenanceScheduler } from "./maintenance.js";
36
+ import { PolicyRuntimeManager, type RuntimePolicyValues } from "../policy-runtime.js";
37
+ import { LastRecallStore, RecallHandleHistoryStore, TierMigrationStatusStore } from "../recall-state.js";
38
+ import { RelevanceStore } from "../relevance.js";
39
+ import { NoopSearchBackend } from "../search/noop-backend.js";
40
+ import type { SearchBackend } from "../search/port.js";
41
+ import { SessionObserverState } from "../session-observer-state.js";
42
+ import { SharedContextManager } from "../shared-context/manager.js";
43
+ import { HourlySummarizer } from "../summarizer.js";
44
+ import { TranscriptManager } from "../transcript.js";
45
+ import type { PluginConfig } from "../types.js";
46
+ import { type UtilityRuntimeValues, loadUtilityRuntimeValues } from "../utility-runtime.js";
47
+ import { WearablesService } from "../wearables/service.js";
48
+ import {
49
+ COMPACTION_SIGNAL_MAX_AGE_MS,
50
+ defaultWorkspaceDir,
51
+ qmdStartupCollectionCheckWithTimeout,
52
+ } from "../orchestrator.js";
53
+
54
+ export interface OrchestratorInitDeps {
55
+ readonly buffer: SmartBuffer;
56
+ readonly compounding?: CompoundingEngine;
57
+ readonly config: PluginConfig;
58
+ configuredNamespaceList(): string[];
59
+ contentHashIndex: ContentHashIndex | null;
60
+ readonly conversationIndexBackend?: ConversationIndexBackend;
61
+ deferredInitAbort: AbortController | null;
62
+ deferredInitialize(signal: AbortSignal): Promise<void>;
63
+ deferredReady: Promise<void>;
64
+ deferredSyncSucceeded: boolean;
65
+ disposeSearchBackendIfNeeded(): Promise<void>;
66
+ readonly embeddingFallback: EmbeddingFallback;
67
+ getWearablesService(): WearablesService;
68
+ readonly handleHistory: RecallHandleHistoryStore;
69
+ readonly lastRecall: LastRecallStore;
70
+ maintenanceNamespaces(
71
+ jobName?: string,
72
+ budgetMode?: "cycle" | "unbounded",
73
+ ): Promise<string[]>;
74
+ readonly maintenanceScheduler: MaintenanceScheduler;
75
+ readonly namespaceCatalog: NamespaceCatalog;
76
+ readonly namespaceSearchRouter: NamespaceSearchRouter;
77
+ readonly negatives: NegativeExampleStore;
78
+ passiveCorrectionService(): CorrectionService;
79
+ readonly policyRuntime: PolicyRuntimeManager;
80
+ qmd: SearchBackend;
81
+ readonly relevance: RelevanceStore;
82
+ resolveDeferredReady: (() => void) | null;
83
+ resolveInit: (() => void) | null;
84
+ runtimePolicyValues: RuntimePolicyValues | null;
85
+ readonly sessionObserver: SessionObserverState;
86
+ readonly sharedContext?: SharedContextManager;
87
+ readonly storage: StorageManager;
88
+ readonly storageRouter: NamespaceStorageRouter;
89
+ readonly summarizer: HourlySummarizer;
90
+ readonly tierMigrationStatus: TierMigrationStatusStore;
91
+ readonly transcript: TranscriptManager;
92
+ utilityRuntimeValues: UtilityRuntimeValues | null;
93
+ validateLocalLlmModel(): Promise<void>;
94
+ wearablesAutoSyncHandle: { stop(): Promise<void> } | null;
95
+ }
96
+
97
+ export class OrchestratorInitCoordinator {
98
+ constructor(
99
+ private readonly deps: OrchestratorInitDeps,
100
+ ) {}
101
+
102
+ async initialize(): Promise<void> {
103
+ // Recreate the deferred-ready gate on every initialize() call.
104
+ // The same Orchestrator instance may be reused across stop/start cycles
105
+ // (src/index.ts does this). Without this reset, the second cycle's
106
+ // `await orchestrator.deferredReady` resolves immediately (already settled
107
+ // from the first cycle) while the new deferredInitialize() is still running.
108
+ this.deps.deferredReady = new Promise<void>((resolve) => {
109
+ this.deps.resolveDeferredReady = resolve;
110
+ });
111
+
112
+ try {
113
+ await migrateFromEngram({
114
+ quiet: true,
115
+ logger: (message) => log.info(message),
116
+ });
117
+ await this.deps.storage.ensureDirectories();
118
+ await this.deps.storage.loadAliases();
119
+ if (resolveNamespaceCapabilities(this.deps.config).namespaces) {
120
+ const namespaces = new Set<string>([
121
+ this.deps.config.defaultNamespace,
122
+ this.deps.config.sharedNamespace,
123
+ ...this.deps.config.namespacePolicies.map((p) => p.name),
124
+ ]);
125
+ for (const ns of namespaces) {
126
+ const sm = await this.deps.storageRouter.storageFor(ns);
127
+ await sm.ensureDirectories();
128
+ await sm.loadAliases().catch(() => undefined);
129
+ }
130
+ // Explicitly seed the catalog with all configured namespaces at startup
131
+ // (round 6, cursor Medium — NBLlR). The storageFor loop above fires the
132
+ // router's onResolve hook, but a warm router cache (reused instance
133
+ // across stop/start) can skip onResolve, leaving policy namespaces absent
134
+ // from the live catalog until an operator runs `rebuild --apply`. This
135
+ // call is cheap, idempotent, and best-effort: a catalog failure must
136
+ // never break initialization (rule #13, #40).
137
+ await this.deps.namespaceCatalog.registerConfiguredNamespaces().catch(() => undefined);
138
+ }
139
+ // #1713 Item 2: recover stale `applying` correction plans left behind
140
+ // by a process that died mid-apply. Best-effort — a failure here must
141
+ // never block initialization (rule 13). Runs for every configured
142
+ // namespace since correction plans can exist in any of them.
143
+ try {
144
+ // #1713 Item 2 + P2 (cursor): sweep ALL known namespaces — configured
145
+ // + catalog-discovered — so stale applying plans in derived namespaces
146
+ // (coding-scoped, session-derived) are also recovered.
147
+ const correctionNamespaces = new Set(this.deps.configuredNamespaceList());
148
+ if (this.deps.namespaceCatalog.enabled) {
149
+ try {
150
+ for (const rec of await this.deps.namespaceCatalog.listNamespaces()) {
151
+ correctionNamespaces.add(rec.namespace);
152
+ }
153
+ } catch { /* best-effort */ }
154
+ }
155
+ const recovered = await this.deps.passiveCorrectionService().recoverStaleApplyingPlans(
156
+ [...correctionNamespaces],
157
+ );
158
+ if (recovered > 0) {
159
+ log.info(`correction: recovered ${recovered} stale applying plan(s) on startup`);
160
+ }
161
+ } catch (staleErr) {
162
+ log.debug(`correction: stale-plan recovery skipped: ${staleErr instanceof Error ? staleErr.message : String(staleErr)}`);
163
+ }
164
+ await this.deps.relevance.load();
165
+ await this.deps.negatives.load();
166
+ await this.deps.lastRecall.load();
167
+ await this.deps.handleHistory.load();
168
+ await this.deps.tierMigrationStatus.load();
169
+ await this.deps.sessionObserver.load();
170
+ this.deps.runtimePolicyValues = await this.deps.policyRuntime.loadRuntimeValues();
171
+ this.deps.utilityRuntimeValues = await loadUtilityRuntimeValues({
172
+ memoryDir: this.deps.config.memoryDir,
173
+ memoryUtilityLearningEnabled: resolveUtilityLearningCapabilities(this.deps.config).memoryUtilityLearning,
174
+ promotionByOutcomeEnabled: resolveUtilityLearningCapabilities(this.deps.config).promotionByOutcome,
175
+ });
176
+
177
+ // Initialize content-hash dedup index
178
+ if (resolveRecallAuxiliaryCapabilities(this.deps.config).factDeduplication) {
179
+ this.deps.contentHashIndex = this.deps.storage.createContentHashIndex();
180
+ await this.deps.contentHashIndex.load();
181
+ log.info(
182
+ `content-hash dedup: loaded ${this.deps.contentHashIndex.size} hashes`,
183
+ );
184
+ }
185
+ await this.deps.transcript.initialize();
186
+ await this.deps.summarizer.initialize();
187
+ if (this.deps.sharedContext) {
188
+ await this.deps.sharedContext.ensureStructure();
189
+ }
190
+ if (this.deps.compounding) {
191
+ await this.deps.compounding.ensureDirs();
192
+ }
193
+
194
+ // Buffer and compaction cleanup are fast and needed for basic operation —
195
+ // load them before the init gate so turn buffering works immediately.
196
+ try {
197
+ await this.deps.buffer.load();
198
+ } catch (bufErr) {
199
+ log.error(
200
+ `buffer.load() failed (init gate will still open): ${bufErr}`,
201
+ );
202
+ this.deps.buffer.resetToEmpty();
203
+ }
204
+ if (resolveRecallAuxiliaryCapabilities(this.deps.config).compactionReset) {
205
+ try {
206
+ const wsDir = this.deps.config.workspaceDir || defaultWorkspaceDir();
207
+ const files = await readdir(wsDir).catch(() => [] as string[]);
208
+ for (const f of files) {
209
+ if (!f.startsWith(".compaction-reset-signal-")) continue;
210
+ const fp = path.join(wsDir, f);
211
+ const s = await stat(fp).catch(() => null);
212
+ if (s && Date.now() - s.mtimeMs >= COMPACTION_SIGNAL_MAX_AGE_MS) {
213
+ await unlink(fp).catch(() => {});
214
+ log.debug(`initialize: removed stale compaction signal ${f}`);
215
+ }
216
+ }
217
+ } catch (err) {
218
+ log.debug("initialize: stale signal sweep failed:", err);
219
+ }
220
+ }
221
+
222
+ // QMD probe + collection check: determines the final QMD state (real
223
+ // client vs NoopSearchBackend). Must complete BEFORE the init gate opens
224
+ // so that recall() — which awaits initPromise — always observes the final
225
+ // QMD state. Without this ordering, a concurrent recall() could read
226
+ // this.deps.qmd while it's still the real client, then get errors when
227
+ // deferredInitialize() swaps it to NoopSearchBackend mid-query.
228
+ try {
229
+ const available = await this.deps.qmd.probe();
230
+ if (available) {
231
+ log.info(`Search backend: available ${this.deps.qmd.debugStatus()}`);
232
+ // Ensure collections at startup for the catalog-union namespace set, not
233
+ // just the configured set (issue #1499 sweep, same class as NHZEV): a
234
+ // dynamic namespace that exists only in the persisted catalog must have
235
+ // its QMD collection checked/ensured on boot so recall against it works
236
+ // after a restart. `registerConfiguredNamespaces()` already seeded the
237
+ // catalog above, so `maintenanceNamespaces()` is readable here; it falls
238
+ // back to the configured set on any catalog read failure.
239
+ const namespaces = resolveNamespaceCapabilities(this.deps.config).namespaces
240
+ ? await this.deps.maintenanceNamespaces()
241
+ : [this.deps.config.defaultNamespace];
242
+ const states = await Promise.all(
243
+ namespaces.map(async (namespace) => {
244
+ const collectionCheckAbort = new AbortController();
245
+ const state = await qmdStartupCollectionCheckWithTimeout(
246
+ resolveNamespaceCapabilities(this.deps.config).namespaces
247
+ ? this.deps.namespaceSearchRouter.ensureNamespaceCollection(
248
+ namespace,
249
+ { signal: collectionCheckAbort.signal },
250
+ )
251
+ : this.deps.qmd.ensureCollection(
252
+ this.deps.config.memoryDir,
253
+ this.deps.config.qmdCollection,
254
+ { signal: collectionCheckAbort.signal },
255
+ ),
256
+ collectionCheckAbort,
257
+ namespace,
258
+ );
259
+ return { namespace, state };
260
+ }),
261
+ );
262
+ const defaultState =
263
+ states.find(
264
+ (entry) => entry.namespace === this.deps.config.defaultNamespace,
265
+ )?.state ?? "unknown";
266
+ if (defaultState === "missing") {
267
+ await this.deps.disposeSearchBackendIfNeeded();
268
+ this.deps.qmd = new NoopSearchBackend();
269
+ log.warn(
270
+ "Search collection missing for Remnic memory store; disabling search retrieval for this runtime (fallback retrieval remains enabled)",
271
+ );
272
+ } else if (defaultState === "unknown") {
273
+ log.warn(
274
+ "Search collection check unavailable; keeping search retrieval enabled for fail-open behavior",
275
+ );
276
+ } else if (defaultState === "skipped") {
277
+ log.debug(
278
+ "Search collection check skipped (remote or daemon-only mode)",
279
+ );
280
+ }
281
+ for (const entry of states) {
282
+ if (entry.namespace === this.deps.config.defaultNamespace) continue;
283
+ if (entry.state === "missing") {
284
+ log.warn(
285
+ `Search collection missing for namespace '${entry.namespace}'; namespace retrieval will fail open to non-search paths`,
286
+ );
287
+ }
288
+ }
289
+ } else if (this.deps.qmd instanceof NoopSearchBackend) {
290
+ log.debug(`Search backend: noop (search intentionally disabled)`);
291
+ } else {
292
+ log.warn(`Search backend: not available ${this.deps.qmd.debugStatus()}`);
293
+ }
294
+ } catch (err) {
295
+ log.error(`QMD probe/collection check failed (non-fatal): ${err}`);
296
+ }
297
+
298
+ // Open the init gate — essential state (storage, aliases, relevance,
299
+ // transcript, summarizer, buffer) is loaded AND QMD state is finalized
300
+ // (probe + collection check complete, NoopSearchBackend swap done if
301
+ // needed). Warmup, sync, caches, and remaining heavy operations run in
302
+ // the background after this point via deferredInitialize().
303
+ if (this.deps.resolveInit) {
304
+ this.deps.resolveInit();
305
+ this.deps.resolveInit = null;
306
+ log.info("init gate opened (essential state + QMD state loaded)");
307
+ }
308
+
309
+ // Deferred init: QMD sync, warmup, conversation index, caches, cron.
310
+ // Runs in background so gateway_start returns fast. On low-power hardware
311
+ // (Umbrel, RPi) QMD warmup/sync alone can take 30-60s and cause gateway
312
+ // restart loops when they block the startup path. See issue #462.
313
+ // Note: QMD probe + collection check (including NoopSearchBackend swap)
314
+ // already ran above before the init gate, so this.deps.qmd is finalized.
315
+ //
316
+ // Capture the resolver by value so a concurrent re-initialize() cannot
317
+ // overwrite this.deps.resolveDeferredReady before .finally() runs — that would
318
+ // cause the first cycle's .finally() to resolve the *second* cycle's
319
+ // promise prematurely while leaving the first cycle's promise pending.
320
+ const resolveDeferred = this.deps.resolveDeferredReady;
321
+ this.deps.resolveDeferredReady = null;
322
+ this.deps.deferredInitAbort = new AbortController();
323
+ this.deps.deferredInitialize(this.deps.deferredInitAbort.signal)
324
+ .catch((err) => {
325
+ log.error(`deferred initialization failed (non-fatal): ${err}`);
326
+ })
327
+ .finally(() => {
328
+ resolveDeferred?.();
329
+ });
330
+ } catch (err) {
331
+ // Resolve both gates so callers never hang on permanently-pending promises
332
+ // after catching the initialize() error:
333
+ //
334
+ // - initPromise: recall(), generateDaySummary(), etc. await this as a
335
+ // readiness gate with a 15s timeout. Leaving it pending means every
336
+ // subsequent call pays that timeout penalty.
337
+ //
338
+ // - deferredReady: CLI callers await this for full QMD readiness. Without
339
+ // resolution it hangs forever since deferredInitialize() never ran.
340
+ if (this.deps.resolveInit) {
341
+ this.deps.resolveInit();
342
+ this.deps.resolveInit = null;
343
+ }
344
+ if (this.deps.resolveDeferredReady) {
345
+ this.deps.resolveDeferredReady();
346
+ this.deps.resolveDeferredReady = null;
347
+ }
348
+ throw err;
349
+ }
350
+ }
351
+
352
+ async deferredInitialize(signal: AbortSignal): Promise<void> {
353
+ const lifecycleCaps = resolveMemoryLifecycleCapabilities(this.deps.config);
354
+
355
+ // Sync QMD index with current disk state so recall finds recently-written
356
+ // facts. Without this, the index stays stale from the last extraction-
357
+ // triggered update — which can be days ago if the daemon restarted without
358
+ // new extractions. This is the root cause of "0 memories" recall results
359
+ // despite thousands of facts on disk.
360
+ if (this.deps.qmd.isAvailable() && resolveQmdCapabilities(this.deps.config).qmdMaintenance) {
361
+ try {
362
+ log.info("QMD startup sync: updating index to match current disk state");
363
+ if (resolveNamespaceCapabilities(this.deps.config).namespaces) {
364
+ // Cover cataloged dynamic namespaces at startup too (NHZEV, codex P2):
365
+ // a dynamic namespace written before a daemon restart must be synced on
366
+ // boot, not only by the debounced runQmdMaintenance() path. Same union +
367
+ // catalog-read-failure fallback as runQmdMaintenance.
368
+ await this.deps.namespaceSearchRouter.updateNamespaces(
369
+ await this.deps.maintenanceNamespaces(),
370
+ { signal },
371
+ );
372
+ } else {
373
+ await this.deps.qmd.update({ signal });
374
+ }
375
+ log.info("QMD startup sync: complete");
376
+ this.deps.deferredSyncSucceeded = true;
377
+ } catch (err) {
378
+ log.warn(`QMD startup sync failed (non-fatal): ${err}`);
379
+ // deferredSyncSucceeded stays false — server retry will attempt sync
380
+ }
381
+ } else if (!(this.deps.qmd.isAvailable())) {
382
+ // QMD not available at deferred init time — server retry will handle it
383
+ } else {
384
+ // QMD available but maintenance disabled — consider sync not needed
385
+ this.deps.deferredSyncSucceeded = true;
386
+ }
387
+
388
+ if (signal.aborted) return;
389
+
390
+ // Warmup: run cheap searches to pre-load QMD embedding models and the
391
+ // embedding-fallback JSON index so the first real recall is fast.
392
+ const warmupPromises: Promise<void>[] = [];
393
+ if (this.deps.qmd.isAvailable()) {
394
+ const warmupNs = this.deps.config.defaultNamespace;
395
+ log.info("QMD warmup: pre-loading models with a test search");
396
+ warmupPromises.push(
397
+ this.deps.qmd
398
+ .search("warmup", warmupNs, 1, undefined, { signal })
399
+ .then(() => {
400
+ log.info("QMD warmup: complete");
401
+ })
402
+ .catch((err) => {
403
+ log.debug(`QMD warmup search failed (non-fatal): ${err}`);
404
+ }),
405
+ );
406
+ }
407
+ if (resolveMemoryLifecycleCapabilities(this.deps.config).embeddingFallback) {
408
+ warmupPromises.push(
409
+ this.deps.embeddingFallback
410
+ .isAvailable()
411
+ .then((ok) => {
412
+ log.info(
413
+ `Embedding fallback warmup: ${ok ? "available" : "unavailable (no provider)"}`,
414
+ );
415
+ })
416
+ .catch((err) => {
417
+ log.debug(`Embedding fallback warmup failed (non-fatal): ${err}`);
418
+ }),
419
+ );
420
+ }
421
+ await Promise.all(warmupPromises);
422
+ if (signal.aborted) return;
423
+
424
+ // Pre-warm knowledge index, memory, and entity caches.
425
+ // Awaited so callers of `deferredReady` can rely on warmups being complete
426
+ // and shutdown sequencing does not race with in-flight cache builds.
427
+ const cacheWarmups: Promise<void>[] = [];
428
+ if (resolveRecallAuxiliaryCapabilities(this.deps.config).knowledgeIndex) {
429
+ cacheWarmups.push(
430
+ (async () => {
431
+ try {
432
+ const t0 = Date.now();
433
+ await this.deps.storage.buildKnowledgeIndex(this.deps.config);
434
+ log.info(`Knowledge Index warmup: complete in ${Date.now() - t0}ms`);
435
+ } catch (err) {
436
+ log.debug(`Knowledge Index warmup failed (non-fatal): ${err}`);
437
+ }
438
+ })(),
439
+ );
440
+ }
441
+ cacheWarmups.push(this.deps.storage.readAllMemories().then(() => {}).catch(() => {}));
442
+ cacheWarmups.push(this.deps.storage.readAllEntityFiles().then(() => {}).catch(() => {}));
443
+ await Promise.all(cacheWarmups);
444
+ if (signal.aborted) return;
445
+
446
+ if (resolveIndexingCapabilities(this.deps.config).conversationIndex && this.deps.conversationIndexBackend) {
447
+ try {
448
+ const init = await this.deps.conversationIndexBackend.initialize();
449
+ if (!init.enabled) {
450
+ this.deps.config.conversationIndexEnabled = false;
451
+ }
452
+ if (init.logLevel === "info") {
453
+ log.info(init.message);
454
+ } else if (init.logLevel === "warn") {
455
+ log.warn(init.message);
456
+ } else {
457
+ log.debug(init.message);
458
+ }
459
+ } catch (err) {
460
+ log.error(`Conversation index initialization failed (non-fatal): ${err}`);
461
+ this.deps.config.conversationIndexEnabled = false;
462
+ }
463
+ }
464
+
465
+ if (signal.aborted) return;
466
+
467
+ if (resolveLocalLlmCapabilities(this.deps.config).localLlm) {
468
+ try {
469
+ await this.deps.validateLocalLlmModel();
470
+ } catch (err) {
471
+ log.error(`Local LLM validation failed (non-fatal): ${err}`);
472
+ }
473
+ }
474
+
475
+ if (signal.aborted) return;
476
+
477
+ // Await cron auto-registration so callers that `await deferredReady` can
478
+ // rely on cron jobs being registered when it resolves. Without this, the
479
+ // fire-and-forget pattern lets deferredReady settle while cron writes are
480
+ // still in flight. Errors are non-fatal — catch individually.
481
+ // Auto-register every cron job in one pass. Each registration is gated
482
+ // by its config flag inside the scheduler and individually non-fatal
483
+ // (issue #1526 PR1 — moved to MaintenanceScheduler).
484
+ await this.deps.maintenanceScheduler.autoRegisterCrons(signal);
485
+
486
+ // First-start lifecycle migration (issue #686 retention-completion).
487
+ // When lifecyclePolicyEnabled is true and the memoryDir has never been
488
+ // touched by the lifecycle policy, run a one-time rate-limited demotion
489
+ // sweep (capped at 50 demotions) so the hot tier isn't flooded on the
490
+ // first real cron pass. Non-fatal — a failure here must not break init.
491
+ if (signal.aborted) return;
492
+ if (lifecycleCaps.lifecyclePolicy && resolveQmdCapabilities(this.deps.config).qmdTierMigration) {
493
+ try {
494
+ const { runFirstStartMigration } = await import("../maintenance/first-start-migration.js"
495
+ );
496
+ const result = await runFirstStartMigration({
497
+ storage: this.deps.storage,
498
+ config: this.deps.config,
499
+ qmd: this.deps.qmd,
500
+ hotCollection: this.deps.config.qmdCollection,
501
+ coldCollection: this.deps.config.qmdColdCollection,
502
+ signal,
503
+ });
504
+ if (!result.skipped) {
505
+ log.info(
506
+ `first-start lifecycle migration: demoted ${result.demotedCount} of ${result.candidateCount} candidates (cap=${result.cappedAt})`,
507
+ );
508
+ } else {
509
+ log.debug(`first-start lifecycle migration skipped: ${result.skipReason}`);
510
+ }
511
+ } catch (err) {
512
+ log.warn(`first-start lifecycle migration failed (non-fatal): ${err}`);
513
+ }
514
+ }
515
+
516
+ // Wearables auto-sync: in-process periodic transcript refresh for
517
+ // long-lived hosts (default on). Today's transcript keeps growing
518
+ // while the wearable records; a once-per-local-day deep pass picks
519
+ // up late uploads and provider re-processing. Static config gate —
520
+ // sources can't appear at runtime, so checking once here is safe.
521
+ // The timer is unref'd, so one-shot CLI runs exit naturally without
522
+ // ever ticking; idempotent across stop/start cycles via the handle
523
+ // guard. Non-fatal: a failure to start must not break init.
524
+ if (signal.aborted) return;
525
+ if (
526
+ !this.deps.wearablesAutoSyncHandle &&
527
+ this.deps.config.wearables.enabled &&
528
+ this.deps.config.wearables.autoSyncEnabled &&
529
+ Object.values(this.deps.config.wearables.sources).some((source) => source.enabled)
530
+ ) {
531
+ try {
532
+ const { startWearablesAutoSync } = await import("../wearables/auto-sync.js");
533
+ // Re-check after the await: destroy() may have aborted while
534
+ // the import was in flight, having found no handle to stop —
535
+ // starting now would leave a live interval on a destroyed
536
+ // orchestrator (Cursor review on PR #1464). Handle creation
537
+ // below is synchronous, so no further window exists.
538
+ if (signal.aborted) return;
539
+ this.deps.wearablesAutoSyncHandle = startWearablesAutoSync(
540
+ {
541
+ intervalMinutes: this.deps.config.wearables.autoSyncIntervalMinutes,
542
+ days: this.deps.config.wearables.autoSyncDays,
543
+ deepDays: this.deps.config.wearables.autoSyncDeepDays,
544
+ ...(this.deps.config.wearables.timezone !== undefined
545
+ ? { timezone: this.deps.config.wearables.timezone }
546
+ : {}),
547
+ },
548
+ {
549
+ sync: (options) => this.deps.getWearablesService().sync(options),
550
+ log: {
551
+ info: (message) => log.info(message),
552
+ warn: (message) => log.warn(message),
553
+ },
554
+ },
555
+ );
556
+ log.info(
557
+ `wearables auto-sync started: every ${this.deps.config.wearables.autoSyncIntervalMinutes}m over ${this.deps.config.wearables.autoSyncDays}d (deep ${this.deps.config.wearables.autoSyncDeepDays}d daily)`,
558
+ );
559
+ } catch (err) {
560
+ const { displayErrorDetail } = await import("../runtime/better-sqlite.js");
561
+ log.warn(
562
+ `wearables auto-sync failed to start (non-fatal): ${displayErrorDetail(err)}`,
563
+ );
564
+ }
565
+ }
566
+
567
+ log.info("orchestrator initialized (full — deferred steps complete)");
568
+ }
569
+
570
+ /**
571
+ * Namespace-aware startup search sync. Re-probes QMD, ensures collections
572
+ * (namespace-aware when namespacesEnabled), runs update, and warms up search.
573
+ * Designed for server retry paths that run after the deferred init completes
574
+ * when QMD was not available during initial startup.
575
+ *
576
+ * Accepts an optional AbortSignal so callers can interrupt the sync during
577
+ * shutdown. The signal is checked between phases and forwarded into the QMD
578
+ * update and warmup search calls so a long-running `qmd update` subprocess
579
+ * is killed promptly rather than left in flight after `httpServer.stop()`.
580
+ *
581
+ * Returns true if the sync succeeded (QMD now available), false otherwise.
582
+ */
583
+ async startupSearchSync(signal?: AbortSignal): Promise<boolean> {
584
+ if (signal?.aborted) return false;
585
+
586
+ const available = await this.deps.qmd.probe();
587
+ if (!available) return false;
588
+ if (signal?.aborted) {
589
+ log.debug("startupSearchSync: aborted after probe");
590
+ return false;
591
+ }
592
+
593
+ log.info(`startupSearchSync: backend now available ${this.deps.qmd.debugStatus()}`);
594
+
595
+ // Clear namespace router cache so re-probe picks up newly available backends
596
+ if (resolveNamespaceCapabilities(this.deps.config).namespaces) {
597
+ this.deps.namespaceSearchRouter.clearCache();
598
+ }
599
+
600
+ // Ensure collections — namespace-aware when enabled.
601
+ // Use the catalog-union namespace set (issue #1499 sweep, same class as
602
+ // NHZEV): this is the QMD startup-recovery sync that ensures collections AND
603
+ // runs `updateNamespaces(...)` below over the SAME `namespaces` set. A dynamic
604
+ // namespace that exists only in the persisted catalog must be ensured and
605
+ // re-synced here too, otherwise after a backend-was-unavailable-at-boot
606
+ // recovery its collection stays stale. Falls back to the configured set on any
607
+ // catalog read failure.
608
+ const namespaces = resolveNamespaceCapabilities(this.deps.config).namespaces
609
+ ? await this.deps.maintenanceNamespaces()
610
+ : [this.deps.config.defaultNamespace];
611
+
612
+ const states = await Promise.all(
613
+ namespaces.map(async (namespace) => ({
614
+ namespace,
615
+ state: resolveNamespaceCapabilities(this.deps.config).namespaces
616
+ ? await this.deps.namespaceSearchRouter.ensureNamespaceCollection(namespace, { signal })
617
+ : await this.deps.qmd.ensureCollection(this.deps.config.memoryDir, this.deps.config.qmdCollection, { signal }),
618
+ })),
619
+ );
620
+
621
+ if (signal?.aborted) {
622
+ log.debug("startupSearchSync: aborted after ensureCollection");
623
+ return false;
624
+ }
625
+
626
+ const defaultState =
627
+ states.find((e) => e.namespace === this.deps.config.defaultNamespace)?.state ?? "unknown";
628
+ if (defaultState === "missing") {
629
+ // Reset the real backend's available flag before replacing it with noop.
630
+ // probe() set available=true earlier in this call; without this reset,
631
+ // any code that captured a reference to the old backend (e.g. a concurrent
632
+ // recall() that read this.deps.qmd before the reassignment) would observe
633
+ // isAvailable()===true against a backend with a missing collection.
634
+ if ("available" in this.deps.qmd) {
635
+ (this.deps.qmd as any).available = false;
636
+ }
637
+ await this.deps.disposeSearchBackendIfNeeded();
638
+ this.deps.qmd = new NoopSearchBackend();
639
+ log.warn("startupSearchSync: search collection missing; disabling search (fallback retrieval remains enabled)");
640
+ return false;
641
+ }
642
+
643
+ // Run index update — namespace-aware when enabled.
644
+ // qmd.update() swallows errors internally, so we: (1) snapshot fail/run
645
+ // timestamps, (2) reset throttles so the update isn't skipped by stale
646
+ // backoff, and (3) verify timestamps after update to confirm it executed
647
+ // and didn't fail silently.
648
+ // The abort signal is forwarded into the QMD subprocess call so the
649
+ // long-running `qmd update` process is killed promptly on shutdown.
650
+ if (resolveQmdCapabilities(this.deps.config).qmdMaintenance) {
651
+ try {
652
+ const failTsBefore = "lastUpdateFailedAtMs" in this.deps.qmd
653
+ ? (this.deps.qmd as any).lastUpdateFailedAtMs as number | null
654
+ : null;
655
+ const hasRunTs = "lastUpdateRanAtMs" in this.deps.qmd;
656
+ if ("resetUpdateThrottles" in this.deps.qmd) {
657
+ (this.deps.qmd as any).resetUpdateThrottles();
658
+ }
659
+ log.info("startupSearchSync: updating index to match current disk state");
660
+ let namespacesUpdated = 0;
661
+ if (resolveNamespaceCapabilities(this.deps.config).namespaces) {
662
+ namespacesUpdated = await this.deps.namespaceSearchRouter.updateNamespaces(
663
+ namespaces,
664
+ { signal },
665
+ );
666
+ } else {
667
+ await this.deps.qmd.update({ signal });
668
+ }
669
+ if (signal?.aborted) {
670
+ log.debug("startupSearchSync: aborted after update");
671
+ return false;
672
+ }
673
+ const failTsAfter = "lastUpdateFailedAtMs" in this.deps.qmd
674
+ ? (this.deps.qmd as any).lastUpdateFailedAtMs as number | null
675
+ : null;
676
+ const runTsAfter = hasRunTs
677
+ ? (this.deps.qmd as any).lastUpdateRanAtMs as number | null
678
+ : null;
679
+ if (failTsAfter !== null && failTsAfter !== failTsBefore) {
680
+ log.warn("startupSearchSync: update silently failed (detected via fail timestamp)");
681
+ return false;
682
+ }
683
+ if (resolveNamespaceCapabilities(this.deps.config).namespaces) {
684
+ if (namespacesUpdated === 0) {
685
+ log.warn("startupSearchSync: no namespace backends were eligible for update (all unavailable or collections missing)");
686
+ return false;
687
+ }
688
+ log.info(`startupSearchSync: namespace updates succeeded (${namespacesUpdated}/${namespaces.length} namespaces updated)`);
689
+ } else if (hasRunTs && runTsAfter === null) {
690
+ log.warn("startupSearchSync: update was throttled/skipped (run timestamp is null after reset + update)");
691
+ return false;
692
+ }
693
+ log.info("startupSearchSync: sync complete");
694
+ } catch (err) {
695
+ log.warn(`startupSearchSync: update failed: ${err}`);
696
+ return false;
697
+ }
698
+ }
699
+
700
+ // Warmup search to pre-load embedding models
701
+ if (!signal?.aborted) {
702
+ try {
703
+ await this.deps.qmd.search("warmup", this.deps.config.defaultNamespace, 1, undefined, { signal });
704
+ log.info("startupSearchSync: warmup complete");
705
+ } catch (err) {
706
+ log.debug(`startupSearchSync: warmup search failed (non-fatal): ${err}`);
707
+ }
708
+ }
709
+
710
+ return true;
711
+ }
712
+ }