@lincoln504/pi-research 1.3.11 → 1.4.1

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 (83) hide show
  1. package/README.md +2 -2
  2. package/dist/cli.mjs +5558 -4596
  3. package/dist/prompts/research-tool-usage.md +1 -1
  4. package/dist/prompts/researcher.md +1 -0
  5. package/dist/prompts/system-coordinator.md +1 -1
  6. package/dist/prompts/system-lead-router.md +122 -0
  7. package/dist/prompts/system-lead-synthesizer.md +46 -0
  8. package/dist/thread-worker.mjs +123 -30
  9. package/docs/ARCHITECTURE.md +69 -24
  10. package/docs/CHANGELOG.md +215 -0
  11. package/docs/CONFIGURATION.md +108 -8
  12. package/package.json +19 -11
  13. package/scripts/build.cjs +8 -1
  14. package/scripts/cleanup.cjs +26 -5
  15. package/scripts/setup.cjs +25 -1
  16. package/skills/pi-research/SKILL.md +1 -1
  17. package/src/cli.ts +41 -2
  18. package/src/config.ts +12 -8
  19. package/src/constants.ts +1 -1
  20. package/src/core/interfaces/observer-interfaces.ts +3 -3
  21. package/src/core/interfaces/orchestration-interfaces.ts +2 -0
  22. package/src/core/interfaces/planning-interfaces.ts +7 -1
  23. package/src/core/interfaces/scheduler-interfaces.ts +18 -2
  24. package/src/core/llm/agentic-repair.ts +7 -2
  25. package/src/core/llm/inject-date.ts +4 -1
  26. package/src/core/llm/llm-utils.ts +7 -0
  27. package/src/core/planning-service.ts +719 -91
  28. package/src/core/planning-utils.ts +21 -5
  29. package/src/core/service-initialization.ts +19 -3
  30. package/src/core/service-registry.ts +19 -3
  31. package/src/healthcheck/index.ts +11 -2
  32. package/src/index.ts +4 -3
  33. package/src/infrastructure/browser/browser-client.ts +68 -34
  34. package/src/infrastructure/browser/browser-task-scheduler.ts +220 -34
  35. package/src/infrastructure/browser/config.ts +77 -6
  36. package/src/infrastructure/browser/priority-task-queue.ts +55 -1
  37. package/src/infrastructure/browser/scheduler-factory.ts +12 -1
  38. package/src/infrastructure/browser/task-execution-service.ts +17 -7
  39. package/src/infrastructure/browser/thread-worker-browser.ts +66 -20
  40. package/src/infrastructure/browser/thread-worker-messaging.ts +38 -6
  41. package/src/infrastructure/browser/thread-worker.mjs +123 -30
  42. package/src/infrastructure/browser/thread-worker.ts +2 -2
  43. package/src/infrastructure/browser/worker-pool-manager.ts +127 -12
  44. package/src/infrastructure/embedding/embedding-client.ts +14 -4
  45. package/src/infrastructure/embedding/embedding-factory.ts +83 -15
  46. package/src/infrastructure/file-lock-service.ts +214 -19
  47. package/src/infrastructure/gpu-resource-service.ts +15 -1
  48. package/src/infrastructure/knowledge-store-service.ts +14 -0
  49. package/src/infrastructure/research-run-semaphore.ts +1 -1
  50. package/src/infrastructure/service-initialization.ts +7 -0
  51. package/src/knowledge/embedder-init.ts +60 -18
  52. package/src/knowledge/store.ts +11 -5
  53. package/src/logger.ts +11 -0
  54. package/src/orchestration/deep-research-orchestrator.ts +141 -87
  55. package/src/orchestration/quick-research-orchestrator.ts +11 -2
  56. package/src/orchestration/research-orchestration-service.ts +30 -1
  57. package/src/orchestration/research-synthesis-service.ts +53 -1
  58. package/src/orchestration/researcher-executor.ts +53 -2
  59. package/src/orchestration/researcher.ts +15 -2
  60. package/src/prompts/research-tool-usage.md +1 -1
  61. package/src/prompts/researcher.md +1 -0
  62. package/src/prompts/system-coordinator.md +1 -1
  63. package/src/prompts/system-lead-router.md +122 -0
  64. package/src/prompts/system-lead-synthesizer.md +46 -0
  65. package/src/sdk.ts +10 -1
  66. package/src/security/nvd-client.ts +55 -4
  67. package/src/security/osv-client.ts +13 -1
  68. package/src/tools/research-knowledge-search.ts +29 -4
  69. package/src/tools/scrape.ts +6 -0
  70. package/src/tools/search.ts +7 -0
  71. package/src/tools/security.ts +27 -9
  72. package/src/tools/stackexchange.ts +13 -7
  73. package/src/tools/youtube-transcript.ts +26 -12
  74. package/src/utils/cancellation.ts +36 -0
  75. package/src/utils/coverage-digest.ts +204 -0
  76. package/src/utils/llm-usage.ts +33 -6
  77. package/src/utils/log-utils.ts +16 -1
  78. package/src/utils/metrics-summary.ts +20 -4
  79. package/src/utils/metrics.ts +27 -1
  80. package/src/web-research/browser-search.ts +93 -20
  81. package/src/web-research/scraper-utils.ts +55 -14
  82. package/dist/prompts/system-lead-evaluator.md +0 -118
  83. package/src/prompts/system-lead-evaluator.md +0 -118
@@ -19,10 +19,11 @@ import type { IStateManager } from '../../core/interfaces/state-manager-interfac
19
19
  import { BrowserServer, getBrowserServerAuthSecret } from './browser-server.ts';
20
20
  import { isPoolShutdownError } from './browser-error-utils.ts';
21
21
  import type { WorkerPoolManager } from './worker-pool-manager.ts';
22
- import type { IScheduler } from '../../core/interfaces/scheduler-interfaces.ts';
22
+ import type { IScheduler, TaskDispatchListener } from '../../core/interfaces/scheduler-interfaces.ts';
23
23
  import { cleanupOrphanedCamoufoxProcesses, getBrowserPidsForWorkers, killBrowserProcesses } from './browser-cleanup.ts';
24
24
  import { raceWithDeadline } from '../../utils/safe-unref.ts';
25
25
  import { PriorityTaskQueue } from './priority-task-queue.ts';
26
+ import { getHealthCheckBudgetMs } from './config.ts';
26
27
 
27
28
  /**
28
29
  * Grace added to the worker's own task budget before the dead-worker backstop
@@ -74,6 +75,47 @@ export async function executePoolTaskWithDeathBackstop<T>(
74
75
  * Browser task scheduler - manages the worker pool and executes tasks.
75
76
  * Only the leader process has an instance of this scheduler.
76
77
  */
78
+ /**
79
+ * How long a task actually RAN, given when it was dispatched and when it was
80
+ * submitted.
81
+ *
82
+ * Duration used to be `now - submittedAt`, which folds queue wait into the work.
83
+ * That is the number `Search completed ... in Nms` prints and the number
84
+ * `browser_search_duration_ms` records, so it is what every latency investigation
85
+ * reads — and during a saturated burst it reported a 51s median against a true
86
+ * 5.8s execution median. A whole evening went into explaining a slowdown that had
87
+ * not happened. Moving the deadline off queue wait while leaving the measurement
88
+ * on it would have preserved exactly that misdiagnosis.
89
+ *
90
+ * A task that never reached a worker has no execution time; report its full
91
+ * elapsed span rather than zero, so a timeout is not silently recorded as instant.
92
+ */
93
+ export function executionMs(dispatchedAt: number, submittedAt: number, now: number = Date.now()): number {
94
+ return dispatchedAt > 0 ? now - dispatchedAt : now - submittedAt;
95
+ }
96
+
97
+ /** How long a task sat in the queue before a worker took it. Zero if never dispatched. */
98
+ export function queueWaitMs(dispatchedAt: number, submittedAt: number): number {
99
+ return dispatchedAt > 0 ? dispatchedAt - submittedAt : 0;
100
+ }
101
+
102
+ /**
103
+ * Record a task's queue wait, whether or not it was ever dispatched.
104
+ *
105
+ * Split by outcome because the two are different quantities and must not be summed
106
+ * into one distribution: a dispatched task's wait ENDED, while an abandoned task's
107
+ * wait was still running when the caller gave up, so the latter is a lower bound.
108
+ * Folding abandonments in as `queueWaitMs`'s zero would pull the distribution
109
+ * DOWNWARD in exactly the bursts where it should spike.
110
+ */
111
+ export function observeQueueWait(metric: string, dispatchedAt: number, submittedAt: number): void {
112
+ metrics.observe(
113
+ metric,
114
+ dispatchedAt > 0 ? dispatchedAt - submittedAt : Date.now() - submittedAt,
115
+ { dispatched: dispatchedAt > 0 ? 'yes' : 'no' },
116
+ );
117
+ }
118
+
77
119
  export class BrowserTaskScheduler implements IScheduler {
78
120
  private workerPoolManager: WorkerPoolManager | null = null;
79
121
  private server: BrowserServer | null = null;
@@ -169,6 +211,9 @@ export class BrowserTaskScheduler implements IScheduler {
169
211
  // Decay (not reset) so a slow worker crash-loop can still accumulate to
170
212
  // the auto-recovery threshold instead of being zeroed on every tick.
171
213
  poolManager.decayConsecutiveErrors();
214
+ // Sampled on the leadership tick rather than per task: this is a
215
+ // distribution property, not a per-task one, and it should read zero.
216
+ poolManager.recordDispatchHealth();
172
217
  } catch (err) {
173
218
  logger.warn('[Scheduler] Leadership check error:', err);
174
219
  } finally {
@@ -218,7 +263,7 @@ export class BrowserTaskScheduler implements IScheduler {
218
263
  return this.server.start();
219
264
  }
220
265
 
221
- async runSearch(query: string, config?: Config, signal?: AbortSignal): Promise<SearchResult[]> {
266
+ async runSearch(query: string, config?: Config, signal?: AbortSignal, onTaskEvent?: TaskDispatchListener): Promise<SearchResult[]> {
222
267
  // Reject tasks once this scheduler has begun teardown. shutdown() nulls priorityQueue and
223
268
  // clears the scheduler reference (a future run gets a fresh scheduler), so getPriorityQueue()
224
269
  // here would otherwise build a FRESH, non-shutdown queue on this dead instance and dispatch
@@ -235,12 +280,36 @@ export class BrowserTaskScheduler implements IScheduler {
235
280
  const cfg = config || getConfig();
236
281
  const timeoutMs = cfg.SEARCH_TIMEOUT_MS + cfg.BROWSER_TASK_TIMEOUT_MS;
237
282
 
283
+ // The deadline measures EXECUTION, not queue wait: the timer is armed by
284
+ // `startDeadline()` from inside the enqueued callback, i.e. once the queue has
285
+ // actually dispatched this task to a worker.
286
+ //
287
+ // It used to be armed here, before enqueuing, so a saturated queue burned a task's
288
+ // entire budget waiting for a slot and it aborted having done no work at all. That
289
+ // made the number of tasks a burst could complete `capacity x (budget / latency)`
290
+ // rather than the number submitted — measured on a live run, 78 of 100 searches
291
+ // aborted at the deadline without ever running. A queue is supposed to absorb
292
+ // volume by making work wait, not by killing whatever it could not reach in time.
293
+ // Waiting is now bounded by the caller's signal and the run-level timeouts; a task
294
+ // that does get dispatched always gets its full budget.
295
+ // `dispatchedAt` is the same instant, kept for MEASUREMENT rather than for the
296
+ // deadline. Reporting `Date.now() - startTime` as the search's duration charged
297
+ // queue wait to the work, and that number is what every latency analysis reads:
298
+ // on the night this was diagnosed it reported a 51s median against a true 5.8s
299
+ // execution median, and invented an eightfold latency regression that never
300
+ // happened. Moving the deadline off queue wait without moving the measurement
301
+ // would have left the misdiagnosis in place while claiming to have fixed it.
238
302
  let timeoutId: NodeJS.Timeout | undefined;
303
+ let dispatchedAt = 0;
304
+ let startDeadline: () => void = () => {};
239
305
  const timeoutPromise = new Promise<never>((_, reject) => {
240
- timeoutId = setTimeout(() => {
241
- reject(new Error(`Search task timed out after ${timeoutMs}ms (including queue wait). query="${query}"`));
242
- }, timeoutMs);
243
- if (timeoutId.unref) timeoutId.unref();
306
+ startDeadline = () => {
307
+ dispatchedAt = Date.now();
308
+ timeoutId = setTimeout(() => {
309
+ reject(new Error(`Search task timed out after ${timeoutMs}ms. query="${query}"`));
310
+ }, timeoutMs);
311
+ if (timeoutId.unref) timeoutId.unref();
312
+ };
244
313
  });
245
314
 
246
315
  logger.debug(`[BrowserTaskScheduler] Executing search: "${query}" (Timeout: ${timeoutMs}ms)`);
@@ -258,19 +327,24 @@ export class BrowserTaskScheduler implements IScheduler {
258
327
  // This ensures that even if the queue is saturated, we won't hang forever.
259
328
  result = await Promise.race([
260
329
  queue.enqueue('search', async () => {
261
- // The shared timeoutPromise is deliberately NOT raced in here (its timer
262
- // dies with the outer race); the backstop below carries its own timer, so
263
- // a QUEUED task can never be dispatched against a disarmed guard, and a
264
- // worker dying mid-task cannot pin the queue slot forever.
265
- return await executePoolTaskWithDeathBackstop(
266
- () => pool.execute({ type: 'search', query, queuedAt: startTime, taskTimeoutMs: timeoutMs }),
267
- timeoutMs,
268
- 'Search',
269
- );
330
+ startDeadline();
331
+ // Same instant, for the same reason, one layer up: whoever called us
332
+ // arms their own guard here instead of at call time, and disarms it
333
+ // when we settle. A listener that throws must not take the task down.
334
+ try { onTaskEvent?.('dispatched'); } catch { /* caller's guard, caller's problem */ }
335
+ try {
336
+ return await executePoolTaskWithDeathBackstop(
337
+ () => pool.execute({ type: 'search', query, queuedAt: startTime, taskTimeoutMs: timeoutMs }),
338
+ timeoutMs,
339
+ 'Search',
340
+ );
341
+ } finally {
342
+ try { onTaskEvent?.('settled'); } catch { /* as above */ }
343
+ }
270
344
  }, queueSignal),
271
345
  timeoutPromise
272
346
  ]);
273
- logger.debug(`[BrowserTaskScheduler] Search completed: "${query}" in ${Date.now() - startTime}ms`);
347
+ logger.debug(`[BrowserTaskScheduler] Search completed: "${query}" in ${executionMs(dispatchedAt, startTime)}ms (queued ${queueWaitMs(dispatchedAt, startTime)}ms)`);
274
348
  } catch (error) {
275
349
  // A pool-shutdown/destroy error means the run is being torn down (quit/SIGTERM
276
350
  // mid-search-burst): the task never ran because dispose() destroyed the pool.
@@ -304,9 +378,16 @@ export class BrowserTaskScheduler implements IScheduler {
304
378
  } finally {
305
379
  if (timeoutId) clearTimeout(timeoutId);
306
380
  settled.abort();
381
+ // Recorded here rather than on the success path below, because the tasks
382
+ // that wait longest are precisely the ones that do not succeed — measuring
383
+ // queue wait only on completed tasks censors the tail this metric exists to
384
+ // expose. A task that never reached a worker has no dispatch instant, so its
385
+ // wait is the whole elapsed time and is labelled as such; averaging it in as
386
+ // a zero would have understated saturation exactly when it was worst.
387
+ observeQueueWait('browser_search_queue_wait_ms', dispatchedAt, startTime);
307
388
  }
308
389
 
309
- const duration = Date.now() - startTime;
390
+ const duration = executionMs(dispatchedAt, startTime);
310
391
  // In-band worker error checked FIRST: a task the worker reports as failed
311
392
  // must count only as an error. Recording success unconditionally before
312
393
  // this check double-counted every worker-reported failure as
@@ -327,7 +408,7 @@ export class BrowserTaskScheduler implements IScheduler {
327
408
  return result.results;
328
409
  }
329
410
 
330
- async runScrape(url: string, config?: Config, signal?: AbortSignal): Promise<ScrapeResult> {
411
+ async runScrape(url: string, config?: Config, signal?: AbortSignal, onTaskEvent?: TaskDispatchListener): Promise<ScrapeResult> {
331
412
  if (this.isShuttingDown) throw new Error('Worker pool is shutting down');
332
413
  this.resetIdleTimer();
333
414
  const pool = await (await this.getWorkerPoolManager()).ensurePool(config);
@@ -338,12 +419,19 @@ export class BrowserTaskScheduler implements IScheduler {
338
419
  const isMocking = process.env['PI_RESEARCH_MOCK_SCRAPE'] === 'true';
339
420
  const timeoutMs = cfg.SCRAPE_TIMEOUT_MS + (isMocking ? 5000 : cfg.BROWSER_TASK_TIMEOUT_MS);
340
421
 
422
+ // Armed on dispatch, not on enqueue — see runSearch for why.
423
+ // See runSearch: dispatch time is tracked for measurement, not for the deadline.
341
424
  let timeoutId: NodeJS.Timeout | undefined;
425
+ let dispatchedAt = 0;
426
+ let startDeadline: () => void = () => {};
342
427
  const timeoutPromise = new Promise<never>((_, reject) => {
343
- timeoutId = setTimeout(() => {
344
- reject(new Error(`Scrape task timed out after ${timeoutMs}ms (including queue wait). url="${url}"`));
345
- }, timeoutMs);
346
- if (timeoutId.unref) timeoutId.unref();
428
+ startDeadline = () => {
429
+ dispatchedAt = Date.now();
430
+ timeoutId = setTimeout(() => {
431
+ reject(new Error(`Scrape task timed out after ${timeoutMs}ms. url="${url}"`));
432
+ }, timeoutMs);
433
+ if (timeoutId.unref) timeoutId.unref();
434
+ };
347
435
  });
348
436
 
349
437
  let result: any;
@@ -355,11 +443,18 @@ export class BrowserTaskScheduler implements IScheduler {
355
443
  const queue = this.getPriorityQueue(config);
356
444
  result = await Promise.race([
357
445
  queue.enqueue('scrape', async () => {
358
- return await executePoolTaskWithDeathBackstop(
359
- () => pool.execute({ type: 'scrape', url, queuedAt: startTime, taskTimeoutMs: timeoutMs }),
360
- timeoutMs,
361
- 'Scrape',
362
- );
446
+ startDeadline();
447
+ // See runSearch: the caller's guard is armed on dispatch and disarmed on settle.
448
+ try { onTaskEvent?.('dispatched'); } catch { /* caller's guard, caller's problem */ }
449
+ try {
450
+ return await executePoolTaskWithDeathBackstop(
451
+ () => pool.execute({ type: 'scrape', url, queuedAt: startTime, taskTimeoutMs: timeoutMs }),
452
+ timeoutMs,
453
+ 'Scrape',
454
+ );
455
+ } finally {
456
+ try { onTaskEvent?.('settled'); } catch { /* as above */ }
457
+ }
363
458
  }, queueSignal),
364
459
  timeoutPromise
365
460
  ]);
@@ -386,9 +481,11 @@ export class BrowserTaskScheduler implements IScheduler {
386
481
  } finally {
387
482
  if (timeoutId) clearTimeout(timeoutId);
388
483
  settled.abort();
484
+ // See runSearch: recorded on every outcome, not only on success.
485
+ observeQueueWait('browser_scrape_queue_wait_ms', dispatchedAt, startTime);
389
486
  }
390
487
 
391
- const duration = Date.now() - startTime;
488
+ const duration = executionMs(dispatchedAt, startTime);
392
489
  // In-band error first — success-side recording must not precede it (see runSearch).
393
490
  if (result.error) {
394
491
  metrics.increment('browser_scrape_requests_total', 1, { status: 'error' });
@@ -405,17 +502,103 @@ export class BrowserTaskScheduler implements IScheduler {
405
502
  return result;
406
503
  }
407
504
 
408
- async runHealthCheck(config?: Config, signal?: AbortSignal): Promise<{ success: boolean }> {
505
+ async runHealthCheck(config?: Config, signal?: AbortSignal, budgetOverrideMs?: number): Promise<{ success: boolean }> {
409
506
  if (this.isShuttingDown) throw new Error('Worker pool is shutting down');
410
507
  this.resetIdleTimer();
411
508
  const pool = await (await this.getWorkerPoolManager()).ensurePool(config);
412
509
  const startTime = Date.now();
413
510
  const isMocking = process.env['PI_RESEARCH_MOCK_SEARCH'] === 'true' || process.env['PI_RESEARCH_MOCK_SCRAPE'] === 'true';
414
- const timeoutMs = (45000 + 60000) / (isMocking ? 4 : 1);
511
+ // Derived, not hardcoded — see getHealthCheckBudgetMs. The wait-phase verdict
512
+ // below rests on this budget out-waiting any task that can hold a slot, and a
513
+ // fixed 105s made that true only for the default timeouts. `budgetOverrideMs`
514
+ // exists so the verdict is reachable from a test at all: at 105s it is not,
515
+ // which is exactly how a previous version of it shipped unreachable and stayed
516
+ // that way.
517
+ const timeoutMs = budgetOverrideMs ?? getHealthCheckBudgetMs(config) / (isMocking ? 4 : 1);
518
+
519
+ // A probe that never got a worker used to fail as "timed out (including queue
520
+ // wait)", and the registry reported BrowserRuntime CRITICALLY UNHEALTHY —
521
+ // during a burst in which the pool was healthily executing the run's own
522
+ // searches. Twice on the night this was measured.
523
+ //
524
+ // Arming the deadline on DISPATCH is the whole fix, and it is sufficient. Three
525
+ // attempts were made to add a second, wait-phase verdict that would excuse a
526
+ // saturated pool, and all three were wrong. They are recorded here because each
527
+ // one reads sensibly and the next reader will otherwise reach for one of them:
528
+ //
529
+ // 1. "did the queue dispatch anything recently" — unreachable. Healthcheck
530
+ // tasks have strict queue priority, so if a slot frees while this probe
531
+ // waits, the queue dispatches THIS probe and cancels the timer. The timer
532
+ // can only fire in a window where nothing dispatched at all.
533
+ // 2. "are the slots occupied" — wrong in the dangerous direction. activeCount
534
+ // rises before pool.execute, so six workers hung inside a browser launch
535
+ // are six occupied slots, and the exact incident this was written for
536
+ // would have reported HEALTHY.
537
+ // 3. "has a task completed successfully recently" — unreachable for the same
538
+ // reason as (1). A success frees a slot, a freed slot dispatches this
539
+ // probe, so at expiry the last success is necessarily older than the whole
540
+ // budget. The condition it tested was false by construction.
541
+ //
542
+ // The general form of the mistake: with strict priority, a probe that waited
543
+ // out its entire budget without being dispatched IS the evidence of a wedge.
544
+ // There is no second signal to consult, and every attempt to find one either
545
+ // could not fire or fired for a dead pool. The numbers below are kept for the
546
+ // DIAGNOSTIC message — an operator needs to know whether the slots were busy
547
+ // and when work last succeeded — and not for the verdict.
548
+ //
549
+ // False positives are bounded by arithmetic rather than by judgement: the probe
550
+ // allows 105s while every task that can hold a slot is itself deadlined well
551
+ // under that (search 55s, scrape 25s plus backstop), so a slot frees inside the
552
+ // budget unless the pool has genuinely stopped.
553
+ //
554
+ // Once dispatched, the deadline measures execution alone (armed below, like
555
+ // runSearch/runScrape), so a slow probe still fails on its own merits.
415
556
  let timeoutId: NodeJS.Timeout | undefined;
557
+ let dispatched = false;
558
+ let hcDispatchedAt = 0;
559
+ let startDeadline: () => void = () => {};
560
+ // Captured BEFORE the timer can run. Resolving the queue inside the callback
561
+ // would call getPriorityQueue() on a scheduler that may have shut down in the
562
+ // meantime, which builds a FRESH queue on the dead instance — undoing the
563
+ // isShutdown latch that exists to reject enqueues racing teardown, and
564
+ // reporting a routine shutdown as a wedge because a new queue has dispatched
565
+ // nothing.
566
+ const healthQueue = this.getPriorityQueue(config);
567
+ // Rejects only. It used to be typed as resolvable because a branch here answered
568
+ // "healthy" without running the probe; nothing does that now, and the type says so.
416
569
  const timeoutPromise = new Promise<never>((_, reject) => {
417
- timeoutId = setTimeout(() => reject(new Error(`Health check timed out after ${timeoutMs}ms (including queue wait)`)), timeoutMs);
570
+ const onWaitExpired = () => {
571
+ if (dispatched) return;
572
+ const lastSuccessAt = healthQueue.getLastSuccessAt();
573
+ const sinceSuccess = lastSuccessAt > 0 ? Date.now() - lastSuccessAt : Number.POSITIVE_INFINITY;
574
+ // The wording is load-bearing, not prose. isBusyPoolHealthFailure
575
+ // (src/healthcheck/index.ts) decides whether a failed probe aborts the
576
+ // run, and it decides by matching /tim(e|ed) out|timeout/ on this very
577
+ // string. An earlier rewrite of this message dropped the word entirely —
578
+ // so a probe that queued out under load stopped reading as a timeout, the
579
+ // tolerance gate rejected it, and a BUSY pool aborted the run at
580
+ // quick-research-orchestrator and research-health. The commit that did it
581
+ // was the one meant to stop a busy pool being reported as dead; it moved
582
+ // the abort one layer up. Pinned by a test that drives this exact string
583
+ // through that exact classifier.
584
+ //
585
+ // The rest is diagnosis: slots busy with nothing completing is a worker
586
+ // fault, slots free is a queue fault (the queue stopped dispatching), and
587
+ // the last-success age separates "stopped just now" from "never started".
588
+ reject(new Error(
589
+ `Health check timed out after ${timeoutMs}ms without reaching a worker — ${healthQueue.getActiveCount()} of ${healthQueue.getConcurrency()} slots busy, ` +
590
+ `${lastSuccessAt > 0 ? `last successful task ${sinceSuccess}ms ago` : 'no task has ever completed successfully'}.`,
591
+ ));
592
+ };
593
+ timeoutId = setTimeout(onWaitExpired, timeoutMs);
418
594
  if (timeoutId.unref) timeoutId.unref();
595
+ startDeadline = () => {
596
+ dispatched = true;
597
+ hcDispatchedAt = Date.now();
598
+ clearTimeout(timeoutId);
599
+ timeoutId = setTimeout(() => reject(new Error(`Health check timed out after ${timeoutMs}ms.`)), timeoutMs);
600
+ if (timeoutId.unref) timeoutId.unref();
601
+ };
419
602
  });
420
603
 
421
604
  let result: { success: boolean; error?: string };
@@ -424,9 +607,11 @@ export class BrowserTaskScheduler implements IScheduler {
424
607
  const settled = new AbortController();
425
608
  const queueSignal = signal ? AbortSignal.any([signal, settled.signal]) : settled.signal;
426
609
  try {
427
- const queue = this.getPriorityQueue(config);
610
+ // Same instance the wait-expiry message reads, so the diagnosis cannot
611
+ // describe a different queue from the one this probe waited on.
428
612
  result = await Promise.race([
429
- queue.enqueue('healthcheck', async () => {
613
+ healthQueue.enqueue('healthcheck', async () => {
614
+ startDeadline();
430
615
  return await executePoolTaskWithDeathBackstop(() => {
431
616
  const execPromise = pool.execute({ type: 'healthcheck', queuedAt: startTime, taskTimeoutMs: timeoutMs });
432
617
  execPromise.catch((err: Error) => logger.debug(`[BrowserTaskScheduler] Background healthcheck task rejection: ${err.message}`));
@@ -459,9 +644,10 @@ export class BrowserTaskScheduler implements IScheduler {
459
644
  } finally {
460
645
  if (timeoutId) clearTimeout(timeoutId);
461
646
  settled.abort();
647
+ observeQueueWait('browser_healthcheck_queue_wait_ms', hcDispatchedAt, startTime);
462
648
  }
463
649
 
464
- const duration = Date.now() - startTime;
650
+ const duration = executionMs(hcDispatchedAt, startTime);
465
651
  // In-band error first — success-side recording must not precede it (see
466
652
  // runSearch). This path additionally owns the browser_pool_health gauge:
467
653
  // setting it to 1 before the check flickered the pool healthy on every
@@ -31,15 +31,40 @@ function getWindowsCamoufoxDir(): string {
31
31
  return join(homedir(), 'AppData', 'Local', 'camoufox', 'camoufox', 'Cache');
32
32
  }
33
33
 
34
+ /**
35
+ * The custom camoufox location, if the user set one.
36
+ *
37
+ * CAMOUFOX_INSTALL_DIR takes precedence because it is camoufox-js's OWN
38
+ * variable and the only one that can ever move the install: camoufox-js ≥0.12.0
39
+ * resolves INSTALL_DIR from it. PLAYWRIGHT_BROWSERS_PATH is kept as a documented
40
+ * alias, but on its own it only ever moved where we LOOK — camoufox went on
41
+ * installing to the default cache, so setting it made the browser permanently
42
+ * "not found" right after a successful download. Both are exported onward to
43
+ * camoufox-js (getBrowserEnv, scripts/setup.cjs) so that install and lookup
44
+ * cannot disagree.
45
+ *
46
+ * CAVEAT, deliberate: the pinned camoufox-js (0.10.x — see
47
+ * thread-worker-browser.ts for why it cannot move yet) hardcodes
48
+ * `userCacheDir("camoufox")` and honours NEITHER variable, so on the shipped
49
+ * pin a custom location still only relocates lookup and the underlying bug
50
+ * remains. This plumbing is correct and inert rather than wrong: it starts
51
+ * working the moment the pin can advance, and costs nothing until then. Until
52
+ * that happens, treat a custom browser directory as unsupported.
53
+ */
54
+ function getCustomCamoufoxDir(): string | undefined {
55
+ return process.env['CAMOUFOX_INSTALL_DIR'] || process.env['PLAYWRIGHT_BROWSERS_PATH'] || undefined;
56
+ }
57
+
34
58
  /**
35
59
  * Get the camoufox binary cache directory.
36
- * Uses PLAYWRIGHT_BROWSERS_PATH if set, otherwise the standard user cache location
60
+ * Uses the custom location above if set, otherwise the standard user cache location
37
61
  * that camoufox uses by default (~/.cache/camoufox on Linux).
38
62
  * We do NOT override HOME — that trick was unreliable and caused install/runtime mismatches.
39
63
  */
40
64
  export function getBrowserCacheDir(): string {
41
- if (process.env['PLAYWRIGHT_BROWSERS_PATH']) {
42
- return process.env['PLAYWRIGHT_BROWSERS_PATH'];
65
+ const custom = getCustomCamoufoxDir();
66
+ if (custom) {
67
+ return custom;
43
68
  }
44
69
  // Mirror getCamoufoxBinaryPath() so both functions agree on the cache location
45
70
  const osPlatform = platform();
@@ -109,11 +134,17 @@ export function getBrowserProfileDir(config?: Config): string {
109
134
  */
110
135
  export function getBrowserEnv(config?: Config): NodeJS.ProcessEnv {
111
136
  const env: NodeJS.ProcessEnv = { ...process.env };
112
- const customPath = process.env['PLAYWRIGHT_BROWSERS_PATH'];
137
+ const customPath = getCustomCamoufoxDir();
113
138
  if (customPath) {
114
139
  env['PLAYWRIGHT_BROWSERS_PATH'] = customPath;
140
+ // The one that camoufox-js actually reads. Without it the worker's
141
+ // camoufox launches from the DEFAULT cache while everything on this side
142
+ // resolves the custom path — the install/runtime mismatch this file's
143
+ // header warns about, just arriving through a different door.
144
+ env['CAMOUFOX_INSTALL_DIR'] = customPath;
115
145
  } else {
116
146
  delete env['PLAYWRIGHT_BROWSERS_PATH'];
147
+ delete env['CAMOUFOX_INSTALL_DIR'];
117
148
  }
118
149
  // Tell thread-workers where to write their lifecycle/error log. Precedence:
119
150
  // 1. A user-set PI_RESEARCH_LOG_FILE (already copied from process.env above) wins —
@@ -164,7 +195,7 @@ export function ensureBrowserCacheDir(): string {
164
195
  * Matches camoufox-js's own resolution logic.
165
196
  */
166
197
  export function getCamoufoxBinaryPath(): string {
167
- const customPath = process.env['PLAYWRIGHT_BROWSERS_PATH'];
198
+ const customPath = getCustomCamoufoxDir();
168
199
  if (customPath) {
169
200
  return customPath;
170
201
  }
@@ -296,4 +327,44 @@ export function isBrowserAvailable(): boolean {
296
327
  function isFullMockMode(): boolean {
297
328
  return process.env['PI_RESEARCH_MOCK_SEARCH'] === 'true' &&
298
329
  process.env['PI_RESEARCH_MOCK_SCRAPE'] === 'true';
299
- }
330
+ }
331
+ /**
332
+ * How long the leader's health probe may take, end to end.
333
+ *
334
+ * This was a hardcoded 105s (45s probe + 60s queue-wait margin) and the verdict logic
335
+ * that reads it is built on an arithmetic claim: the probe out-waits any task that can
336
+ * hold a worker slot, so a probe that never reaches a worker really has found a wedge.
337
+ * That claim held for the DEFAULT timeouts and for no others. `SEARCH_TIMEOUT_MS` and
338
+ * `BROWSER_TASK_TIMEOUT_MS` are both settable to 120 000, so a search may legally hold
339
+ * a slot for 240s — and a user who merely raised the search timeout would see healthy
340
+ * pools reported as wedged, which aborts the run at the readiness gate.
341
+ *
342
+ * Deriving it instead makes the invariant hold by construction:
343
+ * - the longest a single task may hold a slot, plus
344
+ * - the probe's own work: the worker runs up to THREE navigation attempts (primary,
345
+ * retry, neutral-endpoint fallback) at `max(10s, HEALTH_CHECK_TIMEOUT_MS)` each,
346
+ * and a budget below that sum makes the fallback ladder unreachable — so raising
347
+ * HEALTH_CHECK_TIMEOUT_MS used to make the health check strictly worse.
348
+ *
349
+ * Floored at the historical 105s so default installs are unchanged.
350
+ */
351
+ export const HEALTHCHECK_MIN_BUDGET_MS = 45_000 + 60_000;
352
+
353
+ /** Navigation attempts executeHealthCheck makes: primary, retry, fallback endpoint. */
354
+ const HEALTHCHECK_NAV_ATTEMPTS = 3;
355
+
356
+ export function getHealthCheckBudgetMs(config?: Config): number {
357
+ const c = (config || getConfig()) as Partial<Config>;
358
+ // Each field is read defensively. A missing or non-finite value would otherwise
359
+ // propagate a NaN into `setTimeout`, which Node coerces to 1ms — turning the health
360
+ // probe's deadline into an instant failure. Falling back to the schema default is
361
+ // the only reading that cannot silently invert the bound.
362
+ const ms = (value: unknown, fallback: number): number =>
363
+ typeof value === 'number' && Number.isFinite(value) ? value : fallback;
364
+ const longestSlotHold =
365
+ Math.max(ms(c.SEARCH_TIMEOUT_MS, 45_000), ms(c.SCRAPE_TIMEOUT_MS, 15_000))
366
+ + ms(c.BROWSER_TASK_TIMEOUT_MS, 10_000);
367
+ // Mirrors the worker's own floor in executeHealthCheck.
368
+ const probeWork = HEALTHCHECK_NAV_ATTEMPTS * Math.max(10_000, ms(c.HEALTH_CHECK_TIMEOUT_MS, 10_000));
369
+ return Math.max(HEALTHCHECK_MIN_BUDGET_MS, longestSlotHold + probeWork);
370
+ }
@@ -2,6 +2,20 @@ import { logger } from '../../logger.ts';
2
2
 
3
3
  export type TaskType = 'search' | 'scrape' | 'healthcheck';
4
4
 
5
+ /**
6
+ * Did this resolved value carry an in-band worker failure?
7
+ *
8
+ * Worker tasks report failure by RESOLVING with `{ error: ... }` rather than by
9
+ * rejecting, so "the promise settled successfully" and "the task succeeded" are
10
+ * different facts about the same value.
11
+ */
12
+ function isTaskFailureResult(result: unknown): boolean {
13
+ return typeof result === 'object'
14
+ && result !== null
15
+ && typeof (result as { error?: unknown }).error === 'string'
16
+ && (result as { error: string }).error.length > 0;
17
+ }
18
+
5
19
  export interface QueuedTask<T> {
6
20
  type: TaskType;
7
21
  fn: () => Promise<T>;
@@ -27,6 +41,16 @@ export class PriorityTaskQueue {
27
41
  private maxTotalConcurrency: number;
28
42
  private readonly maxQueueDepth: number;
29
43
  private isShutdown = false;
44
+ /** When a task last completed WITHOUT error (epoch ms), or 0 if never.
45
+ *
46
+ * Diagnostic only — it is reported in the health probe's failure message so an
47
+ * operator can tell a pool that stopped a moment ago from one that never started.
48
+ * It was briefly used as a health VERDICT and must not be again: a success frees a
49
+ * slot, a freed slot dispatches the waiting probe (healthchecks have strict
50
+ * priority), so by the time a probe times out this value is necessarily older than
51
+ * its whole budget. Failures deliberately do not count either — a wedged pool still
52
+ * turns its slots over as deadlines and death-backstops fire. */
53
+ private lastSuccessAt = 0;
30
54
 
31
55
  constructor(maxTotalConcurrency: number, maxQueueDepth = 500) {
32
56
  this.maxTotalConcurrency = maxTotalConcurrency;
@@ -90,6 +114,21 @@ export class PriorityTaskQueue {
90
114
  });
91
115
  }
92
116
 
117
+ /** Epoch ms of the last task that completed successfully, or 0 if none has. */
118
+ getLastSuccessAt(): number {
119
+ return this.lastSuccessAt;
120
+ }
121
+
122
+ /** Tasks currently executing on a worker. */
123
+ getActiveCount(): number {
124
+ return this.activeCount;
125
+ }
126
+
127
+ /** How many tasks may execute at once. */
128
+ getConcurrency(): number {
129
+ return this.maxTotalConcurrency;
130
+ }
131
+
93
132
  private removeFromQueue(task: QueuedTask<any>): boolean {
94
133
  const queues = [this.healthcheckQueue, this.searchQueue, this.scrapeQueue];
95
134
  for (const q of queues) {
@@ -171,6 +210,13 @@ export class PriorityTaskQueue {
171
210
  } else {
172
211
  result = await fnPromise;
173
212
  }
213
+ // A worker-reported failure arrives as a RESOLVED value carrying `error` —
214
+ // poolifier delivers it that way, and the scheduler only turns it into a
215
+ // throw afterwards. Resolution alone therefore does not mean the task
216
+ // succeeded: a worker returning `{error:'Task timed out'}` for every task
217
+ // would keep this timestamp fresh through a total outage, which is the one
218
+ // thing it must not do.
219
+ if (!isTaskFailureResult(result)) this.lastSuccessAt = Date.now();
174
220
  task.resolve(result);
175
221
  } catch (err) {
176
222
  task.reject(err);
@@ -187,8 +233,16 @@ export class PriorityTaskQueue {
187
233
  * Update the maximum concurrency limit (e.g. if config changes).
188
234
  */
189
235
  updateConcurrency(maxTotalConcurrency: number) {
236
+ // Called on a path that runs per task, and the value almost never changes: this
237
+ // logged "Concurrency updated to 6" 709 times in one day, always to the value it
238
+ // already had. Log real changes only — a line that fires constantly and says
239
+ // nothing is what a reader learns to skip past, including on the day it matters.
240
+ // process() still runs unconditionally: it is idempotent, and a raise that
241
+ // arrived while the queue was full must be free to dispatch immediately.
242
+ if (maxTotalConcurrency !== this.maxTotalConcurrency) {
243
+ logger.debug(`[PriorityQueue] Concurrency changed ${this.maxTotalConcurrency} -> ${maxTotalConcurrency}`);
244
+ }
190
245
  this.maxTotalConcurrency = maxTotalConcurrency;
191
- logger.debug(`[PriorityQueue] Concurrency updated to ${maxTotalConcurrency}`);
192
246
  this.process();
193
247
  }
194
248
 
@@ -162,8 +162,19 @@ export async function forceSchedulerRestart(forceClearRemoteState: boolean = fal
162
162
 
163
163
  logger.log('[Scheduler] Restart complete. Next call will create fresh scheduler.');
164
164
  } finally {
165
+ // Deliberately does NOT dispose the browser-init lock. This function never
166
+ // acquires it, and FileLockService.dispose() is not a release — it drains for
167
+ // 5s and then FORCES the teardown, unlinking the lock file and retiring the
168
+ // instance. A browser init holds that lock for as long as it takes to launch a
169
+ // browser server, which is routinely longer than 5s, and the retry paths in
170
+ // task-execution-service call this function mid-run precisely when an init is
171
+ // likely in flight. The victim's critical section is not cancelled by the
172
+ // teardown, and the next getBrowserInitLock() builds a fresh instance (new uuid,
173
+ // new FIFO queue) over the now-unlinked path, so it acquires immediately: two
174
+ // concurrent browser inits, two elections, two listening servers. The lock is
175
+ // path-scoped and version-independent, so a restart does not invalidate it;
176
+ // the owning SchedulerFactoryService disposes it at container teardown.
165
177
  schedulerService.setSchedulerRestartInProgress(false);
166
- await disposeBrowserInitLock(container).catch(() => {});
167
178
  }
168
179
  }
169
180