@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.
Files changed (81) hide show
  1. package/dist/cli.mjs +1334 -750
  2. package/dist/thread-worker.mjs +194 -136
  3. package/docs/ARCHITECTURE.md +1 -1
  4. package/docs/CHANGELOG.md +114 -16
  5. package/package.json +1 -1
  6. package/scripts/cleanup.cjs +64 -2
  7. package/skills/pi-research/SKILL.md +1 -1
  8. package/src/cleanup/research-cleanup.ts +12 -4
  9. package/src/cli.ts +228 -70
  10. package/src/config.ts +78 -2
  11. package/src/core/interfaces/health-check-interfaces.ts +3 -39
  12. package/src/core/interfaces/planning-interfaces.ts +8 -0
  13. package/src/core/interfaces/service-names.ts +0 -1
  14. package/src/core/interfaces/state-manager-interfaces.ts +10 -2
  15. package/src/core/llm/agentic-repair.ts +14 -0
  16. package/src/core/planning-service.ts +43 -1
  17. package/src/core/service-initialization.ts +6 -14
  18. package/src/core/service-interfaces.ts +1 -1
  19. package/src/healthcheck/index.ts +7 -2
  20. package/src/infrastructure/browser/browser-client.ts +64 -9
  21. package/src/infrastructure/browser/browser-server.ts +24 -7
  22. package/src/infrastructure/browser/browser-task-scheduler.ts +58 -12
  23. package/src/infrastructure/browser/cleanup-utils.ts +50 -2
  24. package/src/infrastructure/browser/client-agent.ts +6 -6
  25. package/src/infrastructure/browser/config.ts +14 -3
  26. package/src/infrastructure/browser/scheduler-factory.ts +18 -3
  27. package/src/infrastructure/browser/thread-worker-messaging.ts +54 -2
  28. package/src/infrastructure/browser/thread-worker-types.ts +3 -1
  29. package/src/infrastructure/browser/thread-worker.mjs +194 -136
  30. package/src/infrastructure/browser/worker-pool-manager.ts +7 -2
  31. package/src/infrastructure/embedding/embedding-factory.ts +35 -6
  32. package/src/infrastructure/embedding/embedding-server.ts +40 -7
  33. package/src/infrastructure/file-lock-service.ts +72 -2
  34. package/src/infrastructure/knowledge-store-service.ts +48 -3
  35. package/src/infrastructure/process-lifecycle-service.ts +28 -5
  36. package/src/infrastructure/research-run-semaphore.ts +37 -3
  37. package/src/infrastructure/state/state-browser-api.ts +5 -2
  38. package/src/infrastructure/state/state-browser-manager.ts +15 -1
  39. package/src/infrastructure/state/state-manager-service.ts +4 -4
  40. package/src/infrastructure/state/state-manager.ts +15 -3
  41. package/src/knowledge/chunker.ts +88 -1
  42. package/src/knowledge/embedder.ts +136 -43
  43. package/src/knowledge/store.ts +66 -12
  44. package/src/logger.ts +5 -1
  45. package/src/observers/research-observer-impl.ts +31 -0
  46. package/src/orchestration/abort-utils.ts +60 -0
  47. package/src/orchestration/deep-research-orchestrator.ts +12 -2
  48. package/src/orchestration/headless-observer.ts +8 -0
  49. package/src/orchestration/research-orchestration-service.ts +63 -11
  50. package/src/orchestration/research-session-service.ts +37 -11
  51. package/src/orchestration/researcher-executor.ts +100 -20
  52. package/src/orchestration/researcher.ts +39 -20
  53. package/src/orchestration/session-state.ts +11 -0
  54. package/src/sdk.ts +125 -19
  55. package/src/security/index.ts +51 -30
  56. package/src/security/nvd-client.ts +7 -2
  57. package/src/skill-install/skill-installer.ts +18 -2
  58. package/src/stackexchange/index.ts +20 -5
  59. package/src/stackexchange/output/table.ts +7 -2
  60. package/src/stackexchange/rest-client.ts +35 -1
  61. package/src/tools/research-tool-definition.ts +8 -2
  62. package/src/tools/scrape.ts +48 -25
  63. package/src/tools/search.ts +10 -1
  64. package/src/tools/security.ts +68 -7
  65. package/src/tui/research-health.ts +18 -2
  66. package/src/utils/circuit-breaker.ts +9 -2
  67. package/src/utils/disk-space-checker.ts +11 -6
  68. package/src/utils/json-utils.ts +116 -76
  69. package/src/utils/llm-usage.ts +17 -1
  70. package/src/utils/log-rotation.ts +42 -10
  71. package/src/utils/log-utils.ts +40 -0
  72. package/src/utils/metrics-summary.ts +27 -9
  73. package/src/utils/stdio-capture.ts +63 -22
  74. package/src/utils/text-utils.ts +8 -1
  75. package/src/utils/tool-usage-tracker.ts +9 -2
  76. package/src/web-research/browser-search.ts +26 -7
  77. package/src/web-research/scraper-utils.ts +13 -0
  78. package/src/web-research/search.ts +6 -2
  79. package/src/web-research/types.ts +4 -2
  80. package/src/web-research/web-scraper.ts +65 -4
  81. 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 recoverToCpu (15s spin) and dispose (5s hang).
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 logger.runCapturingStderr(async () => {
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 logger.runCapturingStderr(async () => {
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 logger.runCapturingStderr(async () => {
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 logger.runCapturingStderr(async () => {
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) return 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
- this.recoveryPromise = (async () => {
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
- this.state = 'initializing';
533
-
534
- // Wait for other concurrent embeddings to finish before disposing the pipeline
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
- // We expect activeEmbeddings to be at least 1 (the current caller).
538
- // If multiple callers are in recoverToCpu, they all wait for recoveryPromise.
539
- while (this.activeEmbeddings > 1 && (Date.now() - startTime) < maxWaitMs) {
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
- this.device = 'cpu';
548
-
549
- this.initializingPromise = this._initializeInternal();
614
+
550
615
  try {
551
- await this.initializingPromise;
552
- this.state = 'ready';
616
+ await this._initializeInternal();
553
617
  } catch (e) {
554
- this.state = 'failed';
555
- throw e;
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 this.recoveryPromise;
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 startTime = Date.now();
594
- while (this.activeEmbeddings > 0 && (Date.now() - startTime) < maxWaitMs) {
595
- await new Promise(resolve => setTimeout(resolve, 50));
596
- }
597
-
598
- if (this.activeEmbeddings > 0) {
599
- logger.warn(`[embedder] Disposing with ${this.activeEmbeddings} active embeddings (timed out)`);
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) {
@@ -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
- await this.options.embedder.embedMany([' ']);
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 version AFTER indexing (createIndex commits its own transactions) so the
981
- // next call compares against this post-rebuild baseline and only rebuilds on real writes.
982
- this.lastFtsVersion = await (await this.getFreshTable()).version();
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
- const wasFtsIndexCurrent = !stale && this.lastFtsVersion !== null && this.lastFtsVersion === await table.version();
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
- if (wasFtsIndexCurrent) {
1048
- this.lastFtsVersion = await (await this.getFreshTable()).version();
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. ISO-timestamp names sort
1117
- // chronologically, so the last entry is newest.
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
- backupDirs.sort();
1120
- for (const name of backupDirs.slice(0, -1)) {
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
- const msg = message.replace(/[\r\n]/g, '');
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
  }