@lincoln504/pi-research 1.1.2 → 1.3.0
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.
- package/README.md +3 -3
- package/dist/cli.mjs +2599 -1268
- package/dist/thread-worker.mjs +224 -147
- package/docs/AGENT-SKILL.md +11 -4
- package/docs/ARCHITECTURE.md +20 -10
- package/docs/CHANGELOG.md +195 -17
- package/docs/CONFIGURATION.md +9 -7
- package/docs/KNOWLEDGE-STORE.md +4 -3
- package/docs/PI-EXTENSION.md +1 -1
- package/docs/SDK.md +55 -0
- package/docs/THIRD-PARTY-NOTICES.md +1 -1
- package/package.json +5 -4
- package/scripts/cleanup.cjs +114 -12
- package/scripts/prepare.cjs +15 -5
- package/scripts/setup.cjs +24 -4
- package/skills/pi-research/README.md +9 -2
- package/skills/pi-research/SKILL.md +6 -3
- package/skills/pi-research/references/configuration.md +6 -2
- package/skills/pi-research/scripts/run.mjs +24 -2
- package/src/cleanup/research-cleanup.ts +12 -4
- package/src/cli.ts +372 -86
- package/src/config.ts +134 -17
- package/src/constants.ts +31 -0
- package/src/core/interfaces/health-check-interfaces.ts +3 -39
- package/src/core/interfaces/knowledge-interfaces.ts +6 -1
- package/src/core/interfaces/planning-interfaces.ts +8 -0
- package/src/core/interfaces/service-names.ts +0 -1
- package/src/core/interfaces/state-manager-interfaces.ts +11 -3
- package/src/core/llm/agentic-repair.ts +14 -0
- package/src/core/pi-version.ts +136 -0
- package/src/core/planning-service.ts +43 -1
- package/src/core/service-initialization.ts +6 -14
- package/src/core/service-interfaces.ts +1 -1
- package/src/core/service-registry.ts +117 -8
- package/src/healthcheck/index.ts +7 -2
- package/src/index.ts +11 -18
- package/src/infrastructure/browser/browser-client.ts +80 -13
- package/src/infrastructure/browser/browser-error-utils.ts +6 -1
- package/src/infrastructure/browser/browser-server.ts +41 -12
- package/src/infrastructure/browser/browser-task-scheduler.ts +159 -35
- package/src/infrastructure/browser/cleanup-utils.ts +95 -9
- package/src/infrastructure/browser/client-agent.ts +6 -5
- package/src/infrastructure/browser/config.ts +14 -3
- package/src/infrastructure/browser/priority-task-queue.ts +10 -3
- package/src/infrastructure/browser/scheduler-factory.ts +44 -4
- package/src/infrastructure/browser/task-execution-service.ts +92 -28
- package/src/infrastructure/browser/thread-worker-lifecycle.ts +50 -13
- package/src/infrastructure/browser/thread-worker-messaging.ts +54 -2
- package/src/infrastructure/browser/thread-worker-types.ts +3 -1
- package/src/infrastructure/browser/thread-worker.mjs +224 -147
- package/src/infrastructure/browser/worker-pool-manager.ts +18 -5
- package/src/infrastructure/embedding/embedding-client.ts +22 -8
- package/src/infrastructure/embedding/embedding-factory.ts +61 -15
- package/src/infrastructure/embedding/embedding-server.ts +105 -11
- package/src/infrastructure/file-lock-service.ts +295 -28
- package/src/infrastructure/knowledge-store-service.ts +48 -3
- package/src/infrastructure/process-lifecycle-service.ts +134 -34
- package/src/infrastructure/research-run-semaphore.ts +37 -3
- package/src/infrastructure/state/state-backup-manager.ts +5 -7
- package/src/infrastructure/state/state-browser-api.ts +5 -2
- package/src/infrastructure/state/state-browser-manager.ts +15 -1
- package/src/infrastructure/state/state-manager-service.ts +5 -5
- package/src/infrastructure/state/state-manager.ts +34 -23
- package/src/infrastructure/types/state-types.ts +9 -0
- package/src/knowledge/chunker.ts +88 -1
- package/src/knowledge/embedder-init.ts +1 -1
- package/src/knowledge/embedder.ts +188 -46
- package/src/knowledge/index.ts +1 -1
- package/src/knowledge/store.ts +101 -21
- package/src/knowledge/webgpu-viability.ts +9 -4
- package/src/knowledge/writer-queue.ts +88 -46
- package/src/logger.ts +5 -1
- package/src/observers/research-observer-impl.ts +31 -0
- package/src/orchestration/abort-utils.ts +60 -0
- package/src/orchestration/deep-research-orchestrator.ts +25 -4
- package/src/orchestration/headless-observer.ts +8 -0
- package/src/orchestration/quick-research-orchestrator.ts +2 -2
- package/src/orchestration/research-orchestration-service.ts +65 -15
- package/src/orchestration/research-session-service.ts +37 -11
- package/src/orchestration/research-synthesis-service.ts +33 -11
- package/src/orchestration/researcher-executor.ts +100 -20
- package/src/orchestration/researcher.ts +39 -20
- package/src/orchestration/session-state.ts +11 -0
- package/src/sdk.ts +125 -19
- package/src/security/cisa-kev.ts +10 -3
- package/src/security/github-advisories.ts +18 -7
- package/src/security/index.ts +51 -30
- package/src/security/nvd-client.ts +24 -8
- package/src/security/osv-client.ts +14 -4
- package/src/skill-install/skill-installer.ts +153 -11
- package/src/stackexchange/index.ts +20 -5
- package/src/stackexchange/output/table.ts +7 -2
- package/src/stackexchange/rest-client.ts +41 -3
- package/src/tools/research-knowledge-search.ts +15 -0
- package/src/tools/research-tool-definition.ts +8 -2
- package/src/tools/scrape.ts +48 -25
- package/src/tools/search.ts +16 -2
- package/src/tools/security.ts +68 -7
- package/src/tui/research-health.ts +18 -2
- package/src/utils/atomic-replace.ts +101 -0
- package/src/utils/circuit-breaker.ts +9 -2
- package/src/utils/citation-utils.ts +3 -1
- package/src/utils/disk-space-checker.ts +11 -6
- package/src/utils/http-body.ts +176 -0
- package/src/utils/json-utils.ts +116 -76
- package/src/utils/llm-usage.ts +17 -1
- package/src/utils/log-rotation.ts +42 -10
- package/src/utils/log-utils.ts +40 -0
- package/src/utils/metrics-summary.ts +27 -9
- package/src/utils/stdio-capture.ts +63 -22
- package/src/utils/text-utils.ts +8 -1
- package/src/utils/tool-usage-tracker.ts +9 -2
- package/src/web-research/browser-search.ts +70 -5
- package/src/web-research/scraper-utils.ts +13 -0
- package/src/web-research/search.ts +25 -6
- package/src/web-research/types.ts +19 -4
- package/src/web-research/web-scraper.ts +72 -54
- package/src/youtube/transcript-client.ts +5 -1
- package/src/core/health-check-service.ts +0 -265
|
@@ -68,6 +68,36 @@ export class Embedder {
|
|
|
68
68
|
private idleTimer: NodeJS.Timeout | null = null;
|
|
69
69
|
private readonly IDLE_TIMEOUT_MS = 60 * 1000;
|
|
70
70
|
private activeEmbeddings = 0;
|
|
71
|
+
/**
|
|
72
|
+
* Embed calls actively INSIDE the model (a pipeline invocation in flight), as
|
|
73
|
+
* opposed to activeEmbeddings, which counts whole embed()/embedMany() calls —
|
|
74
|
+
* including callers parked on recoveryPromise after a device loss. recoverToCpu
|
|
75
|
+
* drains THIS count before swapping the pipeline: parked callers still hold
|
|
76
|
+
* their activeEmbeddings slot (decremented downstream in their finally), so a
|
|
77
|
+
* drain on activeEmbeddings could never succeed with ≥2 concurrent device-loss
|
|
78
|
+
* failures and always burned the full timeout.
|
|
79
|
+
*/
|
|
80
|
+
private activeInferences = 0;
|
|
81
|
+
/**
|
|
82
|
+
* Callers parked on recoveryPromise — they JOINED an in-flight recovery while
|
|
83
|
+
* still holding their activeEmbeddings slot (the decrement runs downstream in
|
|
84
|
+
* their finally, after the joined recovery settles). recoverToCpu's drain must
|
|
85
|
+
* distinguish them from callers still progressing toward the pipeline (parked
|
|
86
|
+
* on the per-call GPU lock, or between embedMany batches): parked joiners can
|
|
87
|
+
* never finish until recovery completes (waiting on them deadlocks into the
|
|
88
|
+
* drain timeout), while the progressing callers MUST be waited for — disposing
|
|
89
|
+
* the pipeline under them raises a TypeError no device-error classifier
|
|
90
|
+
* recognizes, so their embed fails unrecovered.
|
|
91
|
+
*/
|
|
92
|
+
private recoveryWaiters = 0;
|
|
93
|
+
/**
|
|
94
|
+
* Why the in-flight dispose was started. An IDLE dispose is a memory-reclaim
|
|
95
|
+
* pause that a later embed is expected to undo, so initialize() waits it out and
|
|
96
|
+
* re-initializes. A TERMINAL dispose (shutdown, config change, explicit
|
|
97
|
+
* disposal) must never be undone — re-initializing there would resurrect the
|
|
98
|
+
* ONNX pipeline during teardown. Only meaningful while state === 'disposing'.
|
|
99
|
+
*/
|
|
100
|
+
private disposeReason: 'idle' | 'terminal' = 'terminal';
|
|
71
101
|
|
|
72
102
|
constructor(options: EmbedderOptions) {
|
|
73
103
|
this.model = options.model;
|
|
@@ -113,7 +143,7 @@ export class Embedder {
|
|
|
113
143
|
}
|
|
114
144
|
if (this.state === 'ready') {
|
|
115
145
|
logger.info(`[embedder] Idle timeout reached (${this.IDLE_TIMEOUT_MS}ms), releasing GPU memory...`);
|
|
116
|
-
this.dispose().catch(err => logger.warn('[embedder] Failed to dispose on idle:', err));
|
|
146
|
+
this.dispose('idle').catch(err => logger.warn('[embedder] Failed to dispose on idle:', err));
|
|
117
147
|
}
|
|
118
148
|
}, this.IDLE_TIMEOUT_MS);
|
|
119
149
|
if (this.idleTimer) safeUnref(this.idleTimer);
|
|
@@ -129,7 +159,36 @@ export class Embedder {
|
|
|
129
159
|
async initialize(): Promise<void> {
|
|
130
160
|
if (this.state === 'ready') return;
|
|
131
161
|
if (this.state === 'disposing') {
|
|
132
|
-
|
|
162
|
+
// An IDLE dispose is a pause, not an end: the pipeline was released purely
|
|
163
|
+
// to reclaim GPU/host memory and the next embed is meant to bring it back.
|
|
164
|
+
// Throwing here lost that race — the idle timer fires, dispose() flips the
|
|
165
|
+
// state, and a document that arrives during the (up to ~5s) teardown got
|
|
166
|
+
// "Cannot initialize while disposing", which nothing classifies as
|
|
167
|
+
// transient, so the writer queue dropped it with no retry. Wait the dispose
|
|
168
|
+
// out and re-initialize instead.
|
|
169
|
+
if (this.disposeReason === 'idle') {
|
|
170
|
+
logger.debug('[embedder] initialize() raced an idle dispose — waiting for it to finish, then re-initializing.');
|
|
171
|
+
await this.disposePromise?.catch(() => { /* a failed idle dispose must not block the revive */ });
|
|
172
|
+
// Re-check: another caller may have revived (or terminally disposed) the
|
|
173
|
+
// embedder while we waited.
|
|
174
|
+
if ((this.state as EmbedderState) === 'ready') return;
|
|
175
|
+
// A terminal dispose landed as a DOWNGRADE onto the idle dispose we just
|
|
176
|
+
// awaited: state is 'idle', but reviving is forbidden — pre-fix this fell
|
|
177
|
+
// through to a full model load during process teardown. (A LATER, fresh
|
|
178
|
+
// initialize()/embed() after the completed dispose never enters this
|
|
179
|
+
// branch and stays revivable — that re-use is pinned behaviour.) The cast
|
|
180
|
+
// defeats control-flow narrowing, which cannot see the awaited dispose
|
|
181
|
+
// mutating the field — same idiom as the state re-checks around it.
|
|
182
|
+
if ((this.disposeReason as 'idle' | 'terminal') === 'terminal') {
|
|
183
|
+
throw new Error('Cannot initialize while disposing');
|
|
184
|
+
}
|
|
185
|
+
if ((this.state as EmbedderState) === 'disposing') {
|
|
186
|
+
throw new Error('Cannot initialize while disposing');
|
|
187
|
+
}
|
|
188
|
+
} else {
|
|
189
|
+
// Terminal dispose (shutdown / config change): must NOT be revived.
|
|
190
|
+
throw new Error('Cannot initialize while disposing');
|
|
191
|
+
}
|
|
133
192
|
}
|
|
134
193
|
|
|
135
194
|
if (this.initializingPromise) {
|
|
@@ -352,13 +411,36 @@ export class Embedder {
|
|
|
352
411
|
return text;
|
|
353
412
|
}
|
|
354
413
|
|
|
414
|
+
/**
|
|
415
|
+
* Run the pipeline over an input, tracked in activeInferences so recoverToCpu's
|
|
416
|
+
* drain loop waits only on calls genuinely inside the model.
|
|
417
|
+
*/
|
|
418
|
+
private async runInference(input: string | string[]): Promise<any> {
|
|
419
|
+
// A disposal/recovery that raced this call may have nulled the pipeline out
|
|
420
|
+
// from under us. Calling through null raises a bare TypeError that nothing
|
|
421
|
+
// classifies (the writer queue drops the document as a generic ingest_error);
|
|
422
|
+
// throw the reconnect-classified signal instead — isEmbedderUnreachable
|
|
423
|
+
// matches "aborted" — so the caller retries via a fresh embedder.
|
|
424
|
+
if (!this.pipeline) {
|
|
425
|
+
throw new Error('[embedder] embed aborted: pipeline disposed before inference could run — reconnect required');
|
|
426
|
+
}
|
|
427
|
+
this.activeInferences++;
|
|
428
|
+
try {
|
|
429
|
+
return await logger.runCapturingStderr(async () => {
|
|
430
|
+
return await this.pipeline!(input, this.pipelineOpts());
|
|
431
|
+
});
|
|
432
|
+
} finally {
|
|
433
|
+
this.activeInferences--;
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
|
|
355
437
|
async embed(text: string): Promise<Float32Array> {
|
|
356
438
|
await this.initialize();
|
|
357
439
|
// Compute the input BEFORE the active-count increment: truncateText is a pure
|
|
358
440
|
// string op, and keeping it (and nothing else) ahead of the try/finally means
|
|
359
441
|
// every path that can throw or await is inside the finally that decrements the
|
|
360
442
|
// counter. Otherwise a throw here (or in acquireGpuLock) would leak the count,
|
|
361
|
-
// permanently wedging
|
|
443
|
+
// permanently wedging dispose (5s hang).
|
|
362
444
|
const input = this.truncateText(this.queryPrefix ? this.queryPrefix + text : text);
|
|
363
445
|
this.stopIdleTimer();
|
|
364
446
|
this.activeEmbeddings++;
|
|
@@ -371,9 +453,7 @@ export class Embedder {
|
|
|
371
453
|
logger.warn('[embedder] GPU per-call lock timeout after 15s — proceeding without lock');
|
|
372
454
|
}
|
|
373
455
|
}
|
|
374
|
-
const output = await
|
|
375
|
-
return await this.pipeline!(input, this.pipelineOpts());
|
|
376
|
-
});
|
|
456
|
+
const output = await this.runInference(input);
|
|
377
457
|
return output.data as Float32Array;
|
|
378
458
|
} catch (err) {
|
|
379
459
|
if (isWebGpuDeviceError(err)) {
|
|
@@ -388,9 +468,7 @@ export class Embedder {
|
|
|
388
468
|
// dereferencing a null pipeline would raise a TypeError that MASKS the
|
|
389
469
|
// original device error — rethrow the original instead.
|
|
390
470
|
if (!this.pipeline) throw err;
|
|
391
|
-
const output = await
|
|
392
|
-
return await this.pipeline!(input, this.pipelineOpts());
|
|
393
|
-
});
|
|
471
|
+
const output = await this.runInference(input);
|
|
394
472
|
return output.data as Float32Array;
|
|
395
473
|
}
|
|
396
474
|
throw err;
|
|
@@ -435,9 +513,7 @@ export class Embedder {
|
|
|
435
513
|
|
|
436
514
|
let output: any;
|
|
437
515
|
try {
|
|
438
|
-
output = await
|
|
439
|
-
return await this.pipeline!(batch, this.pipelineOpts());
|
|
440
|
-
});
|
|
516
|
+
output = await this.runInference(batch);
|
|
441
517
|
} catch (err) {
|
|
442
518
|
if (isWebGpuDeviceError(err)) {
|
|
443
519
|
if (lockAcquired && this.stateManager) {
|
|
@@ -450,9 +526,7 @@ export class Embedder {
|
|
|
450
526
|
// Same guard as embed(): recoverToCpu may have skipped (disposing/idle),
|
|
451
527
|
// leaving pipeline null — rethrow the original error, not a TypeError.
|
|
452
528
|
if (!this.pipeline) throw err;
|
|
453
|
-
output = await
|
|
454
|
-
return await this.pipeline!(batch, this.pipelineOpts());
|
|
455
|
-
});
|
|
529
|
+
output = await this.runInference(batch);
|
|
456
530
|
} else {
|
|
457
531
|
throw err;
|
|
458
532
|
}
|
|
@@ -476,7 +550,17 @@ export class Embedder {
|
|
|
476
550
|
}
|
|
477
551
|
|
|
478
552
|
private async recoverToCpu(): Promise<void> {
|
|
479
|
-
if (this.recoveryPromise)
|
|
553
|
+
if (this.recoveryPromise) {
|
|
554
|
+
// Counted join: this caller still holds its activeEmbeddings slot while
|
|
555
|
+
// parked here, and the drain below must not wait on parked joiners (see
|
|
556
|
+
// the recoveryWaiters field comment).
|
|
557
|
+
this.recoveryWaiters++;
|
|
558
|
+
try {
|
|
559
|
+
return await this.recoveryPromise;
|
|
560
|
+
} finally {
|
|
561
|
+
this.recoveryWaiters--;
|
|
562
|
+
}
|
|
563
|
+
}
|
|
480
564
|
|
|
481
565
|
// Guard: if disposal has already started, skip recovery — the embedder is going away.
|
|
482
566
|
if (this.state === 'disposing' || this.state === 'idle') {
|
|
@@ -484,22 +568,42 @@ export class Embedder {
|
|
|
484
568
|
return;
|
|
485
569
|
}
|
|
486
570
|
|
|
487
|
-
|
|
571
|
+
// Close the initialize() re-entry window SYNCHRONOUSLY, before the first
|
|
572
|
+
// await: initialize() only checks 'ready'/'disposing' and initializingPromise,
|
|
573
|
+
// so until the recovery promise below is published, a concurrent
|
|
574
|
+
// embed()/embedMany() passed every guard and launched a second concurrent
|
|
575
|
+
// _initializeInternal — racing pipeline/state assignments, leaking the losing
|
|
576
|
+
// pipeline, and (device was flipped to 'cpu' only after the drain loop)
|
|
577
|
+
// reloading the exact WebGPU backend that just failed. Flip device first so
|
|
578
|
+
// no joiner can ever observe 'webgpu' again this session.
|
|
579
|
+
this.state = 'initializing';
|
|
580
|
+
this.device = 'cpu';
|
|
581
|
+
|
|
582
|
+
const recovery = (async () => {
|
|
488
583
|
logger.warn('[embedder] WebGPU device error detected during operation — falling back to CPU for the remainder of this session');
|
|
489
|
-
|
|
584
|
+
|
|
490
585
|
markWebGpuFallback();
|
|
491
|
-
|
|
586
|
+
|
|
492
587
|
await releaseGpuLock(this.stateManager, this.gpuLockHeld);
|
|
493
588
|
this.gpuLockHeld = false;
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
//
|
|
589
|
+
|
|
590
|
+
// Drain in-flight callers before disposing the pipeline, on BOTH counts:
|
|
591
|
+
// - activeInferences > 0: a call genuinely inside the model.
|
|
592
|
+
// - activeEmbeddings - recoveryWaiters > 1: callers past initialize() but
|
|
593
|
+
// not yet inside the model — parked on the per-call GPU lock, or between
|
|
594
|
+
// embedMany batches. Self counts as 1 (this recovery runs from a caller
|
|
595
|
+
// holding its own slot); parked joiners are subtracted because they can
|
|
596
|
+
// never finish until THIS recovery completes (an uncorrected
|
|
597
|
+
// activeEmbeddings drain always burned the full timeout under
|
|
598
|
+
// multi-caller device loss). An inferences-only drain (the prior form)
|
|
599
|
+
// disposed the pipeline under a lock-parked caller, whose embed then
|
|
600
|
+
// failed with an unclassified TypeError, masking the device loss.
|
|
498
601
|
const maxWaitMs = 15000;
|
|
499
602
|
const startTime = Date.now();
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
603
|
+
while (
|
|
604
|
+
(this.activeInferences > 0 || this.activeEmbeddings - this.recoveryWaiters > 1) &&
|
|
605
|
+
(Date.now() - startTime) < maxWaitMs
|
|
606
|
+
) {
|
|
503
607
|
await new Promise(resolve => setTimeout(resolve, 50));
|
|
504
608
|
}
|
|
505
609
|
|
|
@@ -507,48 +611,77 @@ export class Embedder {
|
|
|
507
611
|
try { if (typeof (this.pipeline as any).dispose === 'function') await (this.pipeline as DisposablePipeline).dispose(); } catch (err) { logger.warn('[embedder] Error disposing pipeline:', err); }
|
|
508
612
|
this.pipeline = null;
|
|
509
613
|
}
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
this.initializingPromise = this._initializeInternal();
|
|
614
|
+
|
|
513
615
|
try {
|
|
514
|
-
|
|
515
|
-
this.state = 'ready';
|
|
616
|
+
await this._initializeInternal();
|
|
516
617
|
} catch (e) {
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
} finally {
|
|
520
|
-
this.initializingPromise = null;
|
|
618
|
+
this.state = 'failed';
|
|
619
|
+
throw e;
|
|
521
620
|
}
|
|
621
|
+
// Same guard as initialize(): a terminal dispose may have landed while the
|
|
622
|
+
// CPU pipeline loaded — never overwrite 'disposing' with 'ready'. (Cast
|
|
623
|
+
// defeats control-flow narrowing, which cannot see the awaited init racing
|
|
624
|
+
// dispose() — same idiom as the state re-checks in initialize().)
|
|
625
|
+
if ((this.state as EmbedderState) === 'disposing') {
|
|
626
|
+
logger.warn('[embedder] Recovery finished but embedder was disposed in the meantime.');
|
|
627
|
+
return;
|
|
628
|
+
}
|
|
629
|
+
this.state = 'ready';
|
|
522
630
|
logger.warn('[embedder] CPU fallback recovery complete.');
|
|
523
631
|
})();
|
|
524
632
|
|
|
633
|
+
this.recoveryPromise = recovery;
|
|
634
|
+
// Publish for initialize(): a caller arriving mid-recovery must join the
|
|
635
|
+
// WHOLE recovery (drain + pipeline swap + re-init), not start its own. This
|
|
636
|
+
// and the assignments above run before recovery's first await, so there is
|
|
637
|
+
// no window in which a joiner can slip past.
|
|
638
|
+
this.initializingPromise = recovery;
|
|
639
|
+
|
|
525
640
|
try {
|
|
526
|
-
await
|
|
641
|
+
await recovery;
|
|
527
642
|
} finally {
|
|
528
|
-
this.recoveryPromise = null;
|
|
643
|
+
if (this.recoveryPromise === recovery) this.recoveryPromise = null;
|
|
644
|
+
// Guarded: by the time this finally runs, a fresh initialize() may already
|
|
645
|
+
// own initializingPromise (e.g. after a failed recovery) — never null it out
|
|
646
|
+
// from under that caller.
|
|
647
|
+
if (this.initializingPromise === recovery) this.initializingPromise = null;
|
|
529
648
|
}
|
|
530
649
|
}
|
|
531
650
|
|
|
532
|
-
|
|
651
|
+
/**
|
|
652
|
+
* @param reason `'idle'` for the memory-reclaim pause driven by the idle timer —
|
|
653
|
+
* a later embed is allowed to revive the embedder. Anything else is terminal
|
|
654
|
+
* (shutdown, config change, explicit disposal) and must never be revived, so
|
|
655
|
+
* the default is deliberately the safe one: callers that do not opt in get
|
|
656
|
+
* terminal semantics.
|
|
657
|
+
*/
|
|
658
|
+
async dispose(reason: 'idle' | 'terminal' = 'terminal'): Promise<void> {
|
|
533
659
|
if (this.state === 'idle') return;
|
|
534
660
|
this.stopIdleTimer();
|
|
535
661
|
|
|
536
662
|
if (this.state === 'disposing' && this.disposePromise) {
|
|
663
|
+
// A terminal dispose landing on top of an in-flight idle dispose must
|
|
664
|
+
// downgrade the reason, so a concurrent initialize() cannot revive what is
|
|
665
|
+
// now a shutdown. Never the reverse.
|
|
666
|
+
if (reason === 'terminal') this.disposeReason = 'terminal';
|
|
537
667
|
return this.disposePromise;
|
|
538
668
|
}
|
|
539
669
|
|
|
670
|
+
this.disposeReason = reason;
|
|
540
671
|
this.state = 'disposing';
|
|
541
672
|
this.disposePromise = (async () => {
|
|
542
673
|
// Wait for all active embeddings to complete
|
|
543
674
|
const maxWaitMs = 5000;
|
|
544
|
-
const
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
675
|
+
const drainActiveEmbeddings = async (): Promise<void> => {
|
|
676
|
+
const startTime = Date.now();
|
|
677
|
+
while (this.activeEmbeddings > 0 && (Date.now() - startTime) < maxWaitMs) {
|
|
678
|
+
await new Promise(resolve => setTimeout(resolve, 50));
|
|
679
|
+
}
|
|
680
|
+
if (this.activeEmbeddings > 0) {
|
|
681
|
+
logger.warn(`[embedder] Disposing with ${this.activeEmbeddings} active embeddings (timed out)`);
|
|
682
|
+
}
|
|
683
|
+
};
|
|
684
|
+
await drainActiveEmbeddings();
|
|
552
685
|
|
|
553
686
|
if (this.initializingPromise) {
|
|
554
687
|
try {
|
|
@@ -556,6 +689,15 @@ export class Embedder {
|
|
|
556
689
|
} catch (_e) {
|
|
557
690
|
// Initialize failed, which is fine
|
|
558
691
|
}
|
|
692
|
+
// A caller parked on that same init increments activeEmbeddings only
|
|
693
|
+
// AFTER init resolves — i.e. after the drain above already passed — and
|
|
694
|
+
// initialize()'s disposed-mid-init branch returns cleanly, so its embed
|
|
695
|
+
// proceeds into runInference. Yield one macrotask so those callers reach
|
|
696
|
+
// their increment, then drain AGAIN; otherwise the pipeline below is
|
|
697
|
+
// torn down under a just-started inference (an unclassified TypeError
|
|
698
|
+
// the writer queue drops as a generic ingest_error).
|
|
699
|
+
await new Promise(resolve => setTimeout(resolve, 0));
|
|
700
|
+
await drainActiveEmbeddings();
|
|
559
701
|
}
|
|
560
702
|
|
|
561
703
|
if (this.pipeline) {
|
package/src/knowledge/index.ts
CHANGED
|
@@ -115,7 +115,7 @@ export async function forceDeleteKnowledgeStore(config?: Config, workspace?: str
|
|
|
115
115
|
const dbDir = getDbDir(config, workspace);
|
|
116
116
|
if (fs.existsSync(dbDir)) {
|
|
117
117
|
try {
|
|
118
|
-
fs.rmSync(dbDir, { recursive: true, force: true });
|
|
118
|
+
fs.rmSync(dbDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 });
|
|
119
119
|
fs.mkdirSync(dbDir, { recursive: true });
|
|
120
120
|
logger.info(`[knowledge] Knowledge store at ${dbDir} forcefully deleted and recreated.`);
|
|
121
121
|
} catch (err) {
|
package/src/knowledge/store.ts
CHANGED
|
@@ -256,7 +256,12 @@ export class KnowledgeStore implements IKnowledgeStore {
|
|
|
256
256
|
// getDimension() report the correct new width. (re-embed needs the warm
|
|
257
257
|
// embedder regardless.)
|
|
258
258
|
if (this.options.embedder.getDimension() === null) {
|
|
259
|
-
|
|
259
|
+
// Reconnect-wrapped: after a service re-scope this warm-up can be
|
|
260
|
+
// the FIRST embed against a stale cached instance (a stepped-down
|
|
261
|
+
// leader fast-fails with ECONNREFUSED); without the wrapper that
|
|
262
|
+
// single fast-fail aborted the whole open, burning an init retry
|
|
263
|
+
// per attempt against the same dead instance.
|
|
264
|
+
await this.withEmbedderReconnect(embedder => embedder.embedMany([' ']));
|
|
260
265
|
}
|
|
261
266
|
try {
|
|
262
267
|
await this.handleModelChange(storedModel || 'unknown', this.options.modelName, strategy);
|
|
@@ -721,12 +726,28 @@ export class KnowledgeStore implements IKnowledgeStore {
|
|
|
721
726
|
}
|
|
722
727
|
}
|
|
723
728
|
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
729
|
+
/**
|
|
730
|
+
* @param olderThan When set, only rows with `timestamp < olderThan` are removed.
|
|
731
|
+
* This is what lets a refresh write the replacement chunks FIRST and prune the
|
|
732
|
+
* superseded ones after, instead of deleting first and losing the entry
|
|
733
|
+
* outright if the insert then fails. `timestamp` is used as the discriminator
|
|
734
|
+
* because it is a real (indexed) column — `contentHash` lives inside the
|
|
735
|
+
* JSON-encoded `metadata` blob and cannot be filtered on.
|
|
736
|
+
*/
|
|
737
|
+
async deleteByUrlAndType(url: string, ingestionType: string, olderThan?: number): Promise<void> {
|
|
738
|
+
// Coordinate with close() — see deleteByUrl. The closing-skip is surfaced
|
|
739
|
+
// rather than silently resolving: a prune skipped because the store is
|
|
740
|
+
// closing (a post-run background ingest racing a Ctrl+C close) leaves
|
|
741
|
+
// duplicates exactly like a failed prune does, but resolved cleanly, so the
|
|
742
|
+
// writer-queue's failed-prune accounting never saw its most common cause.
|
|
743
|
+
if (this.isClosing) {
|
|
744
|
+
logger.warn(`[store] Prune of ${ingestionType} chunks for ${url} skipped — store is closing; superseded duplicates remain until the next refresh.`);
|
|
745
|
+
metrics.increment('knowledge_store_delete_total', 1, { operation: 'by_url_and_type', status: 'skipped_closing' });
|
|
746
|
+
}
|
|
747
|
+
return this.trackOperation('deleteByUrlAndType', undefined, () => this.deleteByUrlAndTypeInner(url, ingestionType, olderThan));
|
|
727
748
|
}
|
|
728
749
|
|
|
729
|
-
private async deleteByUrlAndTypeInner(url: string, ingestionType: string): Promise<void> {
|
|
750
|
+
private async deleteByUrlAndTypeInner(url: string, ingestionType: string, olderThan?: number): Promise<void> {
|
|
730
751
|
let retryCount = 0;
|
|
731
752
|
const MAX_RETRIES = 5;
|
|
732
753
|
const BASE_DELAY = 100;
|
|
@@ -738,11 +759,15 @@ export class KnowledgeStore implements IKnowledgeStore {
|
|
|
738
759
|
try {
|
|
739
760
|
const escapedUrl = url.replace(/'/g, "''");
|
|
740
761
|
const escapedType = ingestionType.replace(/'/g, "''");
|
|
741
|
-
|
|
762
|
+
// Integer-coerced so the predicate can never carry anything but digits.
|
|
763
|
+
const ageClause = Number.isFinite(olderThan as number)
|
|
764
|
+
? ` AND timestamp < ${Math.floor(olderThan as number)}`
|
|
765
|
+
: '';
|
|
766
|
+
await table.delete(`url = '${escapedUrl}' AND ingestion_type = '${escapedType}' AND (${scopeFilter})${ageClause}`);
|
|
742
767
|
const duration = Date.now() - startTime;
|
|
743
768
|
metrics.observe('knowledge_store_delete_duration_ms', duration, { operation: 'by_url_and_type' });
|
|
744
769
|
metrics.increment('knowledge_store_delete_total', 1, { operation: 'by_url_and_type', status: 'success' });
|
|
745
|
-
logger.log(`[store] Deleted ${ingestionType} chunks for ${url} (scoped)`);
|
|
770
|
+
logger.log(`[store] Deleted ${ingestionType} chunks for ${url} (scoped)${ageClause ? ' older than the replacement' : ''}`);
|
|
746
771
|
return;
|
|
747
772
|
} catch (err) {
|
|
748
773
|
if (isLanceCommitConflict(err)) {
|
|
@@ -842,10 +867,14 @@ export class KnowledgeStore implements IKnowledgeStore {
|
|
|
842
867
|
const startTime = Date.now();
|
|
843
868
|
const escapedUrl = url.replace(/'/g, "''");
|
|
844
869
|
|
|
870
|
+
// limit > 1 deliberately: after a failed prune two generations of this URL can
|
|
871
|
+
// coexist, and LanceDB's fragment-scan order typically returns the OLDEST row
|
|
872
|
+
// first — limit(1) would serve the superseded content (and feed its older
|
|
873
|
+
// timestamp to the serve-age gate) until the next successful ingest.
|
|
845
874
|
const results = await table
|
|
846
875
|
.query()
|
|
847
876
|
.where(`url = '${escapedUrl}' AND ingestion_type = 'synthesis-description' AND (${scopeFilter})`)
|
|
848
|
-
.limit(
|
|
877
|
+
.limit(16)
|
|
849
878
|
.toArray();
|
|
850
879
|
|
|
851
880
|
const duration = Date.now() - startTime;
|
|
@@ -856,6 +885,8 @@ export class KnowledgeStore implements IKnowledgeStore {
|
|
|
856
885
|
metrics.increment('knowledge_store_cache_hits_total', 1, { status: 'miss' });
|
|
857
886
|
return null;
|
|
858
887
|
}
|
|
888
|
+
// Newest generation wins, never row order.
|
|
889
|
+
results.sort((a: any, b: any) => Number(b.timestamp) - Number(a.timestamp));
|
|
859
890
|
|
|
860
891
|
const r = results[0];
|
|
861
892
|
// Read-time freshness. The stored timestamp (ms) is otherwise discarded here;
|
|
@@ -951,9 +982,19 @@ export class KnowledgeStore implements IKnowledgeStore {
|
|
|
951
982
|
config: Index.fts(),
|
|
952
983
|
replace: true,
|
|
953
984
|
});
|
|
954
|
-
// Record the
|
|
955
|
-
//
|
|
956
|
-
|
|
985
|
+
// Record the post-rebuild baseline from THIS handle, whose version() is
|
|
986
|
+
// checkout-stable: it advances with our own createIndex commits but not with
|
|
987
|
+
// commits from other handles/processes. Re-reading through a fresh handle
|
|
988
|
+
// here (the old code) read the LATEST version instead — addDocuments takes
|
|
989
|
+
// no lock, so rows committed DURING the rebuild were absorbed into the
|
|
990
|
+
// baseline as already-indexed and stayed out of the BM25 leg until an
|
|
991
|
+
// unrelated later commit. Each createIndex commits exactly one version
|
|
992
|
+
// (measured on lancedb 0.29, fresh and replace alike); anything beyond
|
|
993
|
+
// version+2 means a concurrent commit rebased beneath our index commits, so
|
|
994
|
+
// fall back to the conservative pre-rebuild baseline — the next cleanup
|
|
995
|
+
// sees version > baseline and rebuilds over the missed rows.
|
|
996
|
+
const postVersion = await table.version();
|
|
997
|
+
this.lastFtsVersion = postVersion <= version + 2 ? postVersion : version;
|
|
957
998
|
logger.info('[store] FTS indexes rebuilt (text + content).');
|
|
958
999
|
return true;
|
|
959
1000
|
} catch (err) {
|
|
@@ -1001,7 +1042,12 @@ export class KnowledgeStore implements IKnowledgeStore {
|
|
|
1001
1042
|
// rebuilds + re-optimizes (the disk-bloat regression). A stale baseline (index not
|
|
1002
1043
|
// current, or never built) is left untouched so the next rebuild still detects the
|
|
1003
1044
|
// pending change.
|
|
1004
|
-
|
|
1045
|
+
// The row count is sampled BEFORE the version read: since optimize never changes
|
|
1046
|
+
// the row set, a same-handle count that differs after the call proves rows were
|
|
1047
|
+
// committed DURING optimize's window (rebased beneath its compaction commits) —
|
|
1048
|
+
// rows the FTS index does not cover, which the baseline advance below must skip.
|
|
1049
|
+
const rowCountBefore = !stale && this.lastFtsVersion !== null ? await table.countRows() : null;
|
|
1050
|
+
const wasFtsIndexCurrent = rowCountBefore !== null && this.lastFtsVersion === await table.version();
|
|
1005
1051
|
// cleanupOlderThan defaults to now: prune every prunable old version. The
|
|
1006
1052
|
// current version is always retained, and (with deleteUnverified=false)
|
|
1007
1053
|
// physical files inside LanceDB's safety window are kept regardless.
|
|
@@ -1017,9 +1063,14 @@ export class KnowledgeStore implements IKnowledgeStore {
|
|
|
1017
1063
|
const bytesRemoved = stats?.prune?.bytesRemoved ?? 0;
|
|
1018
1064
|
logger.info(`[store] Optimize complete: removed ${removed} old version(s), reclaimed ${bytesRemoved} bytes (${duration}ms).`);
|
|
1019
1065
|
// Keep the FTS skip-guard baseline in sync with optimize's layout-only version bump
|
|
1020
|
-
// so the next rebuildFtsIndex correctly skips when no content changed.
|
|
1021
|
-
|
|
1022
|
-
|
|
1066
|
+
// so the next rebuildFtsIndex correctly skips when no content changed. Stamp from
|
|
1067
|
+
// THIS handle (checkout-stable: it advances with optimize's own commits but not
|
|
1068
|
+
// with foreign ones — a fresh re-open here read the latest version and absorbed
|
|
1069
|
+
// concurrent commits), and only when the row count is unchanged: a differing count
|
|
1070
|
+
// means an addDocuments landed inside optimize's window and must not be recorded
|
|
1071
|
+
// as already-indexed (the baseline stays put, so the next rebuild catches it).
|
|
1072
|
+
if (wasFtsIndexCurrent && (await table.countRows()) === rowCountBefore) {
|
|
1073
|
+
this.lastFtsVersion = await table.version();
|
|
1023
1074
|
}
|
|
1024
1075
|
return true;
|
|
1025
1076
|
} catch (err) {
|
|
@@ -1081,20 +1132,28 @@ export class KnowledgeStore implements IKnowledgeStore {
|
|
|
1081
1132
|
for (const name of migrationDirs) {
|
|
1082
1133
|
const fullPath = path.join(this.options.dbDir, name);
|
|
1083
1134
|
logger.info(`[store] Removing orphaned migration temp directory: ${name}`);
|
|
1084
|
-
await fsPromises.rm(fullPath, { recursive: true, force: true });
|
|
1135
|
+
await fsPromises.rm(fullPath, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 });
|
|
1085
1136
|
}
|
|
1086
1137
|
|
|
1087
1138
|
// `_backup_` dirs are intentional, user-recoverable snapshots from the
|
|
1088
1139
|
// 'backup' migration strategy. RETAIN the most recent one (deleting it
|
|
1089
1140
|
// would make 'backup' silently equivalent to 'drop'); prune only older,
|
|
1090
|
-
// superseded backups so they don't accumulate.
|
|
1091
|
-
//
|
|
1141
|
+
// superseded backups so they don't accumulate. Rank by the trailing
|
|
1142
|
+
// `_backup_<timestamp>` suffix, NOT by whole-name sort: the prefix is the
|
|
1143
|
+
// table name AT BACKUP TIME — legitimately `knowledge` OR
|
|
1144
|
+
// `knowledge_migration_<ts>` (persisted re-embed rename failure) — and
|
|
1145
|
+
// `knowledge_b…` < `knowledge_m…` lexicographically, so a whole-name sort
|
|
1146
|
+
// retained an OLD migration-named backup while deleting a NEWER
|
|
1147
|
+
// `knowledge_backup_*`.
|
|
1092
1148
|
if (backupDirs.length > 1) {
|
|
1093
|
-
|
|
1094
|
-
|
|
1149
|
+
const ranked = await Promise.all(
|
|
1150
|
+
backupDirs.map(async name => ({ name, ts: await this.backupDirTimestamp(name) }))
|
|
1151
|
+
);
|
|
1152
|
+
ranked.sort((a, b) => a.ts - b.ts);
|
|
1153
|
+
for (const { name } of ranked.slice(0, -1)) {
|
|
1095
1154
|
const fullPath = path.join(this.options.dbDir, name);
|
|
1096
1155
|
logger.info(`[store] Removing superseded backup directory: ${name}`);
|
|
1097
|
-
await fsPromises.rm(fullPath, { recursive: true, force: true });
|
|
1156
|
+
await fsPromises.rm(fullPath, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 });
|
|
1098
1157
|
}
|
|
1099
1158
|
}
|
|
1100
1159
|
} catch (err) {
|
|
@@ -1102,6 +1161,27 @@ export class KnowledgeStore implements IKnowledgeStore {
|
|
|
1102
1161
|
}
|
|
1103
1162
|
}
|
|
1104
1163
|
|
|
1164
|
+
/**
|
|
1165
|
+
* Creation time of a backup dir, for retention ranking in
|
|
1166
|
+
* pruneOrphanedMigrationDirs. Parses the trailing `_backup_<timestamp>` suffix
|
|
1167
|
+
* (an ISO timestamp with `:`/`.` mapped to `-`, exactly as migrationBackup
|
|
1168
|
+
* writes it); falls back to directory mtime for names that don't parse, and to
|
|
1169
|
+
* 0 (ranked oldest, pruned first) if even stat fails.
|
|
1170
|
+
*/
|
|
1171
|
+
private async backupDirTimestamp(name: string): Promise<number> {
|
|
1172
|
+
const m = name.match(/_backup_(\d{4}-\d{2}-\d{2})T(\d{2})-(\d{2})-(\d{2})-(\d{3})Z\.lance$/);
|
|
1173
|
+
if (m) {
|
|
1174
|
+
const parsed = Date.parse(`${m[1]}T${m[2]}:${m[3]}:${m[4]}.${m[5]}Z`);
|
|
1175
|
+
if (!Number.isNaN(parsed)) return parsed;
|
|
1176
|
+
}
|
|
1177
|
+
try {
|
|
1178
|
+
const stat = await fsPromises.stat(path.join(this.options.dbDir, name));
|
|
1179
|
+
return stat.mtimeMs;
|
|
1180
|
+
} catch {
|
|
1181
|
+
return 0;
|
|
1182
|
+
}
|
|
1183
|
+
}
|
|
1184
|
+
|
|
1105
1185
|
async count(): Promise<number> {
|
|
1106
1186
|
if (!this.db) return 0;
|
|
1107
1187
|
// Track against close() — this read had no try/catch, so a teardown racing db.close()
|
|
@@ -150,10 +150,13 @@ function runProbe(model: string, cacheDir: string, timeoutMs: number): Promise<b
|
|
|
150
150
|
}, timeoutMs);
|
|
151
151
|
if (typeof timer.unref === 'function') timer.unref();
|
|
152
152
|
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
153
|
+
// Buffered and decoded once at 'close': per-chunk String(d) mangles a
|
|
154
|
+
// multi-byte character straddling a pipe-chunk boundary (the PROBE_OK
|
|
155
|
+
// sentinel is ASCII, but the logged stderr detail line is not guaranteed to be).
|
|
156
|
+
const stdoutChunks: Buffer[] = [];
|
|
157
|
+
const stderrChunks: Buffer[] = [];
|
|
158
|
+
child.stdout?.on('data', (d) => { stdoutChunks.push(Buffer.from(d)); });
|
|
159
|
+
child.stderr?.on('data', (d) => { stderrChunks.push(Buffer.from(d)); });
|
|
157
160
|
|
|
158
161
|
child.on('error', (err) => {
|
|
159
162
|
logger.warn('[embedder] WebGPU probe process error — defaulting to CPU:', err);
|
|
@@ -161,6 +164,8 @@ function runProbe(model: string, cacheDir: string, timeoutMs: number): Promise<b
|
|
|
161
164
|
});
|
|
162
165
|
|
|
163
166
|
child.on('close', (code, signal) => {
|
|
167
|
+
const stdout = Buffer.concat(stdoutChunks).toString('utf8');
|
|
168
|
+
const stderr = Buffer.concat(stderrChunks).toString('utf8');
|
|
164
169
|
// Viability is the sentinel, not the exit code (teardown may abort post-success).
|
|
165
170
|
if (stdout.includes('PROBE_OK')) {
|
|
166
171
|
finish(true);
|