@lincoln504/pi-research 1.2.0 → 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/dist/cli.mjs +1334 -750
- package/dist/thread-worker.mjs +194 -136
- package/docs/ARCHITECTURE.md +1 -1
- package/docs/CHANGELOG.md +114 -16
- package/package.json +1 -1
- package/scripts/cleanup.cjs +64 -2
- package/skills/pi-research/SKILL.md +1 -1
- package/src/cleanup/research-cleanup.ts +12 -4
- package/src/cli.ts +228 -70
- package/src/config.ts +78 -2
- package/src/core/interfaces/health-check-interfaces.ts +3 -39
- 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 +10 -2
- package/src/core/llm/agentic-repair.ts +14 -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/healthcheck/index.ts +7 -2
- package/src/infrastructure/browser/browser-client.ts +64 -9
- package/src/infrastructure/browser/browser-server.ts +24 -7
- package/src/infrastructure/browser/browser-task-scheduler.ts +58 -12
- package/src/infrastructure/browser/cleanup-utils.ts +50 -2
- package/src/infrastructure/browser/client-agent.ts +6 -6
- package/src/infrastructure/browser/config.ts +14 -3
- package/src/infrastructure/browser/scheduler-factory.ts +18 -3
- 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 +194 -136
- package/src/infrastructure/browser/worker-pool-manager.ts +7 -2
- package/src/infrastructure/embedding/embedding-factory.ts +35 -6
- package/src/infrastructure/embedding/embedding-server.ts +40 -7
- package/src/infrastructure/file-lock-service.ts +72 -2
- package/src/infrastructure/knowledge-store-service.ts +48 -3
- package/src/infrastructure/process-lifecycle-service.ts +28 -5
- package/src/infrastructure/research-run-semaphore.ts +37 -3
- 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 +4 -4
- package/src/infrastructure/state/state-manager.ts +15 -3
- package/src/knowledge/chunker.ts +88 -1
- package/src/knowledge/embedder.ts +136 -43
- package/src/knowledge/store.ts +66 -12
- 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 +12 -2
- package/src/orchestration/headless-observer.ts +8 -0
- package/src/orchestration/research-orchestration-service.ts +63 -11
- package/src/orchestration/research-session-service.ts +37 -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/index.ts +51 -30
- package/src/security/nvd-client.ts +7 -2
- package/src/skill-install/skill-installer.ts +18 -2
- package/src/stackexchange/index.ts +20 -5
- package/src/stackexchange/output/table.ts +7 -2
- package/src/stackexchange/rest-client.ts +35 -1
- package/src/tools/research-tool-definition.ts +8 -2
- package/src/tools/scrape.ts +48 -25
- package/src/tools/search.ts +10 -1
- package/src/tools/security.ts +68 -7
- package/src/tui/research-health.ts +18 -2
- package/src/utils/circuit-breaker.ts +9 -2
- package/src/utils/disk-space-checker.ts +11 -6
- 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 +26 -7
- package/src/web-research/scraper-utils.ts +13 -0
- package/src/web-research/search.ts +6 -2
- package/src/web-research/types.ts +4 -2
- package/src/web-research/web-scraper.ts +65 -4
- package/src/core/health-check-service.ts +0 -265
|
@@ -68,6 +68,28 @@ 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;
|
|
71
93
|
/**
|
|
72
94
|
* Why the in-flight dispose was started. An IDLE dispose is a memory-reclaim
|
|
73
95
|
* pause that a later embed is expected to undo, so initialize() waits it out and
|
|
@@ -389,13 +411,36 @@ export class Embedder {
|
|
|
389
411
|
return text;
|
|
390
412
|
}
|
|
391
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
|
+
|
|
392
437
|
async embed(text: string): Promise<Float32Array> {
|
|
393
438
|
await this.initialize();
|
|
394
439
|
// Compute the input BEFORE the active-count increment: truncateText is a pure
|
|
395
440
|
// string op, and keeping it (and nothing else) ahead of the try/finally means
|
|
396
441
|
// every path that can throw or await is inside the finally that decrements the
|
|
397
442
|
// counter. Otherwise a throw here (or in acquireGpuLock) would leak the count,
|
|
398
|
-
// permanently wedging
|
|
443
|
+
// permanently wedging dispose (5s hang).
|
|
399
444
|
const input = this.truncateText(this.queryPrefix ? this.queryPrefix + text : text);
|
|
400
445
|
this.stopIdleTimer();
|
|
401
446
|
this.activeEmbeddings++;
|
|
@@ -408,9 +453,7 @@ export class Embedder {
|
|
|
408
453
|
logger.warn('[embedder] GPU per-call lock timeout after 15s — proceeding without lock');
|
|
409
454
|
}
|
|
410
455
|
}
|
|
411
|
-
const output = await
|
|
412
|
-
return await this.pipeline!(input, this.pipelineOpts());
|
|
413
|
-
});
|
|
456
|
+
const output = await this.runInference(input);
|
|
414
457
|
return output.data as Float32Array;
|
|
415
458
|
} catch (err) {
|
|
416
459
|
if (isWebGpuDeviceError(err)) {
|
|
@@ -425,9 +468,7 @@ export class Embedder {
|
|
|
425
468
|
// dereferencing a null pipeline would raise a TypeError that MASKS the
|
|
426
469
|
// original device error — rethrow the original instead.
|
|
427
470
|
if (!this.pipeline) throw err;
|
|
428
|
-
const output = await
|
|
429
|
-
return await this.pipeline!(input, this.pipelineOpts());
|
|
430
|
-
});
|
|
471
|
+
const output = await this.runInference(input);
|
|
431
472
|
return output.data as Float32Array;
|
|
432
473
|
}
|
|
433
474
|
throw err;
|
|
@@ -472,9 +513,7 @@ export class Embedder {
|
|
|
472
513
|
|
|
473
514
|
let output: any;
|
|
474
515
|
try {
|
|
475
|
-
output = await
|
|
476
|
-
return await this.pipeline!(batch, this.pipelineOpts());
|
|
477
|
-
});
|
|
516
|
+
output = await this.runInference(batch);
|
|
478
517
|
} catch (err) {
|
|
479
518
|
if (isWebGpuDeviceError(err)) {
|
|
480
519
|
if (lockAcquired && this.stateManager) {
|
|
@@ -487,9 +526,7 @@ export class Embedder {
|
|
|
487
526
|
// Same guard as embed(): recoverToCpu may have skipped (disposing/idle),
|
|
488
527
|
// leaving pipeline null — rethrow the original error, not a TypeError.
|
|
489
528
|
if (!this.pipeline) throw err;
|
|
490
|
-
output = await
|
|
491
|
-
return await this.pipeline!(batch, this.pipelineOpts());
|
|
492
|
-
});
|
|
529
|
+
output = await this.runInference(batch);
|
|
493
530
|
} else {
|
|
494
531
|
throw err;
|
|
495
532
|
}
|
|
@@ -513,7 +550,17 @@ export class Embedder {
|
|
|
513
550
|
}
|
|
514
551
|
|
|
515
552
|
private async recoverToCpu(): Promise<void> {
|
|
516
|
-
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
|
+
}
|
|
517
564
|
|
|
518
565
|
// Guard: if disposal has already started, skip recovery — the embedder is going away.
|
|
519
566
|
if (this.state === 'disposing' || this.state === 'idle') {
|
|
@@ -521,22 +568,42 @@ export class Embedder {
|
|
|
521
568
|
return;
|
|
522
569
|
}
|
|
523
570
|
|
|
524
|
-
|
|
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 () => {
|
|
525
583
|
logger.warn('[embedder] WebGPU device error detected during operation — falling back to CPU for the remainder of this session');
|
|
526
|
-
|
|
584
|
+
|
|
527
585
|
markWebGpuFallback();
|
|
528
|
-
|
|
586
|
+
|
|
529
587
|
await releaseGpuLock(this.stateManager, this.gpuLockHeld);
|
|
530
588
|
this.gpuLockHeld = false;
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
//
|
|
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.
|
|
535
601
|
const maxWaitMs = 15000;
|
|
536
602
|
const startTime = Date.now();
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
603
|
+
while (
|
|
604
|
+
(this.activeInferences > 0 || this.activeEmbeddings - this.recoveryWaiters > 1) &&
|
|
605
|
+
(Date.now() - startTime) < maxWaitMs
|
|
606
|
+
) {
|
|
540
607
|
await new Promise(resolve => setTimeout(resolve, 50));
|
|
541
608
|
}
|
|
542
609
|
|
|
@@ -544,25 +611,40 @@ export class Embedder {
|
|
|
544
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); }
|
|
545
612
|
this.pipeline = null;
|
|
546
613
|
}
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
this.initializingPromise = this._initializeInternal();
|
|
614
|
+
|
|
550
615
|
try {
|
|
551
|
-
|
|
552
|
-
this.state = 'ready';
|
|
616
|
+
await this._initializeInternal();
|
|
553
617
|
} catch (e) {
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
} finally {
|
|
557
|
-
this.initializingPromise = null;
|
|
618
|
+
this.state = 'failed';
|
|
619
|
+
throw e;
|
|
558
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';
|
|
559
630
|
logger.warn('[embedder] CPU fallback recovery complete.');
|
|
560
631
|
})();
|
|
561
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
|
+
|
|
562
640
|
try {
|
|
563
|
-
await
|
|
641
|
+
await recovery;
|
|
564
642
|
} finally {
|
|
565
|
-
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;
|
|
566
648
|
}
|
|
567
649
|
}
|
|
568
650
|
|
|
@@ -590,14 +672,16 @@ export class Embedder {
|
|
|
590
672
|
this.disposePromise = (async () => {
|
|
591
673
|
// Wait for all active embeddings to complete
|
|
592
674
|
const maxWaitMs = 5000;
|
|
593
|
-
const
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
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();
|
|
601
685
|
|
|
602
686
|
if (this.initializingPromise) {
|
|
603
687
|
try {
|
|
@@ -605,6 +689,15 @@ export class Embedder {
|
|
|
605
689
|
} catch (_e) {
|
|
606
690
|
// Initialize failed, which is fine
|
|
607
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();
|
|
608
701
|
}
|
|
609
702
|
|
|
610
703
|
if (this.pipeline) {
|
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);
|
|
@@ -977,9 +982,19 @@ export class KnowledgeStore implements IKnowledgeStore {
|
|
|
977
982
|
config: Index.fts(),
|
|
978
983
|
replace: true,
|
|
979
984
|
});
|
|
980
|
-
// Record the
|
|
981
|
-
//
|
|
982
|
-
|
|
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;
|
|
983
998
|
logger.info('[store] FTS indexes rebuilt (text + content).');
|
|
984
999
|
return true;
|
|
985
1000
|
} catch (err) {
|
|
@@ -1027,7 +1042,12 @@ export class KnowledgeStore implements IKnowledgeStore {
|
|
|
1027
1042
|
// rebuilds + re-optimizes (the disk-bloat regression). A stale baseline (index not
|
|
1028
1043
|
// current, or never built) is left untouched so the next rebuild still detects the
|
|
1029
1044
|
// pending change.
|
|
1030
|
-
|
|
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();
|
|
1031
1051
|
// cleanupOlderThan defaults to now: prune every prunable old version. The
|
|
1032
1052
|
// current version is always retained, and (with deleteUnverified=false)
|
|
1033
1053
|
// physical files inside LanceDB's safety window are kept regardless.
|
|
@@ -1043,9 +1063,14 @@ export class KnowledgeStore implements IKnowledgeStore {
|
|
|
1043
1063
|
const bytesRemoved = stats?.prune?.bytesRemoved ?? 0;
|
|
1044
1064
|
logger.info(`[store] Optimize complete: removed ${removed} old version(s), reclaimed ${bytesRemoved} bytes (${duration}ms).`);
|
|
1045
1065
|
// Keep the FTS skip-guard baseline in sync with optimize's layout-only version bump
|
|
1046
|
-
// so the next rebuildFtsIndex correctly skips when no content changed.
|
|
1047
|
-
|
|
1048
|
-
|
|
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();
|
|
1049
1074
|
}
|
|
1050
1075
|
return true;
|
|
1051
1076
|
} catch (err) {
|
|
@@ -1113,11 +1138,19 @@ export class KnowledgeStore implements IKnowledgeStore {
|
|
|
1113
1138
|
// `_backup_` dirs are intentional, user-recoverable snapshots from the
|
|
1114
1139
|
// 'backup' migration strategy. RETAIN the most recent one (deleting it
|
|
1115
1140
|
// would make 'backup' silently equivalent to 'drop'); prune only older,
|
|
1116
|
-
// superseded backups so they don't accumulate.
|
|
1117
|
-
//
|
|
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_*`.
|
|
1118
1148
|
if (backupDirs.length > 1) {
|
|
1119
|
-
|
|
1120
|
-
|
|
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)) {
|
|
1121
1154
|
const fullPath = path.join(this.options.dbDir, name);
|
|
1122
1155
|
logger.info(`[store] Removing superseded backup directory: ${name}`);
|
|
1123
1156
|
await fsPromises.rm(fullPath, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 });
|
|
@@ -1128,6 +1161,27 @@ export class KnowledgeStore implements IKnowledgeStore {
|
|
|
1128
1161
|
}
|
|
1129
1162
|
}
|
|
1130
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
|
+
|
|
1131
1185
|
async count(): Promise<number> {
|
|
1132
1186
|
if (!this.db) return 0;
|
|
1133
1187
|
// Track against close() — this read had no try/catch, so a teardown racing db.close()
|
package/src/logger.ts
CHANGED
|
@@ -35,6 +35,7 @@ import {
|
|
|
35
35
|
safeJsonStringify,
|
|
36
36
|
formatArg,
|
|
37
37
|
redactSecrets,
|
|
38
|
+
stripTerminalEscapes,
|
|
38
39
|
type LogContext,
|
|
39
40
|
} from './utils/log-utils.ts';
|
|
40
41
|
import { LogRotation } from './utils/log-rotation.ts';
|
|
@@ -286,7 +287,10 @@ export class Logger implements ILogger {
|
|
|
286
287
|
// escapes control chars on its own. CodeQL (js/log-injection) StringReplaceSanitizer
|
|
287
288
|
// requires the replacement to be "" (empty string) — not ' ' — per its QL definition:
|
|
288
289
|
// replaces(s, "") and s.regexpMatch("\\n")
|
|
289
|
-
|
|
290
|
+
// redactSecrets removed only the colour/style CSI subset, so private-mode
|
|
291
|
+
// CSI (\x1b[?25l), OSC retitling, and DCS would still reach the terminal
|
|
292
|
+
// here — stripTerminalEscapes drops those plus residual control bytes.
|
|
293
|
+
const msg = stripTerminalEscapes(message.replace(/[\r\n]/g, ''));
|
|
290
294
|
const rawSid = this.sessionId ?? '';
|
|
291
295
|
const sid = rawSid.replace(/[\r\n]/g, '');
|
|
292
296
|
const prefix = sid ? `[${sid}] ` : '';
|
|
@@ -26,6 +26,14 @@ import {
|
|
|
26
26
|
clearCompletedResearchers,
|
|
27
27
|
} from '../tui/research-panel.ts';
|
|
28
28
|
|
|
29
|
+
/**
|
|
30
|
+
* Slice id for the run-queued placeholder box. Shown by onRunQueued while the
|
|
31
|
+
* run waits for a machine-wide run-cap slot (before ANY other lifecycle event)
|
|
32
|
+
* and removed by onStart once a slot is acquired — both quick and deep fire
|
|
33
|
+
* onStart before doing real work. Stable-id keyed like 'coord'/'eval'.
|
|
34
|
+
*/
|
|
35
|
+
const RUN_QUEUED_SLICE_ID = 'run-queued';
|
|
36
|
+
|
|
29
37
|
export interface ObserverContext {
|
|
30
38
|
panelState: ResearchPanelState;
|
|
31
39
|
debouncedRefresh: () => void;
|
|
@@ -71,6 +79,9 @@ export function createResearchObserver(
|
|
|
71
79
|
|
|
72
80
|
return {
|
|
73
81
|
onStart: (query, complexity) => {
|
|
82
|
+
// A run-cap slot was acquired and the run is actually starting — drop the
|
|
83
|
+
// queued-run placeholder (no-op when the run never queued).
|
|
84
|
+
removeSlice(panelState, RUN_QUEUED_SLICE_ID);
|
|
74
85
|
if (complexity === 0) {
|
|
75
86
|
// Truncate at a CODEPOINT boundary (Array.from iterates codepoints), not a
|
|
76
87
|
// code-unit index: query.slice(0, 20) can split a surrogate pair (emoji,
|
|
@@ -91,6 +102,26 @@ export function createResearchObserver(
|
|
|
91
102
|
debouncedRefresh();
|
|
92
103
|
},
|
|
93
104
|
|
|
105
|
+
onRunQueued: (slots, maxWaitMs) => {
|
|
106
|
+
// Fired before any other lifecycle event when every machine-wide run slot
|
|
107
|
+
// is held and this run is about to queue (for up to maxWaitMs). Without a
|
|
108
|
+
// visible notice the whole wait renders as an empty panel — the exact
|
|
109
|
+
// "looks like a hang" condition the callback exists to prevent. Surface it
|
|
110
|
+
// through the same slice/status channel every other phase uses; the
|
|
111
|
+
// non-numeric label routes updateSliceStatus to a persistent status (no
|
|
112
|
+
// timed pop), and onStart removes the placeholder once the run begins.
|
|
113
|
+
const waitMins = Math.max(1, Math.ceil(maxWaitMs / 60_000));
|
|
114
|
+
addSlice(panelState, RUN_QUEUED_SLICE_ID, 'queued', false);
|
|
115
|
+
activateSlice(panelState, RUN_QUEUED_SLICE_ID);
|
|
116
|
+
updateSliceStatus(
|
|
117
|
+
panelState,
|
|
118
|
+
RUN_QUEUED_SLICE_ID,
|
|
119
|
+
`waiting for a free run slot (all ${slots} in use, up to ${waitMins}m)`,
|
|
120
|
+
debouncedRefresh,
|
|
121
|
+
);
|
|
122
|
+
debouncedRefresh();
|
|
123
|
+
},
|
|
124
|
+
|
|
94
125
|
onPlanningStart: (attempt) => {
|
|
95
126
|
if (attempt === 1) {
|
|
96
127
|
addSlice(panelState, 'coord', `coordinator`, false);
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Abort/Teardown Utilities
|
|
3
|
+
*
|
|
4
|
+
* Shared by the orchestration layer for two teardown concerns:
|
|
5
|
+
* - Classifying the "Aborted" sentinel consistently everywhere it is consumed.
|
|
6
|
+
* - Bounding waits on session aborts, which may never settle.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Upper bound for awaiting a session abort during cleanup/teardown.
|
|
11
|
+
* session.abort() awaits the very in-flight request it is cancelling, which can
|
|
12
|
+
* be wedged (e.g. a provider retry loop against an exhausted quota) and never
|
|
13
|
+
* settle — so no teardown path may await it unbounded.
|
|
14
|
+
*/
|
|
15
|
+
export const SESSION_ABORT_SETTLE_TIMEOUT_MS = 10_000;
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* True when an error message is the abort sentinel. Two forms exist and must
|
|
19
|
+
* classify identically:
|
|
20
|
+
* - bare 'Aborted' — thrown by the orchestrators' local abort races, and
|
|
21
|
+
* - '<researcher-id>: Aborted' — thrown by ensureAssistantResponse
|
|
22
|
+
* (text-utils.ts) when an externally-aborted pi session resolves prompt()
|
|
23
|
+
* normally with no salvageable text (the fast-stop abortAllSessions path).
|
|
24
|
+
* An exact-equality check misses the prefixed form, which made the executor
|
|
25
|
+
* RETRY a researcher the fast-stop path had just aborted: fresh session, fresh
|
|
26
|
+
* search/scrape burst, sessions re-registered after cleanup already ran.
|
|
27
|
+
*/
|
|
28
|
+
export function isAbortSentinel(message: string): boolean {
|
|
29
|
+
return /(^|: )Aborted$/.test(message);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Await an abort (which must never reject — callers attach their own .catch)
|
|
34
|
+
* for at most `timeoutMs`, then proceed with teardown. When the bound fires,
|
|
35
|
+
* `onTimeout` runs (callers log there); the abandoned abort keeps draining in
|
|
36
|
+
* the background and dies with the run's teardown. The timer never keeps the
|
|
37
|
+
* process alive, and is cleared when the abort settles first so it cannot fire
|
|
38
|
+
* a stale onTimeout later.
|
|
39
|
+
*/
|
|
40
|
+
export async function boundSessionAbort(
|
|
41
|
+
abort: Promise<void>,
|
|
42
|
+
onTimeout: () => void,
|
|
43
|
+
timeoutMs: number = SESSION_ABORT_SETTLE_TIMEOUT_MS,
|
|
44
|
+
): Promise<void> {
|
|
45
|
+
let timer: NodeJS.Timeout | undefined;
|
|
46
|
+
try {
|
|
47
|
+
await Promise.race([
|
|
48
|
+
abort,
|
|
49
|
+
new Promise<void>((resolve) => {
|
|
50
|
+
timer = setTimeout(() => {
|
|
51
|
+
onTimeout();
|
|
52
|
+
resolve();
|
|
53
|
+
}, timeoutMs);
|
|
54
|
+
timer.unref?.();
|
|
55
|
+
}),
|
|
56
|
+
]);
|
|
57
|
+
} finally {
|
|
58
|
+
if (timer) clearTimeout(timer);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
@@ -7,7 +7,6 @@
|
|
|
7
7
|
import type { ExtensionContext, AgentToolResult } from '@earendil-works/pi-coding-agent';
|
|
8
8
|
import { type Model } from '@earendil-works/pi-ai';
|
|
9
9
|
import { logger } from '../logger.ts';
|
|
10
|
-
import { safeUnref } from '../utils/safe-unref.ts';
|
|
11
10
|
import { metrics } from '../utils/metrics.ts';
|
|
12
11
|
import { getSteeringMessages, consumeQueuedMessages, getActiveSteeringMessages, getFailedResearchers, getResearcherFailureReasons } from './session-state.ts';
|
|
13
12
|
import { MAX_EXTRA_ROUNDS_WITH_STEERING, resolveExcludedTools } from '../constants.ts';
|
|
@@ -245,6 +244,11 @@ export class DeepResearchOrchestrator {
|
|
|
245
244
|
reports: synthesisService.getAllReports(researchId),
|
|
246
245
|
previousPlan: planningService.getCurrentPlan(researchId),
|
|
247
246
|
totalResearchersPlanned: planningService.getTotalResearchersPlanned(researchId),
|
|
247
|
+
// Live, steering-extended budget — see maxRounds comment above. Without
|
|
248
|
+
// this, updatePlanForRound recomputes the BASE complexity-table value
|
|
249
|
+
// internally and understates the denominator once steering has extended
|
|
250
|
+
// the round budget, prematurely skewing its round-phase guidance to LATE.
|
|
251
|
+
maxRounds,
|
|
248
252
|
signal,
|
|
249
253
|
observer,
|
|
250
254
|
excludeTools: this.options.excludeTools,
|
|
@@ -294,11 +298,15 @@ export class DeepResearchOrchestrator {
|
|
|
294
298
|
clearTimeout(timeout);
|
|
295
299
|
reject(new Error('Research cancelled'));
|
|
296
300
|
};
|
|
301
|
+
// Deliberately REFERENCED: this sleep IS the run's sole pending
|
|
302
|
+
// operation at this moment (nothing else is in flight during a
|
|
303
|
+
// 'wait'), so an unref'd timer would let the process drain its
|
|
304
|
+
// event loop and exit 0 silently mid-run. Abortability, not
|
|
305
|
+
// unref, is what keeps cancellation responsive here.
|
|
297
306
|
const timeout = setTimeout(() => {
|
|
298
307
|
signal?.removeEventListener('abort', onAbort);
|
|
299
308
|
resolve();
|
|
300
309
|
}, 5000);
|
|
301
|
-
safeUnref(timeout);
|
|
302
310
|
signal?.addEventListener('abort', onAbort, { once: true });
|
|
303
311
|
});
|
|
304
312
|
|
|
@@ -474,6 +482,8 @@ export class DeepResearchOrchestrator {
|
|
|
474
482
|
previousPlan: planningService.getCurrentPlan(researchId),
|
|
475
483
|
totalResearchersPlanned: planningService.getTotalResearchersPlanned(researchId),
|
|
476
484
|
mustSynthesize: true,
|
|
485
|
+
// Live, steering-extended budget — see the in-loop call above.
|
|
486
|
+
maxRounds,
|
|
477
487
|
signal,
|
|
478
488
|
observer,
|
|
479
489
|
excludeTools: this.options.excludeTools,
|
|
@@ -139,6 +139,10 @@ export class HeadlessObserver implements ResearchObserver {
|
|
|
139
139
|
this.emit('researcher_progress', { id, status, tokens, cost });
|
|
140
140
|
}
|
|
141
141
|
|
|
142
|
+
onResearcherTokensHint(id: string, inputTokens: number): void {
|
|
143
|
+
this.emit('researcher_tokens_hint', { id, inputTokens });
|
|
144
|
+
}
|
|
145
|
+
|
|
142
146
|
onResearcherComplete(id: string, report: string): void {
|
|
143
147
|
this.emit('researcher_complete', { id, report });
|
|
144
148
|
}
|
|
@@ -167,6 +171,10 @@ export class HeadlessObserver implements ResearchObserver {
|
|
|
167
171
|
this.emit('evaluation_decision', { action, plan, round });
|
|
168
172
|
}
|
|
169
173
|
|
|
174
|
+
onSynthesisStart(): void {
|
|
175
|
+
this.emit('synthesis_start');
|
|
176
|
+
}
|
|
177
|
+
|
|
170
178
|
onComplete(result: string): void {
|
|
171
179
|
this.emit('complete', { result });
|
|
172
180
|
}
|