@lincoln504/pi-research 1.3.12 → 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 (71) hide show
  1. package/README.md +2 -2
  2. package/dist/cli.mjs +5495 -4645
  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 +94 -19
  8. package/docs/ARCHITECTURE.md +18 -10
  9. package/docs/CHANGELOG.md +149 -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 +12 -8
  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 +719 -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 +220 -34
  31. package/src/infrastructure/browser/config.ts +41 -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 +32 -2
  36. package/src/infrastructure/browser/thread-worker-messaging.ts +38 -6
  37. package/src/infrastructure/browser/thread-worker.mjs +94 -19
  38. package/src/infrastructure/browser/thread-worker.ts +2 -2
  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/file-lock-service.ts +214 -19
  43. package/src/infrastructure/knowledge-store-service.ts +14 -0
  44. package/src/infrastructure/research-run-semaphore.ts +1 -1
  45. package/src/infrastructure/service-initialization.ts +7 -0
  46. package/src/knowledge/embedder-init.ts +43 -17
  47. package/src/logger.ts +11 -0
  48. package/src/orchestration/deep-research-orchestrator.ts +141 -87
  49. package/src/orchestration/quick-research-orchestrator.ts +11 -2
  50. package/src/orchestration/research-orchestration-service.ts +8 -0
  51. package/src/orchestration/research-synthesis-service.ts +53 -1
  52. package/src/orchestration/researcher-executor.ts +53 -2
  53. package/src/orchestration/researcher.ts +15 -2
  54. package/src/prompts/research-tool-usage.md +1 -1
  55. package/src/prompts/researcher.md +1 -0
  56. package/src/prompts/system-lead-router.md +122 -0
  57. package/src/prompts/system-lead-synthesizer.md +46 -0
  58. package/src/tools/research-knowledge-search.ts +11 -3
  59. package/src/tools/scrape.ts +6 -0
  60. package/src/tools/search.ts +7 -0
  61. package/src/tools/security.ts +27 -9
  62. package/src/tools/stackexchange.ts +13 -7
  63. package/src/tools/youtube-transcript.ts +26 -12
  64. package/src/utils/cancellation.ts +36 -0
  65. package/src/utils/coverage-digest.ts +204 -0
  66. package/src/utils/llm-usage.ts +33 -6
  67. package/src/utils/metrics-summary.ts +20 -4
  68. package/src/utils/metrics.ts +27 -1
  69. package/src/web-research/browser-search.ts +93 -20
  70. package/dist/prompts/system-lead-evaluator.md +0 -118
  71. package/src/prompts/system-lead-evaluator.md +0 -118
@@ -259,15 +259,42 @@ async function extractSearchResults(page: any): Promise<any[]> {
259
259
  /**
260
260
  * Execute a search task
261
261
  */
262
+ /**
263
+ * Bind a task-timeout signal to the page it is bounding.
264
+ *
265
+ * `page.setDefaultTimeout` bounds Playwright's action and navigation verbs
266
+ * individually; it does not bound their sum, and it does not bound a Response read
267
+ * at all. Closing the page is what actually ends the work: any in-flight call
268
+ * rejects with 'Target closed', which runTask's catch already maps back to the
269
+ * task timeout. Fire-and-forget, `{ once }` so the listener cannot outlive the task.
270
+ */
271
+ function bindTaskAbortToPage(page: any, signal?: AbortSignal): void {
272
+ if (!signal) return;
273
+ const onAbort = () => { page.close().catch(() => {}); };
274
+ if (signal.aborted) onAbort();
275
+ else signal.addEventListener('abort', onAbort, { once: true });
276
+ }
277
+
262
278
  export async function executeSearchTask(
263
279
  _context: any,
264
- query: string
280
+ query: string,
281
+ signal?: AbortSignal
265
282
  ): Promise<{ results: any[]; jitter: number }> {
266
283
  const page = await createPageSafe(_context);
267
284
  const SEARCH_TIMEOUT = parseTimeoutMs(process.env['PI_RESEARCH_SEARCH_TIMEOUT_MS'], 45000);
268
285
  page.setDefaultTimeout(SEARCH_TIMEOUT);
269
286
  page.setDefaultNavigationTimeout(SEARCH_TIMEOUT);
270
287
 
288
+ // Same binding the scrape path has used all along, and for the same reason: the
289
+ // worker's task deadline was armed but its signal was only ever handed to
290
+ // executeScrapeTask, so on a search it fired into nothing. Playwright's own
291
+ // per-action timeouts still bound each verb, but the sum of them plus the jitter
292
+ // sleep can outlast the whole task budget — and poolifier keeps counting this node
293
+ // as executing until the worker replies, so the node stays pinned long after the
294
+ // caller was answered. Closing the page rejects whatever is in flight with
295
+ // 'Target closed', which runTask's catch already recognises as the timeout.
296
+ bindTaskAbortToPage(page, signal);
297
+
271
298
  try {
272
299
  logToDebugFile('DEBUG', `[Worker-${workerId}] Starting search for: ${query}`);
273
300
 
@@ -692,11 +719,15 @@ const HEALTH_FALLBACK_URL = 'https://example.com/';
692
719
  async function executeHealthCheckAttempt(
693
720
  _context: any,
694
721
  navTimeoutMs: number,
695
- url: string = HEALTH_PRIMARY_URL
722
+ url: string = HEALTH_PRIMARY_URL,
723
+ signal?: AbortSignal
696
724
  ): Promise<{ success: boolean; navMs: number }> {
697
725
  const page = await createPageSafe(_context);
698
726
  page.setDefaultTimeout(navTimeoutMs);
699
727
  page.setDefaultNavigationTimeout(navTimeoutMs);
728
+ // See executeSearchTask: the probe runs twice (primary then fallback), so its own
729
+ // nav timeout bounds each attempt but not the pair.
730
+ bindTaskAbortToPage(page, signal);
700
731
 
701
732
  try {
702
733
  const navStart = Date.now();
@@ -721,7 +752,8 @@ async function executeHealthCheckAttempt(
721
752
  */
722
753
  export async function executeHealthCheck(
723
754
  _context: any,
724
- initMs: number
755
+ initMs: number,
756
+ signal?: AbortSignal
725
757
  ): Promise<{ success: boolean; navMs: number }> {
726
758
  // Nav timeout: read from env (passed through getBrowserEnv), floor at 10s.
727
759
  // The outer BrowserTaskScheduler.runHealthCheck() has its own 45s hard deadline.
@@ -733,14 +765,14 @@ export async function executeHealthCheck(
733
765
  }
734
766
 
735
767
  try {
736
- return await executeHealthCheckAttempt(_context, HEALTH_TIMEOUT);
768
+ return await executeHealthCheckAttempt(_context, HEALTH_TIMEOUT, HEALTH_PRIMARY_URL, signal);
737
769
  } catch (firstError: unknown) {
738
770
  // One retry: if the first attempt fails, the page may have been in a bad state
739
771
  // (challenge redirect, partial load, transient network blip). A second attempt
740
772
  // on a fresh page helps distinguish a real outage from a one-off failure.
741
773
  logToDebugFile('WARN', `[Worker-${workerId}] Health check attempt 1 failed: ${firstError instanceof Error ? firstError.message : String(firstError)}. Retrying once...`);
742
774
  try {
743
- return await executeHealthCheckAttempt(_context, HEALTH_TIMEOUT);
775
+ return await executeHealthCheckAttempt(_context, HEALTH_TIMEOUT, HEALTH_PRIMARY_URL, signal);
744
776
  } catch (retryError: unknown) {
745
777
  const retryMsg = retryError instanceof Error ? retryError.message : String(retryError);
746
778
  // A TIMEOUT is left to propagate: upstream (isBusyPoolHealthFailure) already
@@ -756,7 +788,7 @@ export async function executeHealthCheck(
756
788
  // healthy enough to research (search has its own retries/fallbacks downstream).
757
789
  logToDebugFile('WARN', `[Worker-${workerId}] Search-provider health probe failed twice (${retryMsg}); verifying open-web reachability via fallback endpoint...`);
758
790
  try {
759
- const fallback = await executeHealthCheckAttempt(_context, HEALTH_TIMEOUT, HEALTH_FALLBACK_URL);
791
+ const fallback = await executeHealthCheckAttempt(_context, HEALTH_TIMEOUT, HEALTH_FALLBACK_URL, signal);
760
792
  logToDebugFile('WARN', `[Worker-${workerId}] Fallback endpoint reachable — open web is up, search provider is degraded. Proceeding with research.`);
761
793
  return fallback;
762
794
  } catch (fallbackError: unknown) {
@@ -1060,6 +1060,17 @@ var init_logger = __esm({
1060
1060
  timestamp,
1061
1061
  level,
1062
1062
  ...getLogContext(),
1063
+ // Every process writes to the SAME log file — up to three concurrent research
1064
+ // runs plus the elected browser-pool leader — and until this field existed
1065
+ // there was nothing to tell their lines apart. Reconstructing a lock
1066
+ // contention from the log then meant inferring process boundaries from
1067
+ // timing, which is guesswork: two runs a second apart interleave into what
1068
+ // reads like one process contradicting itself. Twelve bytes a line buys
1069
+ // attributable forensics.
1070
+ //
1071
+ // After the context spread, not before: which process wrote a line is not
1072
+ // something a scoped context gets to claim otherwise.
1073
+ pid: process.pid,
1063
1074
  message,
1064
1075
  ...firstError ? { errorMessage: redactSecrets(firstError.message), errorStack: redactSecrets(firstError.stack ?? "") } : {}
1065
1076
  };
@@ -1166,7 +1177,7 @@ var init_metrics = __esm({
1166
1177
  increment(name, value = 1, labels) {
1167
1178
  const key = this.getKey(name, labels);
1168
1179
  this.counters.set(key, (this.counters.get(key) ?? 0) + value);
1169
- if (name === "llm_cost_total" || name === "llm_tokens_total") {
1180
+ if (name.startsWith("llm_")) {
1170
1181
  logger.debug(`[Metrics] Incremented ${name} by ${value}${labels ? ` (labels: ${JSON.stringify(labels)})` : ""}`);
1171
1182
  }
1172
1183
  }
@@ -1270,6 +1281,28 @@ var init_metrics = __esm({
1270
1281
  async measure(name, action, labels) {
1271
1282
  return this.getActive().measure(name, action, labels);
1272
1283
  }
1284
+ /**
1285
+ * Emit API that ALWAYS targets the session registry, ignoring any active run
1286
+ * context.
1287
+ *
1288
+ * For process-lifetime callbacks that happen to be *registered* during a run.
1289
+ * AsyncLocalStorage resolves at emit time from the async resource that fired,
1290
+ * and for a listener installed on a long-lived emitter that resource is the
1291
+ * emitter itself — created, for every lazily-initialized subsystem here, inside
1292
+ * whichever run first needed it. So a worker-pool error handler installed during
1293
+ * run 1 keeps writing into run 1's registry for the rest of the process: those
1294
+ * counts disappear from every later run's summary (the registry was discarded
1295
+ * when the run ended) and the dead registry stays reachable through the closure.
1296
+ *
1297
+ * Use this only where the emission is genuinely process-scoped. Anything that
1298
+ * describes work a run asked for belongs in the run registry, which is what the
1299
+ * plain emit methods above give you.
1300
+ */
1301
+ session = {
1302
+ increment: (name, value = 1, labels) => this._session.increment(name, value, labels),
1303
+ setGauge: (name, value, labels) => this._session.setGauge(name, value, labels),
1304
+ observe: (name, value, labels) => this._session.observe(name, value, labels)
1305
+ };
1273
1306
  // ── Session-level read API ─────────────────────────────────────────────
1274
1307
  /** Snapshot of the session registry (infrastructure / cross-run metrics). */
1275
1308
  getSessionSnapshot() {
@@ -2170,11 +2203,21 @@ async function extractSearchResults(page) {
2170
2203
  return found;
2171
2204
  });
2172
2205
  }
2173
- async function executeSearchTask(_context, query) {
2206
+ function bindTaskAbortToPage(page, signal) {
2207
+ if (!signal) return;
2208
+ const onAbort = () => {
2209
+ page.close().catch(() => {
2210
+ });
2211
+ };
2212
+ if (signal.aborted) onAbort();
2213
+ else signal.addEventListener("abort", onAbort, { once: true });
2214
+ }
2215
+ async function executeSearchTask(_context, query, signal) {
2174
2216
  const page = await createPageSafe(_context);
2175
2217
  const SEARCH_TIMEOUT = parseTimeoutMs(process.env["PI_RESEARCH_SEARCH_TIMEOUT_MS"], 45e3);
2176
2218
  page.setDefaultTimeout(SEARCH_TIMEOUT);
2177
2219
  page.setDefaultNavigationTimeout(SEARCH_TIMEOUT);
2220
+ bindTaskAbortToPage(page, signal);
2178
2221
  try {
2179
2222
  logToDebugFile2("DEBUG", `[Worker-${workerId2}] Starting search for: ${query}`);
2180
2223
  if (process.env["PI_RESEARCH_MOCK_SEARCH"] === "true") {
@@ -2387,10 +2430,11 @@ async function executeScrapeTask(_context, url, signal) {
2387
2430
  }
2388
2431
  var HEALTH_PRIMARY_URL = "https://lite.duckduckgo.com/lite/";
2389
2432
  var HEALTH_FALLBACK_URL = "https://example.com/";
2390
- async function executeHealthCheckAttempt(_context, navTimeoutMs, url = HEALTH_PRIMARY_URL) {
2433
+ async function executeHealthCheckAttempt(_context, navTimeoutMs, url = HEALTH_PRIMARY_URL, signal) {
2391
2434
  const page = await createPageSafe(_context);
2392
2435
  page.setDefaultTimeout(navTimeoutMs);
2393
2436
  page.setDefaultNavigationTimeout(navTimeoutMs);
2437
+ bindTaskAbortToPage(page, signal);
2394
2438
  try {
2395
2439
  const navStart = Date.now();
2396
2440
  await page.goto(url, { waitUntil: "domcontentloaded" });
@@ -2406,25 +2450,25 @@ async function executeHealthCheckAttempt(_context, navTimeoutMs, url = HEALTH_PR
2406
2450
  throw error;
2407
2451
  }
2408
2452
  }
2409
- async function executeHealthCheck(_context, initMs) {
2453
+ async function executeHealthCheck(_context, initMs, signal) {
2410
2454
  const configuredMs = parseTimeoutMs(process.env["PI_RESEARCH_HEALTH_CHECK_TIMEOUT_MS"], 0);
2411
2455
  const HEALTH_TIMEOUT = configuredMs > 0 ? Math.max(1e4, configuredMs) : 1e4;
2412
2456
  if (initMs > 3e3) {
2413
2457
  logToDebugFile2("WARN", `[Worker-${workerId2}] Browser init was slow: ${initMs}ms \u2014 leaving less headroom for DDG navigation.`);
2414
2458
  }
2415
2459
  try {
2416
- return await executeHealthCheckAttempt(_context, HEALTH_TIMEOUT);
2460
+ return await executeHealthCheckAttempt(_context, HEALTH_TIMEOUT, HEALTH_PRIMARY_URL, signal);
2417
2461
  } catch (firstError) {
2418
2462
  logToDebugFile2("WARN", `[Worker-${workerId2}] Health check attempt 1 failed: ${firstError instanceof Error ? firstError.message : String(firstError)}. Retrying once...`);
2419
2463
  try {
2420
- return await executeHealthCheckAttempt(_context, HEALTH_TIMEOUT);
2464
+ return await executeHealthCheckAttempt(_context, HEALTH_TIMEOUT, HEALTH_PRIMARY_URL, signal);
2421
2465
  } catch (retryError) {
2422
2466
  const retryMsg = retryError instanceof Error ? retryError.message : String(retryError);
2423
2467
  const isTimeout = /tim(e|ed)\s*out|timeout/i.test(retryMsg);
2424
2468
  if (!isTimeout) {
2425
2469
  logToDebugFile2("WARN", `[Worker-${workerId2}] Search-provider health probe failed twice (${retryMsg}); verifying open-web reachability via fallback endpoint...`);
2426
2470
  try {
2427
- const fallback = await executeHealthCheckAttempt(_context, HEALTH_TIMEOUT, HEALTH_FALLBACK_URL);
2471
+ const fallback = await executeHealthCheckAttempt(_context, HEALTH_TIMEOUT, HEALTH_FALLBACK_URL, signal);
2428
2472
  logToDebugFile2("WARN", `[Worker-${workerId2}] Fallback endpoint reachable \u2014 open web is up, search provider is degraded. Proceeding with research.`);
2429
2473
  return fallback;
2430
2474
  } catch (fallbackError) {
@@ -2528,7 +2572,7 @@ var ConfigSchema = Type.Object({
2528
2572
  * never killed before it has used its full nav budget plus time waiting in the queue.
2529
2573
  * (default: 10000) */
2530
2574
  BROWSER_TASK_TIMEOUT_MS: Type.Number({ minimum: 2e3, maximum: 12e4, default: 1e4 }),
2531
- /** Timeout for coordinator/evaluator/repair/knowledge LLM calls in ms (default: 300000 = 5 min, range: 60s-30min).
2575
+ /** Timeout for coordinator/research-lead/repair/knowledge LLM calls in ms (default: 300000 = 5 min, range: 60s-30min).
2532
2576
  * Not exposed in TUI — controlled via PI_RESEARCH_LLM_TIMEOUT_MS env var.
2533
2577
  * Ceiling raised to 30min (matching RESEARCHER_TIMEOUT_MS): a slow model on a
2534
2578
  * large-context synthesis/plan call can legitimately run >10min, and the old
@@ -2536,7 +2580,7 @@ var ConfigSchema = Type.Object({
2536
2580
  * (e.g. 900000), causing those calls to time out mid-generation. */
2537
2581
  LLM_TIMEOUT_MS: Type.Number({ minimum: 6e4, maximum: 18e5, default: 3e5 }),
2538
2582
  /** Chain-of-thought "thinking" level for the engine's own LLM calls (coordinator,
2539
- * evaluator, synthesis, JSON-repair, knowledge extraction) AND the researcher
2583
+ * router, synthesizer, JSON-repair, knowledge extraction) AND the researcher
2540
2584
  * sub-agents. Default 'off': these calls emit structured JSON / cited reports, not
2541
2585
  * open-ended reasoning, so thinking only burns the output-token budget (often
2542
2586
  * truncating before the JSON/report block) and adds latency for at most a marginal
@@ -2551,22 +2595,26 @@ var ConfigSchema = Type.Object({
2551
2595
  Type.Literal("medium"),
2552
2596
  Type.Literal("high")
2553
2597
  ], { default: "off" }),
2554
- /** Max output tokens for the coordinator plan + mid-round evaluator decision (and,
2555
- * when the evaluator synthesizes mid-round, the report). Default 16384. The blanket
2598
+ /** Max output tokens for the coordinator's round-1 plan. Default 16384. The routing
2599
+ * decision has its OWN, much smaller ceiling (it emits a decision, never a report,
2600
+ * and cannot synthesize mid-round under the split protocol); the final report uses
2601
+ * SYNTHESIS_MAX_TOKENS below. The blanket
2556
2602
  * 4096 cap previously throttled every call and could be exhausted by a thinking block
2557
2603
  * before any text was emitted. Capped at the model's real ceiling at call time.
2558
2604
  * Not exposed in TUI — controlled via PI_RESEARCH_PLANNING_MAX_TOKENS env var. */
2559
2605
  PLANNING_MAX_TOKENS: Type.Number({ minimum: 1024, maximum: 131072, default: 16384 }),
2560
- /** Max output tokens for the final synthesized research report (the forced-synthesis
2561
- * evaluator call). Default 32768 so a full cited report is never truncated. Capped at
2606
+ /** Max output tokens for the final synthesized research report (the synthesizer call,
2607
+ * and each pass of a reduce when the corpus does not fit in one). Default 32768 so a
2608
+ * full cited report is never truncated. It is also the reserve subtracted from the
2609
+ * model's context window when budgeting that corpus. Capped at
2562
2610
  * the model's real maxTokens at call time.
2563
2611
  * Not exposed in TUI — controlled via PI_RESEARCH_SYNTHESIS_MAX_TOKENS env var. */
2564
2612
  SYNTHESIS_MAX_TOKENS: Type.Number({ minimum: 1024, maximum: 131072, default: 32768 }),
2565
2613
  /** LLM Model override for researcher sub-agents and knowledge synthesis.
2566
2614
  * Format: provider/model-id (e.g. google/gemini-2.0-flash-001) or just model-id.
2567
2615
  * When set, this overrides ctx.model for researcher sub-agents (both deep and quick)
2568
- * and the knowledge synthesis background LLM. The coordinator and evaluator always
2569
- * use the caller's model (ctx.model).
2616
+ * and the knowledge synthesis background LLM. The coordinator and the research lead
2617
+ * always use the caller's model (ctx.model).
2570
2618
  */
2571
2619
  RESEARCH_MODEL: Type.Optional(Type.String()),
2572
2620
  /** Explicit directory for the knowledge store database (overrides default) */
@@ -2620,6 +2668,7 @@ function resolveHeadlessMode() {
2620
2668
  if (process.env["PI_RESEARCH_USE_XVFB"] === "true") return "virtual";
2621
2669
  return true;
2622
2670
  }
2671
+ var HEALTHCHECK_MIN_BUDGET_MS = 45e3 + 6e4;
2623
2672
 
2624
2673
  // src/infrastructure/browser/thread-worker-browser.ts
2625
2674
  var browser = null;
@@ -2734,11 +2783,24 @@ async function initBrowser() {
2734
2783
  });
2735
2784
  launchPromise.catch((err) => logToDebugFile3("DEBUG", `[ThreadWorker] Background browser launch rejection: ${err.message}`));
2736
2785
  let launchTimeoutId;
2786
+ let raceLost = false;
2787
+ launchPromise.then((b) => {
2788
+ if (!raceLost) return;
2789
+ logToDebugFile3("WARN", `[Worker-${workerId3}] Browser launch completed after its deadline; closing the orphaned instance.`);
2790
+ try {
2791
+ b?.close?.();
2792
+ } catch {
2793
+ }
2794
+ }).catch(() => {
2795
+ });
2737
2796
  try {
2738
2797
  return await Promise.race([
2739
2798
  launchPromise,
2740
2799
  new Promise((_, reject) => {
2741
- launchTimeoutId = setTimeout(() => reject(new Error(`Browser launch timed out after ${launchTimeoutMs}ms`)), launchTimeoutMs);
2800
+ launchTimeoutId = setTimeout(() => {
2801
+ raceLost = true;
2802
+ reject(new Error(`Browser launch timed out after ${launchTimeoutMs}ms`));
2803
+ }, launchTimeoutMs);
2742
2804
  })
2743
2805
  ]);
2744
2806
  } finally {
@@ -2763,10 +2825,23 @@ async function initBrowser() {
2763
2825
  const contextPromise = browser.newContext();
2764
2826
  contextPromise.catch((err) => logToDebugFile3("DEBUG", `[ThreadWorker] Background browser context rejection: ${err.message}`));
2765
2827
  let contextTimeoutId;
2828
+ let contextRaceLost = false;
2829
+ contextPromise.then((c) => {
2830
+ if (!contextRaceLost) return;
2831
+ logToDebugFile3("WARN", `[Worker-${workerId3}] Browser context creation completed after its deadline; closing the orphaned context.`);
2832
+ try {
2833
+ c?.close?.();
2834
+ } catch {
2835
+ }
2836
+ }).catch(() => {
2837
+ });
2766
2838
  context = await Promise.race([
2767
2839
  contextPromise,
2768
2840
  new Promise((_, reject) => {
2769
- contextTimeoutId = setTimeout(() => reject(new Error("Browser context creation timed out after 30000ms")), 3e4);
2841
+ contextTimeoutId = setTimeout(() => {
2842
+ contextRaceLost = true;
2843
+ reject(new Error("Browser context creation timed out after 30000ms"));
2844
+ }, 3e4);
2770
2845
  })
2771
2846
  ]);
2772
2847
  if (contextTimeoutId) clearTimeout(contextTimeoutId);
@@ -2882,14 +2957,14 @@ async function runTask(data) {
2882
2957
  let result;
2883
2958
  if (type === "search") {
2884
2959
  if (!query) throw new Error("Search task requires a query");
2885
- const searchResult = await executeSearchTask(getContext(), query);
2960
+ const searchResult = await executeSearchTask(getContext(), query, abortController.signal);
2886
2961
  result = { results: searchResult.results, duration: Date.now() - startTime, jitter: searchResult.jitter };
2887
2962
  } else if (type === "scrape") {
2888
2963
  if (!url) throw new Error("Scrape task requires a URL");
2889
2964
  const scrapeResult = await executeScrapeTask(getContext(), url, abortController.signal);
2890
2965
  result = { ...scrapeResult, duration: Date.now() - startTime };
2891
2966
  } else if (type === "healthcheck") {
2892
- const healthResult = await executeHealthCheck(getContext(), initMs);
2967
+ const healthResult = await executeHealthCheck(getContext(), initMs, abortController.signal);
2893
2968
  result = { ...healthResult, duration: Date.now() - startTime };
2894
2969
  } else {
2895
2970
  result = { error: "Unknown task type", duration: Date.now() - startTime };
@@ -116,14 +116,14 @@ async function runTask(data: TaskData | undefined): Promise<TaskResult> {
116
116
  let result: any;
117
117
  if (type === 'search') {
118
118
  if (!query) throw new Error('Search task requires a query');
119
- const searchResult = await executeSearchTask(getContext(), query);
119
+ const searchResult = await executeSearchTask(getContext(), query, abortController.signal);
120
120
  result = { results: searchResult.results, duration: Date.now() - startTime, jitter: searchResult.jitter };
121
121
  } else if (type === 'scrape') {
122
122
  if (!url) throw new Error('Scrape task requires a URL');
123
123
  const scrapeResult = await executeScrapeTask(getContext(), url, abortController.signal);
124
124
  result = { ...scrapeResult, duration: Date.now() - startTime };
125
125
  } else if (type === 'healthcheck') {
126
- const healthResult = await executeHealthCheck(getContext(), initMs);
126
+ const healthResult = await executeHealthCheck(getContext(), initMs, abortController.signal);
127
127
  result = { ...healthResult, duration: Date.now() - startTime };
128
128
  } else {
129
129
  result = { error: 'Unknown task type', duration: Date.now() - startTime };
@@ -28,6 +28,55 @@ const __dirname = dirname(__filename);
28
28
  /**
29
29
  * Worker pool manager for browser operations.
30
30
  */
31
+ /**
32
+ * The pool's dispatch shape: how a worker is chosen, and how much each may hold.
33
+ *
34
+ * Separated out so it is testable. The claim this configuration makes is behavioural
35
+ * — that no task is parked behind a busy worker while another idles — and the first
36
+ * test written for it asserted that this file contained the string
37
+ * "WorkerChoiceStrategies.LEAST_USED", which passes against an implementation that
38
+ * dispatches nothing.
39
+ *
40
+ * LEAST_USED picks the node minimising executing + queued. ROUND_ROBIN is a bare
41
+ * modular cursor whose only filter is a one-time `ready` flag — it cannot see load,
42
+ * and poolifier consults per-node capacity only AFTER the strategy has chosen. A task
43
+ * handed to a busy node is queued ON that node and never re-routed, so one degraded
44
+ * worker takes a share of every burst with it: on 2026-08-15 a worker whose browser
45
+ * launch hung was still handed roughly one turn in six, and the run lost exactly the
46
+ * three queries that landed on it while five workers sat idle.
47
+ *
48
+ * The guarantee is conditional, not absolute, and worth stating precisely: a
49
+ * load-reading strategy never parks a task WHILE A FREE NODE EXISTS. The
50
+ * PriorityTaskQueue above normally makes sure one does, by capping in-flight work at
51
+ * exactly WORKER_THREADS x WORKER_CONCURRENCY — which is why a WORKER_CONCURRENCY
52
+ * change has to rebuild the pool, or the two layers disagree about capacity. But that
53
+ * cap is released early when a caller aborts a task still running on a worker, and a
54
+ * respawning worker is not `ready`, so there are windows where every node is genuinely
55
+ * full. In those, ties resolve to the lowest-index node and a task does park.
56
+ *
57
+ * Which is also why poolifier's stolen-task accounting defect is made rare rather than
58
+ * unreachable: stealing fires only when something is queued on a node, and a stolen
59
+ * task's completion decrements the ORIGINAL node's executing count instead of the one
60
+ * that ran it, freeing a slot on a worker that is still working. That produced 1,425
61
+ * genuinely overlapping task pairs across 37 days at a configured concurrency of 1.
62
+ *
63
+ * LEAST_BUSY was the other candidate and is worse here: it minimises accumulated
64
+ * run+wait time, which turns on per-task performance measurement for every task and
65
+ * still lets a busy fast worker outrank an idle slow one. LEAST_USED overrides no
66
+ * statistics requirements, so it costs nothing extra.
67
+ */
68
+ export function buildWorkerPoolOptions(config: Config): {
69
+ workerChoiceStrategy: typeof WorkerChoiceStrategies[keyof typeof WorkerChoiceStrategies];
70
+ enableTasksQueue: boolean;
71
+ tasksQueueOptions: { concurrency: number };
72
+ } {
73
+ return {
74
+ workerChoiceStrategy: WorkerChoiceStrategies.LEAST_USED,
75
+ enableTasksQueue: true,
76
+ tasksQueueOptions: { concurrency: config.WORKER_CONCURRENCY },
77
+ };
78
+ }
79
+
31
80
  export class WorkerPoolManager implements IService {
32
81
  readonly name = ServiceNames.WORKER_POOL_MANAGER;
33
82
  lifecycle = ServiceLifecycle.UNINITIALIZED;
@@ -35,6 +84,12 @@ export class WorkerPoolManager implements IService {
35
84
  private pool: any | null = null;
36
85
  private poolInitializationPromise: Promise<any> | null = null;
37
86
  private currentWorkerCount: number | null = null;
87
+ /** Per-worker task concurrency the LIVE pool was built with. Baked into
88
+ * poolifier's tasksQueueOptions at construction and not changeable after, so a
89
+ * change has to recreate the pool — the upper PriorityTaskQueue recomputes its
90
+ * own capacity from the same two values on every task, and the two disagreeing
91
+ * is what lets a task be queued on a worker that the queue believes is free. */
92
+ private currentWorkerConcurrency: number | null = null;
38
93
  private consecutiveErrors: number = 0;
39
94
  private isShuttingDown: boolean = false;
40
95
  // Monotonic shutdown generation. Each shutdown() bumps it, so an in-flight
@@ -95,7 +150,8 @@ export class WorkerPoolManager implements IService {
95
150
 
96
151
  // If pool exists, worker count matches, and we are not shutting down,
97
152
  // return the existing pool immediately.
98
- if (this.pool && this.currentWorkerCount === maxWorkers) {
153
+ const workerConcurrency = (config || getConfig()).WORKER_CONCURRENCY;
154
+ if (this.pool && this.currentWorkerCount === maxWorkers && this.currentWorkerConcurrency === workerConcurrency) {
99
155
  return this.pool;
100
156
  }
101
157
 
@@ -110,14 +166,15 @@ export class WorkerPoolManager implements IService {
110
166
  const myGeneration = this.generation;
111
167
  this.poolInitializationPromise = (async () => {
112
168
  try {
113
- if (this.pool && this.currentWorkerCount !== maxWorkers) {
114
- logger.log(`[WorkerPoolManager] Worker count changed from ${this.currentWorkerCount} to ${maxWorkers}, recreating pool...`);
169
+ if (this.pool && (this.currentWorkerCount !== maxWorkers || this.currentWorkerConcurrency !== workerConcurrency)) {
170
+ logger.log(`[WorkerPoolManager] Pool shape changed (workers ${this.currentWorkerCount} -> ${maxWorkers}, concurrency ${this.currentWorkerConcurrency} -> ${workerConcurrency}), recreating pool...`);
115
171
  this.poolEpoch++; // intentional destroy: silence the dying pool's exit events
116
172
  await this.pool.destroy();
117
173
  this.pool = null;
118
174
  }
119
175
 
120
176
  this.currentWorkerCount = maxWorkers;
177
+ this.currentWorkerConcurrency = workerConcurrency;
121
178
 
122
179
  // Install the master-side IPC guard BEFORE any worker is forked, so a
123
180
  // `write EPIPE` on a closing worker channel (poolifier dispatch racing a
@@ -161,7 +218,6 @@ export class WorkerPoolManager implements IService {
161
218
  void cleanupOrphanedCamoufoxProcesses()
162
219
  .catch((e) => logger.debug('[WorkerPoolManager] Startup orphan sweep failed:', e));
163
220
 
164
- const workerConcurrency = (config || getConfig()).WORKER_CONCURRENCY;
165
221
 
166
222
  // thread-worker.mjs is the esbuild-compiled bundle of thread-worker.ts and its
167
223
  // local imports. Using a pre-compiled JS file eliminates any TypeScript loading
@@ -205,10 +261,15 @@ export class WorkerPoolManager implements IService {
205
261
  return;
206
262
  }
207
263
  this.consecutiveErrors++;
208
- metrics.increment('browser_pool_errors_total', 1);
264
+ // Session-scoped on purpose. This handler is installed on the pool,
265
+ // and the pool is built lazily inside whichever research run first
266
+ // needed a browser — so the run-registry context every later
267
+ // invocation inherits is that FIRST run's, long after it was
268
+ // discarded. Pool health is process state, not run accounting.
269
+ metrics.session.increment('browser_pool_errors_total', 1);
209
270
  logger.error('[WorkerPoolManager] Cluster Error:', e);
210
271
  if (this.consecutiveErrors >= 3) {
211
- metrics.increment('browser_pool_unhealthy_events_total', 1);
272
+ metrics.session.increment('browser_pool_unhealthy_events_total', 1);
212
273
  logger.error(`[WorkerPoolManager] Worker pool may be unhealthy: ${this.consecutiveErrors} consecutive errors. Consider restarting.`);
213
274
  if (this.onPoolError) {
214
275
  this.onPoolError(e, this.consecutiveErrors);
@@ -235,11 +296,7 @@ export class WorkerPoolManager implements IService {
235
296
  }
236
297
  }
237
298
  },
238
- workerChoiceStrategy: WorkerChoiceStrategies.ROUND_ROBIN,
239
- enableTasksQueue: true,
240
- tasksQueueOptions: {
241
- concurrency: workerConcurrency, // configurable via PI_RESEARCH_WORKER_CONCURRENCY
242
- }
299
+ ...buildWorkerPoolOptions(config || getConfig()),
243
300
  });
244
301
 
245
302
  // Secondary race check: if a shutdown() was called concurrent with
@@ -315,6 +372,60 @@ export class WorkerPoolManager implements IService {
315
372
  if (this.consecutiveErrors > 0) this.consecutiveErrors--;
316
373
  }
317
374
 
375
+ /**
376
+ * Record how the pool is actually distributing work.
377
+ *
378
+ * The head-of-line failure this pool used to have was invisible to every metric
379
+ * and every log line: tasks parked on one node while other nodes idled, and the
380
+ * only trace was a PriorityTaskQueue reporting free slots and an empty backlog
381
+ * while queries timed out. poolifier computes all of it on `pool.info` and
382
+ * nothing read it.
383
+ *
384
+ * `browser_pool_parked_tasks` is the number that should stay at zero: a task
385
+ * queued on a worker node while some other node is idle. Non-zero means the
386
+ * choice strategy handed work to a busy node when a free one existed — which is
387
+ * also the precondition for poolifier's task stealing, and therefore for its
388
+ * stolen-task accounting defect. `stolenTasks` is recorded for the same reason.
389
+ *
390
+ * Two limits, stated because a gauge that reads zero is otherwise taken as proof:
391
+ * `idleWorkerNodes` counts nodes that are READY and free, so during a respawn
392
+ * window a genuinely parked task is attributed to legitimate saturation; and with
393
+ * every node busy the figure is zero by construction, which is correct but is not
394
+ * evidence the strategy is choosing well. `browser_pool_idle_workers` is exported
395
+ * alongside so the denominator is visible rather than inferred.
396
+ *
397
+ * With no pool the gauges are ZEROED rather than left alone. Returning early held
398
+ * the last live reading forever — so a burst that ended with tasks parked left a
399
+ * standing non-zero gauge for a pool that no longer exists, and every later read
400
+ * of it was a report about a dead process.
401
+ *
402
+ * Session-scoped, for the same reason as the pool's error counters above. The only
403
+ * caller is the leader's 5s leadership tick — a self-rescheduling timer chain
404
+ * started when this process won the election, i.e. inside whichever research run
405
+ * first needed a browser. AsyncLocalStorage resolves at emit time from the async
406
+ * resource that fired, so a plain `metrics.setGauge` here would write into that
407
+ * FIRST run's registry for the life of the process: invisible to every later run's
408
+ * summary, and holding a discarded registry reachable through the timer. The pool's
409
+ * dispatch shape is process state, not run accounting.
410
+ */
411
+ recordDispatchHealth(): void {
412
+ const info = (this.pool as unknown as { info?: Record<string, number> } | null)?.info;
413
+ if (!info) {
414
+ metrics.session.setGauge('browser_pool_parked_tasks', 0);
415
+ metrics.session.setGauge('browser_pool_queued_tasks', 0);
416
+ metrics.session.setGauge('browser_pool_idle_workers', 0);
417
+ return;
418
+ }
419
+ const queued = info['queuedTasks'] ?? 0;
420
+ const idle = info['idleWorkerNodes'] ?? 0;
421
+ metrics.session.setGauge('browser_pool_parked_tasks', idle > 0 ? queued : 0);
422
+ metrics.session.setGauge('browser_pool_queued_tasks', queued);
423
+ metrics.session.setGauge('browser_pool_idle_workers', idle);
424
+ // Cumulative, not a level: never zeroed on teardown, or a restart would read
425
+ // as the defect having been fixed.
426
+ metrics.session.setGauge('browser_pool_stolen_tasks', info['stolenTasks'] ?? 0);
427
+ }
428
+
318
429
  /**
319
430
  * Schedule an out-of-band pool reset. Called from poolifier event handlers
320
431
  * where destroying the pool synchronously would deadlock. Waits 1 s so the
@@ -334,12 +445,15 @@ export class WorkerPoolManager implements IService {
334
445
  // is still being destroyed. Instead, use a flag to signal reset is in progress.
335
446
  this._resetInProgress = true;
336
447
  this.currentWorkerCount = null;
448
+ this.currentWorkerConcurrency = null;
337
449
  this.consecutiveErrors = 0;
338
450
  // Pin the generation this reset belongs to. shutdown() bumps generation, so if a
339
451
  // shutdown (and a subsequent re-init) happens while this timer is pending, the
340
452
  // generation no longer matches and the timer must NOT touch the new pool's state.
341
453
  const myGen = this.generation;
342
- metrics.increment('browser_pool_auto_recoveries_total', 1);
454
+ // Session-scoped: every caller of schedulePoolReset is one of the pool's own
455
+ // lifecycle handlers, which carry the async context of pool construction.
456
+ metrics.session.increment('browser_pool_auto_recoveries_total', 1);
343
457
  logger.info('[WorkerPoolManager] Pool scheduled for auto-recovery; next ensurePool() will wait for old pool destruction.');
344
458
  // Destroy the old pool asynchronously after the event handler returns.
345
459
  const t = setTimeout(async () => {
@@ -427,6 +541,7 @@ export class WorkerPoolManager implements IService {
427
541
 
428
542
  this.poolInitializationPromise = null;
429
543
  this.currentWorkerCount = null;
544
+ this.currentWorkerConcurrency = null;
430
545
  this.consecutiveErrors = 0;
431
546
  // Clear any in-flight auto-recovery flag too: if a schedulePoolReset() timer
432
547
  // was pending when shutdown ran, leaving this true makes the next ensurePool()
@@ -72,8 +72,19 @@ export class EmbeddingClient implements IEmbedder {
72
72
 
73
73
  // ---- Health ----
74
74
 
75
- async fetchHealth(): Promise<void> {
76
- const result = await this.getRequest<{ status: string; device: string; dimension: number | null }>('/health');
75
+ /**
76
+ * `timeoutMs` defaults to the general-purpose 120s used for real embed requests, but the
77
+ * embedding-leader poll loop (embedding-factory.ts) needs a much shorter deadline: it uses
78
+ * a failed health check as one "unreachable poll" toward declaring a leader's registration
79
+ * stale, on the documented assumption that each poll costs "the poll interval plus up to
80
+ * the [probe] timeout" (~2.5s). At the 120s default that assumption is off by 48x — a
81
+ * leader whose event loop is merely busy (not dead) can take the full 120s to answer, so
82
+ * the poll loop's outer wait times out and retries long before its own unreachable-poll
83
+ * counter would ever fire. Callers that need the fast-fail poll semantics must pass an
84
+ * explicit short timeout.
85
+ */
86
+ async fetchHealth(timeoutMs?: number): Promise<void> {
87
+ const result = await this.getRequest<{ status: string; device: string; dimension: number | null }>('/health', timeoutMs);
77
88
  this._device = result.device;
78
89
  if (result.dimension !== null) {
79
90
  this.dimension = result.dimension;
@@ -82,9 +93,8 @@ export class EmbeddingClient implements IEmbedder {
82
93
 
83
94
  // ---- Internal HTTP helpers ----
84
95
 
85
- private getRequest<T>(path: string): Promise<T> {
96
+ private getRequest<T>(path: string, timeoutMs = 120_000): Promise<T> {
86
97
  return new Promise((resolve, reject) => {
87
- const timeoutMs = 120_000;
88
98
  let resolved = false;
89
99
  let req: http.ClientRequest | undefined;
90
100
  const timer = setTimeout(() => {