@lincoln504/pi-research 1.3.12 → 1.4.3

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 (86) hide show
  1. package/README.md +2 -2
  2. package/dist/cli.mjs +5670 -4705
  3. package/dist/prompts/research-tool-usage.md +1 -1
  4. package/dist/prompts/researcher.md +1 -0
  5. package/dist/prompts/system-lead-router.md +122 -0
  6. package/dist/prompts/system-lead-synthesizer.md +46 -0
  7. package/dist/thread-worker.mjs +169 -62
  8. package/docs/ARCHITECTURE.md +18 -10
  9. package/docs/CHANGELOG.md +225 -0
  10. package/docs/CONFIGURATION.md +106 -7
  11. package/package.json +2 -2
  12. package/scripts/build.cjs +8 -1
  13. package/skills/pi-research/SKILL.md +1 -1
  14. package/src/cli.ts +5 -1
  15. package/src/config.ts +22 -12
  16. package/src/constants.ts +1 -1
  17. package/src/core/interfaces/observer-interfaces.ts +3 -3
  18. package/src/core/interfaces/orchestration-interfaces.ts +2 -0
  19. package/src/core/interfaces/planning-interfaces.ts +7 -1
  20. package/src/core/interfaces/scheduler-interfaces.ts +18 -2
  21. package/src/core/llm/agentic-repair.ts +7 -2
  22. package/src/core/llm/inject-date.ts +4 -1
  23. package/src/core/llm/llm-utils.ts +7 -0
  24. package/src/core/planning-service.ts +766 -91
  25. package/src/core/planning-utils.ts +21 -5
  26. package/src/core/service-initialization.ts +19 -3
  27. package/src/healthcheck/index.ts +11 -2
  28. package/src/index.ts +4 -3
  29. package/src/infrastructure/browser/browser-client.ts +68 -34
  30. package/src/infrastructure/browser/browser-task-scheduler.ts +236 -43
  31. package/src/infrastructure/browser/config.ts +79 -1
  32. package/src/infrastructure/browser/priority-task-queue.ts +55 -1
  33. package/src/infrastructure/browser/scheduler-factory.ts +12 -1
  34. package/src/infrastructure/browser/task-execution-service.ts +5 -5
  35. package/src/infrastructure/browser/thread-worker-browser.ts +87 -36
  36. package/src/infrastructure/browser/thread-worker-messaging.ts +99 -10
  37. package/src/infrastructure/browser/thread-worker.mjs +169 -62
  38. package/src/infrastructure/browser/thread-worker.ts +13 -5
  39. package/src/infrastructure/browser/worker-pool-manager.ts +127 -12
  40. package/src/infrastructure/embedding/embedding-client.ts +14 -4
  41. package/src/infrastructure/embedding/embedding-factory.ts +83 -15
  42. package/src/infrastructure/embedding/embedding-server.ts +34 -0
  43. package/src/infrastructure/file-lock-service.ts +214 -19
  44. package/src/infrastructure/knowledge-store-service.ts +14 -0
  45. package/src/infrastructure/research-run-semaphore.ts +1 -1
  46. package/src/infrastructure/service-initialization.ts +7 -0
  47. package/src/knowledge/embedder-init.ts +46 -19
  48. package/src/knowledge/embedder.ts +11 -3
  49. package/src/knowledge/index.ts +36 -10
  50. package/src/knowledge/store.ts +29 -5
  51. package/src/logger.ts +11 -0
  52. package/src/orchestration/deep-research-orchestrator.ts +143 -87
  53. package/src/orchestration/quick-research-orchestrator.ts +11 -2
  54. package/src/orchestration/research-orchestration-service.ts +8 -0
  55. package/src/orchestration/research-synthesis-service.ts +53 -1
  56. package/src/orchestration/researcher-executor.ts +53 -2
  57. package/src/orchestration/researcher.ts +15 -2
  58. package/src/orchestration/session-state.ts +16 -0
  59. package/src/prompts/research-tool-usage.md +1 -1
  60. package/src/prompts/researcher.md +1 -0
  61. package/src/prompts/system-lead-router.md +122 -0
  62. package/src/prompts/system-lead-synthesizer.md +46 -0
  63. package/src/sdk.ts +10 -0
  64. package/src/skill-install/skill-installer.ts +5 -1
  65. package/src/stackexchange/index.ts +3 -0
  66. package/src/stackexchange/rest-client.ts +7 -12
  67. package/src/tools/research-knowledge-search.ts +11 -3
  68. package/src/tools/research-tool-definition.ts +38 -7
  69. package/src/tools/scrape.ts +6 -0
  70. package/src/tools/search.ts +7 -0
  71. package/src/tools/security.ts +31 -10
  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/host-config.ts +12 -1
  77. package/src/utils/llm-usage.ts +33 -6
  78. package/src/utils/log-utils.ts +10 -0
  79. package/src/utils/metrics-summary.ts +20 -4
  80. package/src/utils/metrics.ts +27 -1
  81. package/src/web-research/browser-search.ts +102 -22
  82. package/src/web-research/web-scraper.ts +27 -16
  83. package/src/youtube/potoken.ts +8 -1
  84. package/src/youtube/transcript-client.ts +56 -5
  85. package/dist/prompts/system-lead-evaluator.md +0 -118
  86. 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, COLD_START_ALLOWANCE_MS } 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
@@ -228,19 +273,48 @@ export class BrowserTaskScheduler implements IScheduler {
228
273
  const pool = await (await this.getWorkerPoolManager()).ensurePool(config);
229
274
  const startTime = Date.now();
230
275
 
231
- // Task timeout = the search nav budget (SEARCH_TIMEOUT_MS) + a queue-wait/overhead
232
- // margin (BROWSER_TASK_TIMEOUT_MS). Deriving from SEARCH_TIMEOUT_MS keeps the task
233
- // ceiling coherent with the worker's own nav timeout — using a flat base here would
234
- // kill a search before it had used its full nav budget.
276
+ // Task timeout = the search nav budget (SEARCH_TIMEOUT_MS) + an overhead margin
277
+ // (BROWSER_TASK_TIMEOUT_MS) + a cold-start allowance (COLD_START_ALLOWANCE_MS).
278
+ // Deriving from SEARCH_TIMEOUT_MS keeps the task ceiling coherent with the
279
+ // worker's own nav timeout — using a flat base here would kill a search before
280
+ // it had used its full nav budget. The cold-start term matters because eager
281
+ // browser warmup is deliberately disabled: the first search dispatched to a
282
+ // freshly created or just-reset worker pays a real Camoufox launch + context
283
+ // creation inline, before any navigation even starts — without this term that
284
+ // legitimate cold start reads as a dead/wedged worker (see COLD_START_ALLOWANCE_MS).
235
285
  const cfg = config || getConfig();
236
- const timeoutMs = cfg.SEARCH_TIMEOUT_MS + cfg.BROWSER_TASK_TIMEOUT_MS;
237
-
286
+ const timeoutMs = cfg.SEARCH_TIMEOUT_MS + cfg.BROWSER_TASK_TIMEOUT_MS + COLD_START_ALLOWANCE_MS;
287
+
288
+ // The deadline measures EXECUTION, not queue wait: the timer is armed by
289
+ // `startDeadline()` from inside the enqueued callback, i.e. once the queue has
290
+ // actually dispatched this task to a worker.
291
+ //
292
+ // It used to be armed here, before enqueuing, so a saturated queue burned a task's
293
+ // entire budget waiting for a slot and it aborted having done no work at all. That
294
+ // made the number of tasks a burst could complete `capacity x (budget / latency)`
295
+ // rather than the number submitted — measured on a live run, 78 of 100 searches
296
+ // aborted at the deadline without ever running. A queue is supposed to absorb
297
+ // volume by making work wait, not by killing whatever it could not reach in time.
298
+ // Waiting is now bounded by the caller's signal and the run-level timeouts; a task
299
+ // that does get dispatched always gets its full budget.
300
+ // `dispatchedAt` is the same instant, kept for MEASUREMENT rather than for the
301
+ // deadline. Reporting `Date.now() - startTime` as the search's duration charged
302
+ // queue wait to the work, and that number is what every latency analysis reads:
303
+ // on the night this was diagnosed it reported a 51s median against a true 5.8s
304
+ // execution median, and invented an eightfold latency regression that never
305
+ // happened. Moving the deadline off queue wait without moving the measurement
306
+ // would have left the misdiagnosis in place while claiming to have fixed it.
238
307
  let timeoutId: NodeJS.Timeout | undefined;
308
+ let dispatchedAt = 0;
309
+ let startDeadline: () => void = () => {};
239
310
  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();
311
+ startDeadline = () => {
312
+ dispatchedAt = Date.now();
313
+ timeoutId = setTimeout(() => {
314
+ reject(new Error(`Search task timed out after ${timeoutMs}ms. query="${query}"`));
315
+ }, timeoutMs);
316
+ if (timeoutId.unref) timeoutId.unref();
317
+ };
244
318
  });
245
319
 
246
320
  logger.debug(`[BrowserTaskScheduler] Executing search: "${query}" (Timeout: ${timeoutMs}ms)`);
@@ -258,19 +332,24 @@ export class BrowserTaskScheduler implements IScheduler {
258
332
  // This ensures that even if the queue is saturated, we won't hang forever.
259
333
  result = await Promise.race([
260
334
  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
- );
335
+ startDeadline();
336
+ // Same instant, for the same reason, one layer up: whoever called us
337
+ // arms their own guard here instead of at call time, and disarms it
338
+ // when we settle. A listener that throws must not take the task down.
339
+ try { onTaskEvent?.('dispatched'); } catch { /* caller's guard, caller's problem */ }
340
+ try {
341
+ return await executePoolTaskWithDeathBackstop(
342
+ () => pool.execute({ type: 'search', query, queuedAt: startTime, taskTimeoutMs: timeoutMs }),
343
+ timeoutMs,
344
+ 'Search',
345
+ );
346
+ } finally {
347
+ try { onTaskEvent?.('settled'); } catch { /* as above */ }
348
+ }
270
349
  }, queueSignal),
271
350
  timeoutPromise
272
351
  ]);
273
- logger.debug(`[BrowserTaskScheduler] Search completed: "${query}" in ${Date.now() - startTime}ms`);
352
+ logger.debug(`[BrowserTaskScheduler] Search completed: "${query}" in ${executionMs(dispatchedAt, startTime)}ms (queued ${queueWaitMs(dispatchedAt, startTime)}ms)`);
274
353
  } catch (error) {
275
354
  // A pool-shutdown/destroy error means the run is being torn down (quit/SIGTERM
276
355
  // mid-search-burst): the task never ran because dispose() destroyed the pool.
@@ -304,9 +383,16 @@ export class BrowserTaskScheduler implements IScheduler {
304
383
  } finally {
305
384
  if (timeoutId) clearTimeout(timeoutId);
306
385
  settled.abort();
386
+ // Recorded here rather than on the success path below, because the tasks
387
+ // that wait longest are precisely the ones that do not succeed — measuring
388
+ // queue wait only on completed tasks censors the tail this metric exists to
389
+ // expose. A task that never reached a worker has no dispatch instant, so its
390
+ // wait is the whole elapsed time and is labelled as such; averaging it in as
391
+ // a zero would have understated saturation exactly when it was worst.
392
+ observeQueueWait('browser_search_queue_wait_ms', dispatchedAt, startTime);
307
393
  }
308
394
 
309
- const duration = Date.now() - startTime;
395
+ const duration = executionMs(dispatchedAt, startTime);
310
396
  // In-band worker error checked FIRST: a task the worker reports as failed
311
397
  // must count only as an error. Recording success unconditionally before
312
398
  // this check double-counted every worker-reported failure as
@@ -327,23 +413,32 @@ export class BrowserTaskScheduler implements IScheduler {
327
413
  return result.results;
328
414
  }
329
415
 
330
- async runScrape(url: string, config?: Config, signal?: AbortSignal): Promise<ScrapeResult> {
416
+ async runScrape(url: string, config?: Config, signal?: AbortSignal, onTaskEvent?: TaskDispatchListener): Promise<ScrapeResult> {
331
417
  if (this.isShuttingDown) throw new Error('Worker pool is shutting down');
332
418
  this.resetIdleTimer();
333
419
  const pool = await (await this.getWorkerPoolManager()).ensurePool(config);
334
420
  const startTime = Date.now();
335
- // Task timeout = the scrape nav budget (SCRAPE_TIMEOUT_MS) + the queue-wait/overhead
336
- // margin (BROWSER_TASK_TIMEOUT_MS), mirroring the search path so both stay coherent.
421
+ // Task timeout = the scrape nav budget (SCRAPE_TIMEOUT_MS) + an overhead margin,
422
+ // mirroring the search path so both stay coherent. The mocked branch skips the
423
+ // cold-start allowance deliberately: PI_RESEARCH_MOCK_SCRAPE implies no real
424
+ // Camoufox launch is expected for scrape tasks, so there is nothing to budget for.
337
425
  const cfg = config || getConfig();
338
426
  const isMocking = process.env['PI_RESEARCH_MOCK_SCRAPE'] === 'true';
339
- const timeoutMs = cfg.SCRAPE_TIMEOUT_MS + (isMocking ? 5000 : cfg.BROWSER_TASK_TIMEOUT_MS);
427
+ const timeoutMs = cfg.SCRAPE_TIMEOUT_MS + (isMocking ? 5000 : cfg.BROWSER_TASK_TIMEOUT_MS + COLD_START_ALLOWANCE_MS);
340
428
 
429
+ // Armed on dispatch, not on enqueue — see runSearch for why.
430
+ // See runSearch: dispatch time is tracked for measurement, not for the deadline.
341
431
  let timeoutId: NodeJS.Timeout | undefined;
432
+ let dispatchedAt = 0;
433
+ let startDeadline: () => void = () => {};
342
434
  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();
435
+ startDeadline = () => {
436
+ dispatchedAt = Date.now();
437
+ timeoutId = setTimeout(() => {
438
+ reject(new Error(`Scrape task timed out after ${timeoutMs}ms. url="${url}"`));
439
+ }, timeoutMs);
440
+ if (timeoutId.unref) timeoutId.unref();
441
+ };
347
442
  });
348
443
 
349
444
  let result: any;
@@ -355,11 +450,18 @@ export class BrowserTaskScheduler implements IScheduler {
355
450
  const queue = this.getPriorityQueue(config);
356
451
  result = await Promise.race([
357
452
  queue.enqueue('scrape', async () => {
358
- return await executePoolTaskWithDeathBackstop(
359
- () => pool.execute({ type: 'scrape', url, queuedAt: startTime, taskTimeoutMs: timeoutMs }),
360
- timeoutMs,
361
- 'Scrape',
362
- );
453
+ startDeadline();
454
+ // See runSearch: the caller's guard is armed on dispatch and disarmed on settle.
455
+ try { onTaskEvent?.('dispatched'); } catch { /* caller's guard, caller's problem */ }
456
+ try {
457
+ return await executePoolTaskWithDeathBackstop(
458
+ () => pool.execute({ type: 'scrape', url, queuedAt: startTime, taskTimeoutMs: timeoutMs }),
459
+ timeoutMs,
460
+ 'Scrape',
461
+ );
462
+ } finally {
463
+ try { onTaskEvent?.('settled'); } catch { /* as above */ }
464
+ }
363
465
  }, queueSignal),
364
466
  timeoutPromise
365
467
  ]);
@@ -386,9 +488,11 @@ export class BrowserTaskScheduler implements IScheduler {
386
488
  } finally {
387
489
  if (timeoutId) clearTimeout(timeoutId);
388
490
  settled.abort();
491
+ // See runSearch: recorded on every outcome, not only on success.
492
+ observeQueueWait('browser_scrape_queue_wait_ms', dispatchedAt, startTime);
389
493
  }
390
494
 
391
- const duration = Date.now() - startTime;
495
+ const duration = executionMs(dispatchedAt, startTime);
392
496
  // In-band error first — success-side recording must not precede it (see runSearch).
393
497
  if (result.error) {
394
498
  metrics.increment('browser_scrape_requests_total', 1, { status: 'error' });
@@ -405,17 +509,103 @@ export class BrowserTaskScheduler implements IScheduler {
405
509
  return result;
406
510
  }
407
511
 
408
- async runHealthCheck(config?: Config, signal?: AbortSignal): Promise<{ success: boolean }> {
512
+ async runHealthCheck(config?: Config, signal?: AbortSignal, budgetOverrideMs?: number): Promise<{ success: boolean }> {
409
513
  if (this.isShuttingDown) throw new Error('Worker pool is shutting down');
410
514
  this.resetIdleTimer();
411
515
  const pool = await (await this.getWorkerPoolManager()).ensurePool(config);
412
516
  const startTime = Date.now();
413
517
  const isMocking = process.env['PI_RESEARCH_MOCK_SEARCH'] === 'true' || process.env['PI_RESEARCH_MOCK_SCRAPE'] === 'true';
414
- const timeoutMs = (45000 + 60000) / (isMocking ? 4 : 1);
518
+ // Derived, not hardcoded — see getHealthCheckBudgetMs. The wait-phase verdict
519
+ // below rests on this budget out-waiting any task that can hold a slot, and a
520
+ // fixed 105s made that true only for the default timeouts. `budgetOverrideMs`
521
+ // exists so the verdict is reachable from a test at all: at 105s it is not,
522
+ // which is exactly how a previous version of it shipped unreachable and stayed
523
+ // that way.
524
+ const timeoutMs = budgetOverrideMs ?? getHealthCheckBudgetMs(config) / (isMocking ? 4 : 1);
525
+
526
+ // A probe that never got a worker used to fail as "timed out (including queue
527
+ // wait)", and the registry reported BrowserRuntime CRITICALLY UNHEALTHY —
528
+ // during a burst in which the pool was healthily executing the run's own
529
+ // searches. Twice on the night this was measured.
530
+ //
531
+ // Arming the deadline on DISPATCH is the whole fix, and it is sufficient. Three
532
+ // attempts were made to add a second, wait-phase verdict that would excuse a
533
+ // saturated pool, and all three were wrong. They are recorded here because each
534
+ // one reads sensibly and the next reader will otherwise reach for one of them:
535
+ //
536
+ // 1. "did the queue dispatch anything recently" — unreachable. Healthcheck
537
+ // tasks have strict queue priority, so if a slot frees while this probe
538
+ // waits, the queue dispatches THIS probe and cancels the timer. The timer
539
+ // can only fire in a window where nothing dispatched at all.
540
+ // 2. "are the slots occupied" — wrong in the dangerous direction. activeCount
541
+ // rises before pool.execute, so six workers hung inside a browser launch
542
+ // are six occupied slots, and the exact incident this was written for
543
+ // would have reported HEALTHY.
544
+ // 3. "has a task completed successfully recently" — unreachable for the same
545
+ // reason as (1). A success frees a slot, a freed slot dispatches this
546
+ // probe, so at expiry the last success is necessarily older than the whole
547
+ // budget. The condition it tested was false by construction.
548
+ //
549
+ // The general form of the mistake: with strict priority, a probe that waited
550
+ // out its entire budget without being dispatched IS the evidence of a wedge.
551
+ // There is no second signal to consult, and every attempt to find one either
552
+ // could not fire or fired for a dead pool. The numbers below are kept for the
553
+ // DIAGNOSTIC message — an operator needs to know whether the slots were busy
554
+ // and when work last succeeded — and not for the verdict.
555
+ //
556
+ // False positives are bounded by arithmetic rather than by judgement: the probe
557
+ // allows 105s while every task that can hold a slot is itself deadlined well
558
+ // under that (search 55s, scrape 25s plus backstop), so a slot frees inside the
559
+ // budget unless the pool has genuinely stopped.
560
+ //
561
+ // Once dispatched, the deadline measures execution alone (armed below, like
562
+ // runSearch/runScrape), so a slow probe still fails on its own merits.
415
563
  let timeoutId: NodeJS.Timeout | undefined;
564
+ let dispatched = false;
565
+ let hcDispatchedAt = 0;
566
+ let startDeadline: () => void = () => {};
567
+ // Captured BEFORE the timer can run. Resolving the queue inside the callback
568
+ // would call getPriorityQueue() on a scheduler that may have shut down in the
569
+ // meantime, which builds a FRESH queue on the dead instance — undoing the
570
+ // isShutdown latch that exists to reject enqueues racing teardown, and
571
+ // reporting a routine shutdown as a wedge because a new queue has dispatched
572
+ // nothing.
573
+ const healthQueue = this.getPriorityQueue(config);
574
+ // Rejects only. It used to be typed as resolvable because a branch here answered
575
+ // "healthy" without running the probe; nothing does that now, and the type says so.
416
576
  const timeoutPromise = new Promise<never>((_, reject) => {
417
- timeoutId = setTimeout(() => reject(new Error(`Health check timed out after ${timeoutMs}ms (including queue wait)`)), timeoutMs);
577
+ const onWaitExpired = () => {
578
+ if (dispatched) return;
579
+ const lastSuccessAt = healthQueue.getLastSuccessAt();
580
+ const sinceSuccess = lastSuccessAt > 0 ? Date.now() - lastSuccessAt : Number.POSITIVE_INFINITY;
581
+ // The wording is load-bearing, not prose. isBusyPoolHealthFailure
582
+ // (src/healthcheck/index.ts) decides whether a failed probe aborts the
583
+ // run, and it decides by matching /tim(e|ed) out|timeout/ on this very
584
+ // string. An earlier rewrite of this message dropped the word entirely —
585
+ // so a probe that queued out under load stopped reading as a timeout, the
586
+ // tolerance gate rejected it, and a BUSY pool aborted the run at
587
+ // quick-research-orchestrator and research-health. The commit that did it
588
+ // was the one meant to stop a busy pool being reported as dead; it moved
589
+ // the abort one layer up. Pinned by a test that drives this exact string
590
+ // through that exact classifier.
591
+ //
592
+ // The rest is diagnosis: slots busy with nothing completing is a worker
593
+ // fault, slots free is a queue fault (the queue stopped dispatching), and
594
+ // the last-success age separates "stopped just now" from "never started".
595
+ reject(new Error(
596
+ `Health check timed out after ${timeoutMs}ms without reaching a worker — ${healthQueue.getActiveCount()} of ${healthQueue.getConcurrency()} slots busy, ` +
597
+ `${lastSuccessAt > 0 ? `last successful task ${sinceSuccess}ms ago` : 'no task has ever completed successfully'}.`,
598
+ ));
599
+ };
600
+ timeoutId = setTimeout(onWaitExpired, timeoutMs);
418
601
  if (timeoutId.unref) timeoutId.unref();
602
+ startDeadline = () => {
603
+ dispatched = true;
604
+ hcDispatchedAt = Date.now();
605
+ clearTimeout(timeoutId);
606
+ timeoutId = setTimeout(() => reject(new Error(`Health check timed out after ${timeoutMs}ms.`)), timeoutMs);
607
+ if (timeoutId.unref) timeoutId.unref();
608
+ };
419
609
  });
420
610
 
421
611
  let result: { success: boolean; error?: string };
@@ -424,9 +614,11 @@ export class BrowserTaskScheduler implements IScheduler {
424
614
  const settled = new AbortController();
425
615
  const queueSignal = signal ? AbortSignal.any([signal, settled.signal]) : settled.signal;
426
616
  try {
427
- const queue = this.getPriorityQueue(config);
617
+ // Same instance the wait-expiry message reads, so the diagnosis cannot
618
+ // describe a different queue from the one this probe waited on.
428
619
  result = await Promise.race([
429
- queue.enqueue('healthcheck', async () => {
620
+ healthQueue.enqueue('healthcheck', async () => {
621
+ startDeadline();
430
622
  return await executePoolTaskWithDeathBackstop(() => {
431
623
  const execPromise = pool.execute({ type: 'healthcheck', queuedAt: startTime, taskTimeoutMs: timeoutMs });
432
624
  execPromise.catch((err: Error) => logger.debug(`[BrowserTaskScheduler] Background healthcheck task rejection: ${err.message}`));
@@ -459,9 +651,10 @@ export class BrowserTaskScheduler implements IScheduler {
459
651
  } finally {
460
652
  if (timeoutId) clearTimeout(timeoutId);
461
653
  settled.abort();
654
+ observeQueueWait('browser_healthcheck_queue_wait_ms', hcDispatchedAt, startTime);
462
655
  }
463
656
 
464
- const duration = Date.now() - startTime;
657
+ const duration = executionMs(hcDispatchedAt, startTime);
465
658
  // In-band error first — success-side recording must not precede it (see
466
659
  // runSearch). This path additionally owns the browser_pool_health gauge:
467
660
  // setting it to 1 before the check flickered the pool healthy on every
@@ -327,4 +327,82 @@ export function isBrowserAvailable(): boolean {
327
327
  function isFullMockMode(): boolean {
328
328
  return process.env['PI_RESEARCH_MOCK_SEARCH'] === 'true' &&
329
329
  process.env['PI_RESEARCH_MOCK_SCRAPE'] === 'true';
330
- }
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, kept as a floor rather than a default: the
350
+ * derived value now exceeds it even at default timeouts once COLD_START_ALLOWANCE_MS
351
+ * (below) is folded into `longestSlotHold` — a cold Firefox launch was always able to
352
+ * hold a slot for close to that long, the 105s floor just didn't know it. The floor
353
+ * still matters for a hypothetical config where the derived value would otherwise land
354
+ * below it.
355
+ */
356
+ export const HEALTHCHECK_MIN_BUDGET_MS = 45_000 + 60_000;
357
+
358
+ /** Navigation attempts executeHealthCheck makes: primary, retry, fallback endpoint. */
359
+ const HEALTHCHECK_NAV_ATTEMPTS = 3;
360
+
361
+ /**
362
+ * Hard ceiling on a single Camoufox browser launch (see launchOnce() in
363
+ * thread-worker-browser.ts, the single source of truth this mirrors — CI runners
364
+ * (2 vCPU) can take up to ~60s to start Firefox under load, floored generously
365
+ * above that).
366
+ */
367
+ export const BROWSER_LAUNCH_TIMEOUT_MS = 90_000;
368
+
369
+ /**
370
+ * Hard ceiling on creating one BrowserContext against an already-launched browser
371
+ * (see acquireTaskContext() in thread-worker-browser.ts, the single source of
372
+ * truth this mirrors).
373
+ */
374
+ export const CONTEXT_CREATION_TIMEOUT_MS = 30_000;
375
+
376
+ /**
377
+ * Worst-case time a worker can spend getting a browser+context ready before it
378
+ * starts a task's actual work. Eager warmup is deliberately disabled (thundering-
379
+ * herd avoidance — see thread-worker.ts), so the first task dispatched to a
380
+ * freshly created or just-reset worker pays this in full, inline with the task
381
+ * itself. Every deadline that bounds how long a task may legitimately hold a
382
+ * worker slot has to include this on top of its own nav budget, or a completely
383
+ * normal cold start on a fresh pool reads as a dead/wedged worker — the false
384
+ * positive `runSearch`/`runScrape`'s task-timeout ceiling and this budget were
385
+ * both built to eliminate, just triggered by a different clock.
386
+ *
387
+ * Deliberately NOT part of BROWSER_TASK_TIMEOUT_MS: that value is user-tunable
388
+ * overhead for per-task work, and cold-start cost isn't something a user should
389
+ * need to account for by hand when adjusting it.
390
+ */
391
+ export const COLD_START_ALLOWANCE_MS = BROWSER_LAUNCH_TIMEOUT_MS + CONTEXT_CREATION_TIMEOUT_MS;
392
+
393
+ export function getHealthCheckBudgetMs(config?: Config): number {
394
+ const c = (config || getConfig()) as Partial<Config>;
395
+ // Each field is read defensively. A missing or non-finite value would otherwise
396
+ // propagate a NaN into `setTimeout`, which Node coerces to 1ms — turning the health
397
+ // probe's deadline into an instant failure. Falling back to the schema default is
398
+ // the only reading that cannot silently invert the bound.
399
+ const ms = (value: unknown, fallback: number): number =>
400
+ typeof value === 'number' && Number.isFinite(value) ? value : fallback;
401
+ const longestSlotHold =
402
+ Math.max(ms(c.SEARCH_TIMEOUT_MS, 45_000), ms(c.SCRAPE_TIMEOUT_MS, 15_000))
403
+ + ms(c.BROWSER_TASK_TIMEOUT_MS, 10_000)
404
+ + COLD_START_ALLOWANCE_MS;
405
+ // Mirrors the worker's own floor in executeHealthCheck.
406
+ const probeWork = HEALTHCHECK_NAV_ATTEMPTS * Math.max(10_000, ms(c.HEALTH_CHECK_TIMEOUT_MS, 10_000));
407
+ return Math.max(HEALTHCHECK_MIN_BUDGET_MS, longestSlotHold + probeWork);
408
+ }
@@ -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
 
@@ -18,7 +18,7 @@ import { getServiceContainer, getService } from '../../core/service-registry.ts'
18
18
  import type { ServiceContainer } from '../../core/service-registry.ts';
19
19
  import { ServiceNames } from '../../core/service-interfaces.ts';
20
20
  import type { ISchedulerFactory, IScheduler } from '../../core/scheduler-factory.ts';
21
- import type { ISchedulerInternals } from '../../core/interfaces/scheduler-interfaces.ts';
21
+ import type { ISchedulerInternals, TaskDispatchListener } from '../../core/interfaces/scheduler-interfaces.ts';
22
22
  import type { IStateManager } from '../../core/interfaces/state-manager-interfaces.ts';
23
23
  import { BrowserClient } from './browser-client.ts';
24
24
 
@@ -331,7 +331,7 @@ export async function runBrowserHealthCheck(config?: Config, retries = 1, signal
331
331
  /**
332
332
  * Run a worker search query with retry logic.
333
333
  */
334
- export async function runWorkerSearch(query: string, config?: Config, signal?: AbortSignal, retries = 1, sessionId?: string, container: ServiceContainer = getServiceContainer(), handoverRetries = LEADER_HANDOVER_RETRIES): Promise<SearchResult[]> {
334
+ export async function runWorkerSearch(query: string, config?: Config, signal?: AbortSignal, retries = 1, sessionId?: string, container: ServiceContainer = getServiceContainer(), handoverRetries = LEADER_HANDOVER_RETRIES, onTaskEvent?: TaskDispatchListener): Promise<SearchResult[]> {
335
335
  if (signal?.aborted) throw new Error('Aborted');
336
336
 
337
337
  const breaker = sessionId ? getBrowserCircuitBreaker(sessionId) : browserCircuitBreaker;
@@ -340,7 +340,7 @@ export async function runWorkerSearch(query: string, config?: Config, signal?: A
340
340
  return await breaker.execute(async () => {
341
341
  if (signal?.aborted) throw new Error('Aborted');
342
342
  const scheduler = await getScheduler(config, container);
343
- return await scheduler.runSearch(query, config, signal);
343
+ return await scheduler.runSearch(query, config, signal, onTaskEvent);
344
344
  });
345
345
  } catch (error: unknown) {
346
346
  if (signal?.aborted || (error instanceof Error && error.message === 'Aborted')) throw new Error('Aborted', { cause: error });
@@ -358,7 +358,7 @@ export async function runWorkerSearch(query: string, config?: Config, signal?: A
358
358
  logger.warn(`[BrowserManager] Leader handover during search (handover attempts left: ${handoverRetries}): ${briefErrorMessage(error)}...`);
359
359
  await recoverFromLeaderHandover(container);
360
360
  await new Promise((resolve) => setTimeout(resolve, HANDOVER_BACKOFF_MS + Math.floor(Math.random() * 400)));
361
- return runWorkerSearch(query, config, signal, retries, sessionId, container, handoverRetries - 1);
361
+ return runWorkerSearch(query, config, signal, retries, sessionId, container, handoverRetries - 1, onTaskEvent);
362
362
  }
363
363
 
364
364
  if (retries > 0 && isTransientSocketError(error) && !isTaskTimeoutError(error) && !isCloudflareBlockError(error)) {
@@ -381,7 +381,7 @@ export async function runWorkerSearch(query: string, config?: Config, signal?: A
381
381
 
382
382
  const jitter = 100 + Math.floor(Math.random() * 400);
383
383
  await new Promise(resolve => setTimeout(resolve, jitter));
384
- return runWorkerSearch(query, config, signal, retries - 1, sessionId, container, handoverRetries);
384
+ return runWorkerSearch(query, config, signal, retries - 1, sessionId, container, handoverRetries, onTaskEvent);
385
385
  }
386
386
  throw error;
387
387
  }