@lincoln504/pi-research 1.1.2 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (119) hide show
  1. package/README.md +3 -3
  2. package/dist/cli.mjs +2599 -1268
  3. package/dist/thread-worker.mjs +224 -147
  4. package/docs/AGENT-SKILL.md +11 -4
  5. package/docs/ARCHITECTURE.md +20 -10
  6. package/docs/CHANGELOG.md +195 -17
  7. package/docs/CONFIGURATION.md +9 -7
  8. package/docs/KNOWLEDGE-STORE.md +4 -3
  9. package/docs/PI-EXTENSION.md +1 -1
  10. package/docs/SDK.md +55 -0
  11. package/docs/THIRD-PARTY-NOTICES.md +1 -1
  12. package/package.json +5 -4
  13. package/scripts/cleanup.cjs +114 -12
  14. package/scripts/prepare.cjs +15 -5
  15. package/scripts/setup.cjs +24 -4
  16. package/skills/pi-research/README.md +9 -2
  17. package/skills/pi-research/SKILL.md +6 -3
  18. package/skills/pi-research/references/configuration.md +6 -2
  19. package/skills/pi-research/scripts/run.mjs +24 -2
  20. package/src/cleanup/research-cleanup.ts +12 -4
  21. package/src/cli.ts +372 -86
  22. package/src/config.ts +134 -17
  23. package/src/constants.ts +31 -0
  24. package/src/core/interfaces/health-check-interfaces.ts +3 -39
  25. package/src/core/interfaces/knowledge-interfaces.ts +6 -1
  26. package/src/core/interfaces/planning-interfaces.ts +8 -0
  27. package/src/core/interfaces/service-names.ts +0 -1
  28. package/src/core/interfaces/state-manager-interfaces.ts +11 -3
  29. package/src/core/llm/agentic-repair.ts +14 -0
  30. package/src/core/pi-version.ts +136 -0
  31. package/src/core/planning-service.ts +43 -1
  32. package/src/core/service-initialization.ts +6 -14
  33. package/src/core/service-interfaces.ts +1 -1
  34. package/src/core/service-registry.ts +117 -8
  35. package/src/healthcheck/index.ts +7 -2
  36. package/src/index.ts +11 -18
  37. package/src/infrastructure/browser/browser-client.ts +80 -13
  38. package/src/infrastructure/browser/browser-error-utils.ts +6 -1
  39. package/src/infrastructure/browser/browser-server.ts +41 -12
  40. package/src/infrastructure/browser/browser-task-scheduler.ts +159 -35
  41. package/src/infrastructure/browser/cleanup-utils.ts +95 -9
  42. package/src/infrastructure/browser/client-agent.ts +6 -5
  43. package/src/infrastructure/browser/config.ts +14 -3
  44. package/src/infrastructure/browser/priority-task-queue.ts +10 -3
  45. package/src/infrastructure/browser/scheduler-factory.ts +44 -4
  46. package/src/infrastructure/browser/task-execution-service.ts +92 -28
  47. package/src/infrastructure/browser/thread-worker-lifecycle.ts +50 -13
  48. package/src/infrastructure/browser/thread-worker-messaging.ts +54 -2
  49. package/src/infrastructure/browser/thread-worker-types.ts +3 -1
  50. package/src/infrastructure/browser/thread-worker.mjs +224 -147
  51. package/src/infrastructure/browser/worker-pool-manager.ts +18 -5
  52. package/src/infrastructure/embedding/embedding-client.ts +22 -8
  53. package/src/infrastructure/embedding/embedding-factory.ts +61 -15
  54. package/src/infrastructure/embedding/embedding-server.ts +105 -11
  55. package/src/infrastructure/file-lock-service.ts +295 -28
  56. package/src/infrastructure/knowledge-store-service.ts +48 -3
  57. package/src/infrastructure/process-lifecycle-service.ts +134 -34
  58. package/src/infrastructure/research-run-semaphore.ts +37 -3
  59. package/src/infrastructure/state/state-backup-manager.ts +5 -7
  60. package/src/infrastructure/state/state-browser-api.ts +5 -2
  61. package/src/infrastructure/state/state-browser-manager.ts +15 -1
  62. package/src/infrastructure/state/state-manager-service.ts +5 -5
  63. package/src/infrastructure/state/state-manager.ts +34 -23
  64. package/src/infrastructure/types/state-types.ts +9 -0
  65. package/src/knowledge/chunker.ts +88 -1
  66. package/src/knowledge/embedder-init.ts +1 -1
  67. package/src/knowledge/embedder.ts +188 -46
  68. package/src/knowledge/index.ts +1 -1
  69. package/src/knowledge/store.ts +101 -21
  70. package/src/knowledge/webgpu-viability.ts +9 -4
  71. package/src/knowledge/writer-queue.ts +88 -46
  72. package/src/logger.ts +5 -1
  73. package/src/observers/research-observer-impl.ts +31 -0
  74. package/src/orchestration/abort-utils.ts +60 -0
  75. package/src/orchestration/deep-research-orchestrator.ts +25 -4
  76. package/src/orchestration/headless-observer.ts +8 -0
  77. package/src/orchestration/quick-research-orchestrator.ts +2 -2
  78. package/src/orchestration/research-orchestration-service.ts +65 -15
  79. package/src/orchestration/research-session-service.ts +37 -11
  80. package/src/orchestration/research-synthesis-service.ts +33 -11
  81. package/src/orchestration/researcher-executor.ts +100 -20
  82. package/src/orchestration/researcher.ts +39 -20
  83. package/src/orchestration/session-state.ts +11 -0
  84. package/src/sdk.ts +125 -19
  85. package/src/security/cisa-kev.ts +10 -3
  86. package/src/security/github-advisories.ts +18 -7
  87. package/src/security/index.ts +51 -30
  88. package/src/security/nvd-client.ts +24 -8
  89. package/src/security/osv-client.ts +14 -4
  90. package/src/skill-install/skill-installer.ts +153 -11
  91. package/src/stackexchange/index.ts +20 -5
  92. package/src/stackexchange/output/table.ts +7 -2
  93. package/src/stackexchange/rest-client.ts +41 -3
  94. package/src/tools/research-knowledge-search.ts +15 -0
  95. package/src/tools/research-tool-definition.ts +8 -2
  96. package/src/tools/scrape.ts +48 -25
  97. package/src/tools/search.ts +16 -2
  98. package/src/tools/security.ts +68 -7
  99. package/src/tui/research-health.ts +18 -2
  100. package/src/utils/atomic-replace.ts +101 -0
  101. package/src/utils/circuit-breaker.ts +9 -2
  102. package/src/utils/citation-utils.ts +3 -1
  103. package/src/utils/disk-space-checker.ts +11 -6
  104. package/src/utils/http-body.ts +176 -0
  105. package/src/utils/json-utils.ts +116 -76
  106. package/src/utils/llm-usage.ts +17 -1
  107. package/src/utils/log-rotation.ts +42 -10
  108. package/src/utils/log-utils.ts +40 -0
  109. package/src/utils/metrics-summary.ts +27 -9
  110. package/src/utils/stdio-capture.ts +63 -22
  111. package/src/utils/text-utils.ts +8 -1
  112. package/src/utils/tool-usage-tracker.ts +9 -2
  113. package/src/web-research/browser-search.ts +70 -5
  114. package/src/web-research/scraper-utils.ts +13 -0
  115. package/src/web-research/search.ts +25 -6
  116. package/src/web-research/types.ts +19 -4
  117. package/src/web-research/web-scraper.ts +72 -54
  118. package/src/youtube/transcript-client.ts +5 -1
  119. package/src/core/health-check-service.ts +0 -265
@@ -17,6 +17,9 @@ import { getServiceContainer, getService } from '../../core/service-registry.ts'
17
17
  import type { ServiceContainer } from '../../core/service-registry.ts';
18
18
  import { ServiceNames } from '../../core/service-interfaces.ts';
19
19
  import type { ISchedulerFactory, IScheduler } from '../../core/scheduler-factory.ts';
20
+ import type { ISchedulerInternals } from '../../core/interfaces/scheduler-interfaces.ts';
21
+ import type { IStateManager } from '../../core/interfaces/state-manager-interfaces.ts';
22
+ import { BrowserClient } from './browser-client.ts';
20
23
 
21
24
  // Cooldown to prevent cascading scheduler restarts (thundering herd)
22
25
  let lastRestartTime = 0;
@@ -62,6 +65,92 @@ async function recoverFromLeaderHandover(container: ServiceContainer): Promise<v
62
65
  );
63
66
  }
64
67
 
68
+ /**
69
+ * Is the leader currently registered in state still a live process — and is it
70
+ * the one our cached handle actually targets?
71
+ *
72
+ * Used only to decide whether the restart cooldown below may be honoured.
73
+ * Anything that prevents a confident "yes" (no registered leader, an unreadable
74
+ * state file, a service-resolution failure, a cached client aimed at a different
75
+ * port than the registered leader's) answers `false`, because the cost of an
76
+ * unnecessary re-resolve is one cheap round-trip while the cost of skipping a
77
+ * needed one is a task failure against a dead port.
78
+ */
79
+ async function isRegisteredLeaderAliveUncached(container: ServiceContainer): Promise<boolean> {
80
+ try {
81
+ const stateManager = await getService<IStateManager>(ServiceNames.STATE_MANAGER, undefined, container);
82
+ const info = await stateManager.getBrowserServer();
83
+ if (!info) return false;
84
+ // "Leader alive" is not enough: the cooldown protects the CACHED handle, so
85
+ // that handle must also target the registered leader. If our leader B died
86
+ // and another process already elected C, C is alive — but our cached client
87
+ // still aims at B's dead port, and honouring the cooldown would leave every
88
+ // retry failing against the corpse until the window expired.
89
+ const schedulerService = await getService<ISchedulerInternals>(ServiceNames.SCHEDULER, undefined, container);
90
+ const cached = schedulerService.getSchedulerInstance();
91
+ if (cached instanceof BrowserClient && cached.targetPort !== info.port) return false;
92
+ return await stateManager.isPidAlive(info.pid, info.schedulerId);
93
+ } catch {
94
+ return false;
95
+ }
96
+ }
97
+
98
+ /**
99
+ * Coalesce concurrent liveness probes.
100
+ *
101
+ * This runs on the failure path, where a whole query burst fails at once: a
102
+ * 20-query search losing its leader calls it up to 19 times within milliseconds
103
+ * (the first caller restarts and the rest land inside the cooldown). The probe is
104
+ * cheap on Linux — a `/proc/<pid>/stat` read — but on Windows the PID-reuse guard
105
+ * resolves the process start time by spawning `powershell -NoProfile`, so an
106
+ * uncoalesced burst would fan out into ~19 concurrent PowerShell processes on the
107
+ * slowest platform we support. The window is deliberately far shorter than
108
+ * RESTART_COOLDOWN_MS: it exists to collapse one burst, not to cache a verdict.
109
+ */
110
+ const LEADER_LIVENESS_COALESCE_MS = 1000;
111
+ let leaderLivenessProbe: { startedAt: number; result: Promise<boolean> } | null = null;
112
+
113
+ async function isRegisteredLeaderAlive(container: ServiceContainer): Promise<boolean> {
114
+ const now = Date.now();
115
+ if (leaderLivenessProbe && now - leaderLivenessProbe.startedAt < LEADER_LIVENESS_COALESCE_MS) {
116
+ return leaderLivenessProbe.result;
117
+ }
118
+ const result = isRegisteredLeaderAliveUncached(container);
119
+ leaderLivenessProbe = { startedAt: now, result };
120
+ return result;
121
+ }
122
+
123
+ /** Test seam: drop the coalesced liveness probe. */
124
+ export function _resetLeaderLivenessProbeForTests(): void {
125
+ leaderLivenessProbe = null;
126
+ }
127
+
128
+ /**
129
+ * Drop the scheduler handle and wait for the pool to settle, subject to a
130
+ * cooldown that stops N concurrent task failures from each restarting.
131
+ *
132
+ * The cooldown is only safe while the registered leader is still alive: then the
133
+ * cached handle points at a working process and skipping the restart costs
134
+ * nothing. It is NOT safe when the leader is gone. A hard leader kill (SIGKILL,
135
+ * OOM) surfaces to clients as ECONNREFUSED, which `isTransientSocketError`
136
+ * matches but `isPoolShutdownError` does not — so it lands here rather than on
137
+ * the handover path, and a bare wait leaves the cached client aimed at a corpse.
138
+ * The retry then re-resolves to the same dead port and fails identically: the
139
+ * exact stale-handle failure the leader-handover work fixed for the *graceful*
140
+ * shutdown case. So the cooldown yields whenever the leader is not confirmed alive.
141
+ */
142
+ async function restartSchedulerWithHerdGuard(container: ServiceContainer): Promise<void> {
143
+ const now = Date.now();
144
+ if (now - lastRestartTime > RESTART_COOLDOWN_MS || !(await isRegisteredLeaderAlive(container))) {
145
+ lastRestartTime = now;
146
+ logger.warn('[BrowserManager] Forcing scheduler restart and retrying...');
147
+ await forceSchedulerRestart(false, container);
148
+ } else {
149
+ logger.warn('[BrowserManager] Scheduler restart triggered recently and the registered leader is still alive — waiting for pool idle instead...');
150
+ }
151
+ await waitForBrowserPoolIdle(15000).catch((err) => logger.debug('Wait for browser idle timed out or failed:', err));
152
+ }
153
+
65
154
  /**
66
155
  * Get the scheduler from the factory, optionally using a specific container.
67
156
  */
@@ -163,16 +252,7 @@ export async function runBrowserTask<T>(
163
252
  // PID+start-time liveness probe confirms the registered leader is
164
253
  // actually gone; it only skips clearing when that probe finds the
165
254
  // leader alive, which is exactly the case we must not disturb.
166
- const now = Date.now();
167
- if (now - lastRestartTime > RESTART_COOLDOWN_MS) {
168
- lastRestartTime = now;
169
- logger.warn(`[BrowserManager] Forcing scheduler restart and retrying...`);
170
- await forceSchedulerRestart(false, container);
171
- await waitForBrowserPoolIdle(15000).catch((err) => logger.debug('Wait for browser idle timed out or failed:', err));
172
- } else {
173
- logger.warn(`[BrowserManager] Scheduler restart recently triggered, waiting for pool idle...`);
174
- await waitForBrowserPoolIdle(15000).catch((err) => logger.debug('Wait for browser idle timed out or failed:', err));
175
- }
255
+ await restartSchedulerWithHerdGuard(container);
176
256
  }
177
257
 
178
258
  // Retry with jitter (100-500ms) to prevent thundering herd
@@ -211,15 +291,7 @@ export async function runBrowserHealthCheck(config?: Config, retries = 1, signal
211
291
  // See runBrowserTask: don't force-clear remote leader state on a bare
212
292
  // transient socket error — let the liveness probe inside
213
293
  // forceSchedulerRestart decide.
214
- const now = Date.now();
215
- if (now - lastRestartTime > RESTART_COOLDOWN_MS) {
216
- lastRestartTime = now;
217
- logger.warn(`[BrowserManager] Forcing scheduler restart and retrying...`);
218
- await forceSchedulerRestart(false, container);
219
- await waitForBrowserPoolIdle(15000).catch((err) => logger.debug('Wait for browser idle timed out or failed:', err));
220
- } else {
221
- await waitForBrowserPoolIdle(15000).catch((err) => logger.debug('Wait for browser idle timed out or failed:', err));
222
- }
294
+ await restartSchedulerWithHerdGuard(container);
223
295
  }
224
296
 
225
297
  const jitter = 100 + Math.floor(Math.random() * 400);
@@ -278,15 +350,7 @@ export async function runWorkerSearch(query: string, config?: Config, signal?: A
278
350
  // See runBrowserTask: don't force-clear remote leader state on a bare
279
351
  // transient socket error — let the liveness probe inside
280
352
  // forceSchedulerRestart decide.
281
- const now = Date.now();
282
- if (now - lastRestartTime > RESTART_COOLDOWN_MS) {
283
- lastRestartTime = now;
284
- logger.warn(`[BrowserManager] Forcing scheduler restart and retrying...`);
285
- await forceSchedulerRestart(false, container);
286
- await waitForBrowserPoolIdle(15000).catch((err) => logger.debug('Wait for browser idle timed out or failed:', err));
287
- } else {
288
- await waitForBrowserPoolIdle(15000).catch((err) => logger.debug('Wait for browser idle timed out or failed:', err));
289
- }
353
+ await restartSchedulerWithHerdGuard(container);
290
354
  }
291
355
 
292
356
  const jitter = 100 + Math.floor(Math.random() * 400);
@@ -206,14 +206,60 @@ export function setupOrphanProtection(): void {
206
206
  return;
207
207
  }
208
208
 
209
- if (!process.ppid) {
209
+ // Capture the parent PID ONCE, at setup. `process.ppid` is re-read from the OS
210
+ // on every access, so on POSIX it changes the moment we are orphaned — and that
211
+ // change, not a liveness probe, is the reliable signal (see checkOrphan).
212
+ const originalPpid = process.ppid;
213
+ if (!originalPpid) {
210
214
  return;
211
215
  }
212
216
 
217
+ /** Tear down the browser and exit. Shared by both orphan signals below. */
218
+ const exitAsOrphan = async () => {
219
+ // Await cleanup to prevent browser/context leaks — but bounded. The browser is
220
+ // the plausible thing to be wedged after the parent died abruptly, and an
221
+ // unbounded browser.close() hang here would keep the orphan (and its browser)
222
+ // alive forever: the precise leak this guard exists to stop. The exit deadline
223
+ // wins over a perfect teardown.
224
+ if (cleanupBrowser) {
225
+ await Promise.race([
226
+ cleanupBrowser().catch(err => {
227
+ logToDebugFile('WARN', `[Worker-${workerId}] Browser cleanup failed during orphan exit:`, err);
228
+ }),
229
+ new Promise<void>(resolve => {
230
+ const t = setTimeout(resolve, 10_000);
231
+ if (t.unref) t.unref();
232
+ }),
233
+ ]);
234
+ }
235
+ // Clear the orphan check timer
236
+ cleanupOrphanProtection();
237
+ process.env['PI_PROCESS_EXITING'] = '1';
238
+ process.exit(1);
239
+ };
240
+
213
241
  const checkOrphan = async () => {
214
242
  try {
215
- // signal 0 checks if the process is alive
216
- process.kill(process.ppid, 0);
243
+ // Re-parenting is the authoritative orphan signal on POSIX. When the parent
244
+ // dies the kernel immediately re-parents us — to PID 1, or to the nearest
245
+ // subreaper (a user systemd instance, a container init). Probing the CURRENT
246
+ // ppid cannot see that: PID 1 always exists, and signalling it from an
247
+ // unprivileged process raises EPERM, which the handler below correctly reads
248
+ // as "alive but not ours to signal". Net effect on Linux was that this guard
249
+ // never fired at all and orphaned workers kept their browsers running.
250
+ // Comparing against the PID captured at setup detects it directly.
251
+ // Windows does not re-parent, so the liveness probe below remains the
252
+ // mechanism there and this check simply never trips.
253
+ if (process.ppid !== originalPpid) {
254
+ logToDebugFile('WARN', `[Worker-${workerId}] Re-parented (${originalPpid} -> ${process.ppid}): parent died, shutting down...`);
255
+ await exitAsOrphan();
256
+ return;
257
+ }
258
+
259
+ // signal 0 checks if the process is alive. Probe the ORIGINAL ppid, never
260
+ // `process.ppid` — after a re-parent the latter points at init, not at the
261
+ // process whose liveness we actually care about.
262
+ process.kill(originalPpid, 0);
217
263
 
218
264
  // Schedule next check
219
265
  orphanCheckTimer = setTimeout(checkOrphan, 10000);
@@ -231,16 +277,7 @@ export function setupOrphanProtection(): void {
231
277
  }
232
278
  // If error is thrown, the parent process is likely dead or unreachable
233
279
  logToDebugFile('WARN', `[Worker-${workerId}] Parent process died or unreachable (orphaned), shutting down...`);
234
- // FIX: Await cleanup to prevent browser/context leaks
235
- if (cleanupBrowser) {
236
- await cleanupBrowser().catch(err => {
237
- logToDebugFile('WARN', `[Worker-${workerId}] Browser cleanup failed during orphan exit:`, err);
238
- });
239
- }
240
- // Clear the orphan check timer
241
- cleanupOrphanProtection();
242
- process.env['PI_PROCESS_EXITING'] = '1';
243
- process.exit(1);
280
+ await exitAsOrphan();
244
281
  }
245
282
  };
246
283
 
@@ -8,6 +8,7 @@ let workerId: string = '';
8
8
 
9
9
  import { appendFileSync } from 'node:fs';
10
10
  import { redactSecrets } from '../../utils/log-utils.ts';
11
+ import { MAX_HTML_SIZE, MAX_PDF_SIZE } from '../../web-research/scraper-types.ts';
11
12
 
12
13
  /**
13
14
  * Mutex lock to serialize page creation. Playwright/Firefox can deadlock if newPage()
@@ -311,7 +312,7 @@ export async function executeScrapeTask(
311
312
  _context: any,
312
313
  url: string,
313
314
  signal?: AbortSignal
314
- ): Promise<{ contentType: string; html?: string; buffer?: Buffer; jitter: number }> {
315
+ ): Promise<{ contentType: string; html?: string; bufferB64?: string; jitter: number }> {
315
316
  const page = await createPageSafe(_context);
316
317
  const SCRAPE_TIMEOUT = parseInt(process.env['PI_RESEARCH_SCRAPE_TIMEOUT_MS'] || '15000', 10);
317
318
  page.setDefaultTimeout(SCRAPE_TIMEOUT);
@@ -458,15 +459,47 @@ export async function executeScrapeTask(
458
459
  }
459
460
 
460
461
  const contentType = (await response?.headerValue('content-type')) || '';
462
+ // HTTP status of the final navigation response. Never consulting it let
463
+ // 404/500/403 pages return as normal {contentType, html} results — major
464
+ // sites' rich error pages cleared the stub gate, were cached as scrape
465
+ // SUCCESS, and became citable. The fetch layer enforces !response.ok; this
466
+ // layer must match it (`HTTP <status>` is benign-classified per URL). The
467
+ // one legitimate exception is a Cloudflare challenge, which arrives with
468
+ // 403/503 — the HTML branch defers to the challenge wait below for that.
469
+ const status = response?.status?.() ?? 0;
461
470
 
462
471
  if (contentType.includes('application/pdf')) {
463
472
  if (!response) throw new Error(`[Worker] No response received for PDF URL: ${url}`);
473
+ // An errored "PDF" is an error page: fail BEFORE buffering its body.
474
+ if (status >= 400) throw new Error(`HTTP ${status}`);
475
+ // Enforce the parent's PDF cap HERE, not only after the IPC transfer:
476
+ // buffering a multi-hundred-MB PDF (plus the base64 copy below) in the
477
+ // worker can OOM-kill the process — stranding every sibling task — or hit
478
+ // ERR_STRING_TOO_LONG. Content-Length first (free when present, and it
479
+ // skips the read entirely)...
480
+ const declaredLength = parseInt((await response.headerValue('content-length').catch(() => null)) || '', 10);
481
+ if (Number.isFinite(declaredLength) && declaredLength > MAX_PDF_SIZE) {
482
+ const sizeMB = Math.round(declaredLength / 1024 / 1024);
483
+ throw new Error(`PDF too large (${sizeMB}MB, max 100MB)`);
484
+ }
464
485
  const buffer = await response.body();
486
+ // ...then the actual bytes: the header is advisory and absent on chunked
487
+ // responses, so the byte count is the check that actually bounds memory.
488
+ // Checked BEFORE toString('base64') doubles the allocation.
489
+ if (buffer.byteLength > MAX_PDF_SIZE) {
490
+ const sizeMB = Math.round(buffer.byteLength / 1024 / 1024);
491
+ throw new Error(`PDF too large (${sizeMB}MB, max 100MB)`);
492
+ }
465
493
  // Discard the bytes if any observed response came from a private address.
466
494
  await Promise.allSettled(pendingAddrChecks);
467
495
  if (poisonedError) throw poisonedError;
468
496
  await page.close();
469
- return { contentType, buffer, jitter: 0 };
497
+ // Base64, never a raw Buffer: this result crosses the poolifier cluster IPC
498
+ // channel (default JSON serialization), where a Buffer arrives as
499
+ // {type:'Buffer',data:[...]} — and the follower path adds a second JSON hop
500
+ // through the leader's browser-server. A base64 string survives both hops
501
+ // verbatim (and is ~3x smaller on the wire than the data-array form).
502
+ return { contentType, bufferB64: buffer.toString('base64'), jitter: 0 };
470
503
  }
471
504
 
472
505
  // If it's HTML, check if we need to wait longer (JS-heavy sites)
@@ -476,6 +509,15 @@ export async function executeScrapeTask(
476
509
  const cfPatterns = ['_cf_chl_', 'cdn-cgi/challenge-platform', 'cf_chl_opt', 'Just a moment...', 'Checking your browser before accessing'];
477
510
  const hasCloudflare = cfPatterns.some((pattern: string) => html.includes(pattern));
478
511
 
512
+ // HTTP-error enforcement for HTML (see the PDF branch above). A Cloudflare
513
+ // challenge legitimately arrives with 403/503 and the wait below may clear
514
+ // it to the real page, so a challenged response defers to that wait — whose
515
+ // failure path already throws its own 'Fetch blocked:' error. Everything
516
+ // else with a 4xx/5xx status is an error page, not content.
517
+ if (status >= 400 && !hasCloudflare) {
518
+ throw new Error(`HTTP ${status}`);
519
+ }
520
+
479
521
  if (hasCloudflare) {
480
522
  logToDebugFile('WARN', `[Worker-${workerId}] Cloudflare challenge detected for: ${url}`);
481
523
 
@@ -521,6 +563,16 @@ export async function executeScrapeTask(
521
563
  html = await page.content();
522
564
  }
523
565
 
566
+ // Enforce the parent's rendered-HTML cap worker-side, BEFORE the string
567
+ // crosses JSON IPC (and, for followers, a second JSON hop through the
568
+ // leader's browser-server). The parent's identical check in web-scraper.ts
569
+ // runs only after the transfer — too late to protect this process from a
570
+ // pathological/hostile page ballooning worker memory. Same error wording.
571
+ if (html.length > MAX_HTML_SIZE) {
572
+ const sizeMB = Math.round(html.length / 1024 / 1024);
573
+ throw new Error(`Browser HTML too large (${sizeMB}MB, max 25MB)`);
574
+ }
575
+
524
576
  // Skip jitter in mock mode; in real browsing, add 500–1500ms to mimic human behavior
525
577
  const jitter = process.env['PI_RESEARCH_MOCK_SCRAPE'] === 'true'
526
578
  ? 0
@@ -19,7 +19,9 @@ export interface TaskResult {
19
19
  error?: string;
20
20
  success?: boolean;
21
21
  navMs?: number;
22
- buffer?: Buffer;
22
+ /** PDF bytes, base64-encoded: a raw Buffer would be mangled to
23
+ * {type:'Buffer',data:[...]} by the cluster-IPC JSON serialization. */
24
+ bufferB64?: string;
23
25
  html?: string;
24
26
  contentType?: string;
25
27
  }