@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
@@ -11,21 +11,23 @@
11
11
  import { platform } from 'node:os';
12
12
  import { setupMocking } from './thread-worker-messaging.ts';
13
13
  import { redactSecrets } from '../../utils/log-utils.ts';
14
- import { resolveHeadlessMode } from './config.ts';
14
+ import { resolveHeadlessMode, BROWSER_LAUNCH_TIMEOUT_MS, CONTEXT_CREATION_TIMEOUT_MS } from './config.ts';
15
15
 
16
16
  let browser: any = null;
17
- let context: any = null;
18
17
  let initPromise: Promise<void> | null = null;
19
18
  let workerId: string = '';
20
19
 
21
20
  // How many tasks this worker process currently has in flight. poolifier's
22
21
  // tasksQueueOptions.concurrency (WORKER_CONCURRENCY, default 2 — see
23
22
  // worker-pool-manager.ts) dispatches more than one task at a time to the SAME
24
- // worker process, and every dispatched task shares this module's browser/
25
- // context singletons (each gets its own page via createPageSafe(context), but
26
- // the context/browser themselves are one shared instance). resetBrowser()
27
- // uses this to avoid tearing that shared instance down out from under a
28
- // sibling task that is still actively using it — see resetBrowser() below.
23
+ // worker process, and every dispatched task shares this module's browser
24
+ // singleton (each gets its own FRESH BrowserContext via acquireTaskContext()
25
+ // below — a real Firefox process is too expensive to launch per task, but a
26
+ // context is cheap, and per-task contexts keep cookies/storage/route-mocking
27
+ // from one task bleeding into a concurrent or later, unrelated one).
28
+ // resetBrowser() uses this to avoid tearing the shared BROWSER down out from
29
+ // under a sibling task that is still actively using it — see resetBrowser()
30
+ // below.
29
31
  let activeTaskCount = 0;
30
32
  // A reset that resetBrowser() deferred because sibling tasks were still
31
33
  // active, to be applied once the last one finishes.
@@ -119,12 +121,12 @@ function logToDebugFile(level: string, ...args: any[]): void {
119
121
  * Initialize the browser instance
120
122
  */
121
123
  export async function initBrowser(): Promise<void> {
122
- if (isBrowserConnected() && context) return;
124
+ if (isBrowserConnected()) return;
123
125
  if (initPromise) return initPromise;
124
126
 
125
127
  initPromise = (async () => {
126
128
  try {
127
- if (!isBrowserConnected() || !context) {
129
+ if (!isBrowserConnected()) {
128
130
  logToDebugFile('INFO', `[Worker-${workerId}] Initializing browser instance...`);
129
131
 
130
132
  // ---------------------------------------------------------------------
@@ -196,8 +198,12 @@ export async function initBrowser(): Promise<void> {
196
198
  //
197
199
  // 90s launch timeout: CI runners (2 vCPU) need up to 60s to start Firefox
198
200
  // when it has both CPUs. The old 45s limit was too tight and caused silent
199
- // init failures under resource pressure.
200
- const launchTimeoutMs = 90000;
201
+ // init failures under resource pressure. Imported from config.ts (single
202
+ // source of truth) rather than a local literal — the scheduler's task
203
+ // deadlines (browser-task-scheduler.ts) and the healthcheck budget
204
+ // (getHealthCheckBudgetMs) both have to budget for this exact worst case,
205
+ // via COLD_START_ALLOWANCE_MS.
206
+ const launchTimeoutMs = BROWSER_LAUNCH_TIMEOUT_MS;
201
207
  const launchOnce = async (headless: boolean | 'virtual') => {
202
208
  const launchPromise = Camoufox({
203
209
  headless,
@@ -244,11 +250,28 @@ export async function initBrowser(): Promise<void> {
244
250
  // timeout races ahead.
245
251
  launchPromise.catch((err: Error) => logToDebugFile('DEBUG', `[ThreadWorker] Background browser launch rejection: ${err.message}`));
246
252
  let launchTimeoutId: NodeJS.Timeout | undefined;
253
+ let raceLost = false;
254
+ // If the launch resolves AFTER we gave up on it, nobody holds the result:
255
+ // `browser` is assigned only from the race's winner, so the catch below —
256
+ // whose comment promises to "close any partially-launched browser" — finds
257
+ // null and closes nothing. Camoufox's own launch bound is 180s against our
258
+ // 90s, so a launch completing in that window leaves a fully-started headless
259
+ // Firefox with no owner, surviving the orphan sweep (its parent worker is
260
+ // alive) until the pool shuts down. Adopt and close it, exactly as
261
+ // createPageSafe already does for an orphaned page.
262
+ launchPromise.then((b: any) => {
263
+ if (!raceLost) return;
264
+ logToDebugFile('WARN', `[Worker-${workerId}] Browser launch completed after its deadline; closing the orphaned instance.`);
265
+ try { b?.close?.(); } catch { /* already gone */ }
266
+ }).catch(() => { /* rejection already logged above */ });
247
267
  try {
248
268
  return await Promise.race([
249
269
  launchPromise,
250
270
  new Promise<never>((_, reject) => {
251
- launchTimeoutId = setTimeout(() => reject(new Error(`Browser launch timed out after ${launchTimeoutMs}ms`)), launchTimeoutMs);
271
+ launchTimeoutId = setTimeout(() => {
272
+ raceLost = true;
273
+ reject(new Error(`Browser launch timed out after ${launchTimeoutMs}ms`));
274
+ }, launchTimeoutMs);
252
275
  })
253
276
  ]);
254
277
  } finally {
@@ -278,22 +301,6 @@ export async function initBrowser(): Promise<void> {
278
301
  }
279
302
 
280
303
  browser = launchedBrowser;
281
- // newContext() can hang if the browser process becomes unresponsive immediately
282
- // after launch (e.g. OOM, GPU crash). Guard with a hard timeout.
283
- const contextPromise = browser.newContext();
284
- contextPromise.catch((err: Error) => logToDebugFile('DEBUG', `[ThreadWorker] Background browser context rejection: ${err.message}`));
285
- let contextTimeoutId: NodeJS.Timeout | undefined;
286
- context = await Promise.race([
287
- contextPromise,
288
- new Promise<never>((_, reject) => {
289
- contextTimeoutId = setTimeout(() => reject(new Error('Browser context creation timed out after 30000ms')), 30000);
290
- })
291
- ]);
292
- if (contextTimeoutId) clearTimeout(contextTimeoutId);
293
-
294
- // Setup mocking for CI if enabled
295
- await setupMocking(context);
296
-
297
304
  logToDebugFile('INFO', `[Worker-${workerId}] Browser initialized.`);
298
305
  }
299
306
  } catch (e: unknown) {
@@ -304,7 +311,6 @@ export async function initBrowser(): Promise<void> {
304
311
  Promise.resolve(browser.close()).catch((err: Error) => logToDebugFile('DEBUG', `[Worker-${workerId}] Swallowed browser close error during failed init: ${err.message}`));
305
312
  }
306
313
  browser = null;
307
- context = null;
308
314
  const msg = e instanceof Error ? e.message : String(e);
309
315
 
310
316
  if (msg.includes('Camoufox is not installed') || msg.includes('Version information not found')) {
@@ -331,16 +337,60 @@ export async function initBrowser(): Promise<void> {
331
337
  }
332
338
 
333
339
  /**
334
- * Get the current browser context
340
+ * Create a FRESH BrowserContext for one task, against the shared browser
341
+ * process. Call once per task, right after initBrowser() resolves; the
342
+ * caller owns the returned context and must close it itself (in a `finally`,
343
+ * covering the success, error, AND timeout-abort paths) once the task ends.
344
+ *
345
+ * Never reused across tasks — that was the bug this replaced. Two tasks
346
+ * dispatched concurrently to the same worker (WORKER_CONCURRENCY > 1, the
347
+ * default) used to share one BrowserContext, so cookies/storage a site set
348
+ * for one task's request were visible to a different, unrelated task's later
349
+ * request to the same site — a content-consistency gap with no counterpart
350
+ * anywhere else in this codebase, which scopes browser-adjacent state
351
+ * (circuit breakers, scrape caches) per session deliberately.
352
+ *
353
+ * The launch race/timeout/orphan-adoption pattern below is the exact one
354
+ * initBrowser() used when context creation lived there (moved here wholesale,
355
+ * not reinvented): newContext() can hang if the browser process is
356
+ * unresponsive (OOM, GPU crash), so it is guarded with a hard timeout, and a
357
+ * context that finishes creating AFTER that timeout is adopted and closed
358
+ * rather than left to leak.
335
359
  */
336
- export function getContext(): any {
337
- return context;
360
+ export async function acquireTaskContext(): Promise<any> {
361
+ const contextPromise = browser.newContext();
362
+ contextPromise.catch((err: Error) => logToDebugFile('DEBUG', `[ThreadWorker] Background browser context rejection: ${err.message}`));
363
+ let contextTimeoutId: NodeJS.Timeout | undefined;
364
+ let contextRaceLost = false;
365
+ contextPromise.then((c: any) => {
366
+ if (!contextRaceLost) return;
367
+ logToDebugFile('WARN', `[Worker-${workerId}] Browser context creation completed after its deadline; closing the orphaned context.`);
368
+ try { c?.close?.(); } catch { /* already gone */ }
369
+ }).catch(() => { /* rejection already logged above */ });
370
+ let newContext: any;
371
+ try {
372
+ newContext = await Promise.race([
373
+ contextPromise,
374
+ new Promise<never>((_, reject) => {
375
+ contextTimeoutId = setTimeout(() => {
376
+ contextRaceLost = true;
377
+ reject(new Error(`Browser context creation timed out after ${CONTEXT_CREATION_TIMEOUT_MS}ms`));
378
+ }, CONTEXT_CREATION_TIMEOUT_MS);
379
+ })
380
+ ]);
381
+ } finally {
382
+ if (contextTimeoutId) clearTimeout(contextTimeoutId);
383
+ }
384
+
385
+ // Route interception is per-context, so mocking must be (re-)applied to
386
+ // every fresh context, not just the first one ever created.
387
+ await setupMocking(newContext);
388
+
389
+ return newContext;
338
390
  }
339
391
 
340
392
  function doResetBrowser(): void {
341
- if (context) Promise.resolve(context.close()).catch((err: Error) => logToDebugFile('DEBUG', `[Worker-${workerId}] Swallowed context close error during reset: ${err.message}`));
342
393
  if (browser) Promise.resolve(browser.close()).catch((err: Error) => logToDebugFile('DEBUG', `[Worker-${workerId}] Swallowed browser close error during reset: ${err.message}`));
343
- context = null;
344
394
  browser = null;
345
395
  }
346
396
 
@@ -377,7 +427,9 @@ export async function cleanupBrowser(): Promise<void> {
377
427
  const timeoutMs = 2000;
378
428
 
379
429
  const cleanup = async () => {
380
- if (context) await Promise.resolve(context.close()).catch((err: Error) => logToDebugFile('DEBUG', `[Worker-${workerId}] Swallowed context close error during cleanup: ${err.message}`));
430
+ // Closes every context still open under it (any task whose own close()
431
+ // hasn't run yet) as a side effect — no separate context bookkeeping
432
+ // needed here now that contexts are task-owned, not module-owned.
381
433
  if (browser) await Promise.resolve(browser.close()).catch((err: Error) => logToDebugFile('DEBUG', `[Worker-${workerId}] Swallowed browser close error during cleanup: ${err.message}`));
382
434
  };
383
435
 
@@ -389,7 +441,6 @@ export async function cleanupBrowser(): Promise<void> {
389
441
  } catch {
390
442
  // Ignore timeout — we just want to ensure cleanup doesn't hang indefinitely
391
443
  } finally {
392
- context = null;
393
444
  browser = null;
394
445
  }
395
446
  }
@@ -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
 
@@ -406,6 +433,13 @@ export async function executeScrapeTask(
406
433
  let poisonedError: Error | null = null;
407
434
  const pendingAddrChecks: Array<Promise<void>> = [];
408
435
 
436
+ // Set by the response-side early-abort check (see page.on('response') below) when the
437
+ // main-frame document's OWN headers already declare a PDF larger than MAX_PDF_SIZE.
438
+ // Declared outside the try for the same reason as poisonedError: the catch below needs
439
+ // to surface this instead of the secondary 'Target closed' error the triggering
440
+ // page.close() produces.
441
+ let oversizedPdfError: Error | null = null;
442
+
409
443
  try {
410
444
  logToDebugFile('DEBUG', `[Worker-${workerId}] Starting scrape for: ${url}`);
411
445
 
@@ -484,6 +518,42 @@ export async function executeScrapeTask(
484
518
  try {
485
519
  if (resp.request().resourceType() === 'document' && resp.frame() === page.mainFrame()) {
486
520
  finalMainFrameAddr = resp.serverAddr().catch(() => null);
521
+
522
+ // Early-abort a declared-oversized PDF as soon as its headers arrive, rather
523
+ // than waiting for page.goto() to finish downloading the whole thing first.
524
+ // 'response' fires on headers-received, well before the body is fully
525
+ // transferred — for a direct navigation to a PDF URL, Playwright's goto()
526
+ // does not resolve until the download completes, so without this the full
527
+ // (possibly multi-hundred-MB) body is pulled into the browser process before
528
+ // the PDF branch below ever gets to check Content-Length.
529
+ //
530
+ // This closes the gap only for a server that HONESTLY declares an oversized
531
+ // Content-Length up front. A chunked-transfer response (no Content-Length) or
532
+ // one that understates its length and then sends far more cannot be caught
533
+ // this way — Playwright's public API exposes no incremental
534
+ // bytes-received/streaming hook to abort mid-body on either engine. That
535
+ // residual case is bounded instead by the task's own SCRAPE_TIMEOUT_MS +
536
+ // BROWSER_TASK_TIMEOUT_MS deadline (see the onAbort/page.close() wiring
537
+ // above), which forcibly closes the page — and with it any in-flight
538
+ // download — once the task's time budget is exhausted, converting an
539
+ // otherwise-unbounded read into one bounded by (bandwidth x that budget).
540
+ const check2: Promise<void> = Promise.resolve(resp.headerValue('content-type'))
541
+ .then(async (ct: string | null) => {
542
+ // Matches the PDF branch below AND the fetch layer's own detection
543
+ // (web-scraper.ts): a server can mislabel or omit content-type, so a
544
+ // `.pdf` URL extension is an equally-trusted signal, not just the header.
545
+ const looksLikePdf = ct?.includes('application/pdf') || url.toLowerCase().endsWith('.pdf');
546
+ if (!looksLikePdf || oversizedPdfError || poisonedError) return;
547
+ const declared = parseInt((await resp.headerValue('content-length').catch(() => null)) || '', 10);
548
+ if (Number.isFinite(declared) && declared > MAX_PDF_SIZE) {
549
+ const sizeMB = Math.round(declared / 1024 / 1024);
550
+ oversizedPdfError = new Error(`PDF too large (${sizeMB}MB, max 100MB)`);
551
+ logToDebugFile('DEBUG', `[Worker-${workerId}] Aborting oversized PDF (${sizeMB}MB declared) before download completes: ${url}`);
552
+ page.close().catch(() => {});
553
+ }
554
+ })
555
+ .catch(() => {});
556
+ pendingAddrChecks.push(check2);
487
557
  }
488
558
  const check: Promise<void> = Promise.resolve(resp.serverAddr())
489
559
  .then((addr: { ipAddress?: string } | null) => {
@@ -535,7 +605,14 @@ export async function executeScrapeTask(
535
605
  // 403/503 — the HTML branch defers to the challenge wait below for that.
536
606
  const status = response?.status?.() ?? 0;
537
607
 
538
- if (contentType.includes('application/pdf')) {
608
+ // `.pdf` extension is an equally-trusted signal alongside the header (matches
609
+ // the fetch layer's own detection in web-scraper.ts): a server that mislabels
610
+ // or omits content-type would otherwise fall into the HTML branch below, whose
611
+ // page.content() would serialize Firefox's PDF-viewer chrome instead of
612
+ // extracting the document — a silent content loss for exactly the URLs this
613
+ // feature advertises as supported, and specifically for the malformed/oddly-
614
+ // served PDFs most likely to reach this browser fallback in the first place.
615
+ if (contentType.includes('application/pdf') || url.toLowerCase().endsWith('.pdf')) {
539
616
  if (!response) throw new Error(`[Worker] No response received for PDF URL: ${url}`);
540
617
  // An errored "PDF" is an error page: fail BEFORE buffering its body.
541
618
  if (status >= 400) throw new Error(`HTTP ${status}`);
@@ -549,6 +626,10 @@ export async function executeScrapeTask(
549
626
  const sizeMB = Math.round(declaredLength / 1024 / 1024);
550
627
  throw new Error(`PDF too large (${sizeMB}MB, max 100MB)`);
551
628
  }
629
+ // The response-side early-abort check above (page.on('response')) may already have
630
+ // decided this and be in the middle of closing the page — skip the (redundant,
631
+ // about-to-fail) download attempt rather than race it.
632
+ if (oversizedPdfError) throw oversizedPdfError;
552
633
  const buffer = await response.body();
553
634
  // ...then the actual bytes: the header is advisory and absent on chunked
554
635
  // responses, so the byte count is the check that actually bounds memory.
@@ -665,9 +746,12 @@ export async function executeScrapeTask(
665
746
  return { contentType, html, jitter };
666
747
  } catch (error) {
667
748
  await page.close().catch((err: any) => logToDebugFile('DEBUG', `[ThreadWorker] Swallowed page close/wait error: ${err.message || String(err)}`));
668
- // A poison-triggered page.close() makes the in-flight goto/content() reject
669
- // with 'Target closed'; report the SSRF poisoning (a benign-classified
670
- // 'Fetch blocked:' failure), not the secondary teardown error.
749
+ // An early-abort-triggered page.close() (oversized PDF, or SSRF poisoning) makes
750
+ // the in-flight goto/content()/body() reject with 'Target closed'; report the
751
+ // actual reason (a benign-classified failure) instead of that secondary teardown
752
+ // error. oversizedPdfError takes priority since it can only be set on the PDF path,
753
+ // where poisonedError (an HTML-only DOM-exfil concern) is not meaningful.
754
+ if (oversizedPdfError) throw oversizedPdfError;
671
755
  if (poisonedError) throw poisonedError;
672
756
  throw error;
673
757
  } finally {
@@ -692,11 +776,15 @@ const HEALTH_FALLBACK_URL = 'https://example.com/';
692
776
  async function executeHealthCheckAttempt(
693
777
  _context: any,
694
778
  navTimeoutMs: number,
695
- url: string = HEALTH_PRIMARY_URL
779
+ url: string = HEALTH_PRIMARY_URL,
780
+ signal?: AbortSignal
696
781
  ): Promise<{ success: boolean; navMs: number }> {
697
782
  const page = await createPageSafe(_context);
698
783
  page.setDefaultTimeout(navTimeoutMs);
699
784
  page.setDefaultNavigationTimeout(navTimeoutMs);
785
+ // See executeSearchTask: the probe runs twice (primary then fallback), so its own
786
+ // nav timeout bounds each attempt but not the pair.
787
+ bindTaskAbortToPage(page, signal);
700
788
 
701
789
  try {
702
790
  const navStart = Date.now();
@@ -721,7 +809,8 @@ async function executeHealthCheckAttempt(
721
809
  */
722
810
  export async function executeHealthCheck(
723
811
  _context: any,
724
- initMs: number
812
+ initMs: number,
813
+ signal?: AbortSignal
725
814
  ): Promise<{ success: boolean; navMs: number }> {
726
815
  // Nav timeout: read from env (passed through getBrowserEnv), floor at 10s.
727
816
  // The outer BrowserTaskScheduler.runHealthCheck() has its own 45s hard deadline.
@@ -733,14 +822,14 @@ export async function executeHealthCheck(
733
822
  }
734
823
 
735
824
  try {
736
- return await executeHealthCheckAttempt(_context, HEALTH_TIMEOUT);
825
+ return await executeHealthCheckAttempt(_context, HEALTH_TIMEOUT, HEALTH_PRIMARY_URL, signal);
737
826
  } catch (firstError: unknown) {
738
827
  // One retry: if the first attempt fails, the page may have been in a bad state
739
828
  // (challenge redirect, partial load, transient network blip). A second attempt
740
829
  // on a fresh page helps distinguish a real outage from a one-off failure.
741
830
  logToDebugFile('WARN', `[Worker-${workerId}] Health check attempt 1 failed: ${firstError instanceof Error ? firstError.message : String(firstError)}. Retrying once...`);
742
831
  try {
743
- return await executeHealthCheckAttempt(_context, HEALTH_TIMEOUT);
832
+ return await executeHealthCheckAttempt(_context, HEALTH_TIMEOUT, HEALTH_PRIMARY_URL, signal);
744
833
  } catch (retryError: unknown) {
745
834
  const retryMsg = retryError instanceof Error ? retryError.message : String(retryError);
746
835
  // A TIMEOUT is left to propagate: upstream (isBusyPoolHealthFailure) already
@@ -756,7 +845,7 @@ export async function executeHealthCheck(
756
845
  // healthy enough to research (search has its own retries/fallbacks downstream).
757
846
  logToDebugFile('WARN', `[Worker-${workerId}] Search-provider health probe failed twice (${retryMsg}); verifying open-web reachability via fallback endpoint...`);
758
847
  try {
759
- const fallback = await executeHealthCheckAttempt(_context, HEALTH_TIMEOUT, HEALTH_FALLBACK_URL);
848
+ const fallback = await executeHealthCheckAttempt(_context, HEALTH_TIMEOUT, HEALTH_FALLBACK_URL, signal);
760
849
  logToDebugFile('WARN', `[Worker-${workerId}] Fallback endpoint reachable — open web is up, search provider is degraded. Proceeding with research.`);
761
850
  return fallback;
762
851
  } catch (fallbackError: unknown) {