@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
@@ -24,6 +24,52 @@ import { cleanupOrphanedCamoufoxProcesses, getBrowserPidsForWorkers, killBrowser
24
24
  import { raceWithDeadline } from '../../utils/safe-unref.ts';
25
25
  import { PriorityTaskQueue } from './priority-task-queue.ts';
26
26
 
27
+ /**
28
+ * Grace added to the worker's own task budget before the dead-worker backstop
29
+ * fires, so a live worker's more informative timeout always answers first.
30
+ */
31
+ export const WORKER_DEATH_BACKSTOP_GRACE_MS = 5000;
32
+
33
+ /**
34
+ * Execute a pool task with a dead-worker backstop.
35
+ *
36
+ * poolifier (measured on 5.3.2) never settles an in-flight execute() whose worker
37
+ * dies — not when the worker exits, not when a replacement is respawned, not even
38
+ * when the pool is destroyed. The worker enforces taskTimeoutMs on itself, but only
39
+ * while it is alive; an OOM-killed worker leaves the promise pending forever, and
40
+ * with it the PriorityTaskQueue concurrency slot the dispatched closure holds. Each
41
+ * such death would permanently shrink the queue until the leader serves nothing but
42
+ * timeouts.
43
+ *
44
+ * The backstop timer is created inside the dispatched closure, so it shares no
45
+ * lifetime with the caller's outer race — whose `finally` clearing a shared timer
46
+ * is exactly how a QUEUED task once lost its guard. Armed at dispatch, it fires
47
+ * only when nothing is left to answer; its rejection frees the queue slot (the
48
+ * caller has already been answered by the outer race).
49
+ */
50
+ export async function executePoolTaskWithDeathBackstop<T>(
51
+ execute: () => Promise<T>,
52
+ taskTimeoutMs: number,
53
+ label: string,
54
+ ): Promise<T> {
55
+ const backstopMs = taskTimeoutMs + WORKER_DEATH_BACKSTOP_GRACE_MS;
56
+ let backstopId: NodeJS.Timeout | undefined;
57
+ try {
58
+ return await Promise.race([
59
+ execute(),
60
+ new Promise<never>((_, reject) => {
61
+ backstopId = setTimeout(
62
+ () => reject(new Error(`${label} task unanswered after ${backstopMs}ms — worker likely died mid-task`)),
63
+ backstopMs,
64
+ );
65
+ if (backstopId.unref) backstopId.unref();
66
+ }),
67
+ ]);
68
+ } finally {
69
+ if (backstopId) clearTimeout(backstopId);
70
+ }
71
+ }
72
+
27
73
  /**
28
74
  * Browser task scheduler - manages the worker pool and executes tasks.
29
75
  * Only the leader process has an instance of this scheduler.
@@ -156,9 +202,16 @@ export class BrowserTaskScheduler implements IScheduler {
156
202
 
157
203
  async startServer(): Promise<number> {
158
204
  this.server = new BrowserServer({
159
- onSearch: (q) => this.runSearch(q),
160
- onScrape: (u) => this.runScrape(u),
161
- onHealthCheck: () => this.runHealthCheck(),
205
+ // The per-request signal aborts when the follower's socket closes
206
+ // before completion, so the leader-side task releases its queue
207
+ // claim (abort-while-queued/running is the PriorityTaskQueue's own
208
+ // pinned semantics) instead of holding the slot to full timeout.
209
+ // Healthcheck mirrors search/scrape here even though its one current
210
+ // production caller (src/healthcheck/index.ts) never passes a signal —
211
+ // the wiring must not silently drop one the moment a caller does.
212
+ onSearch: (q, signal) => this.runSearch(q, undefined, signal),
213
+ onScrape: (u, signal) => this.runScrape(u, undefined, signal),
214
+ onHealthCheck: (signal) => this.runHealthCheck(undefined, signal),
162
215
  });
163
216
  // FIX (#21): Expose auth secret to child processes via env
164
217
  process.env['PI_BROWSER_AUTH_SECRET'] = getBrowserServerAuthSecret();
@@ -192,17 +245,29 @@ export class BrowserTaskScheduler implements IScheduler {
192
245
 
193
246
  logger.debug(`[BrowserTaskScheduler] Executing search: "${query}" (Timeout: ${timeoutMs}ms)`);
194
247
  let result: any;
248
+ // Settling the outer race must also release the task's queue claim: a task
249
+ // still QUEUED when the caller is answered is cancelled before it consumes
250
+ // a slot, and a RUNNING one frees its slot instead of keeping it (plus a
251
+ // fresh worker budget) for a result nobody will consume. The abort-while-
252
+ // queued/running semantics are the queue's own, pinned by its tests.
253
+ const settled = new AbortController();
254
+ const queueSignal = signal ? AbortSignal.any([signal, settled.signal]) : settled.signal;
195
255
  try {
196
256
  const queue = this.getPriorityQueue(config);
197
257
  // Race the enqueue call against the timeoutPromise.
198
258
  // This ensures that even if the queue is saturated, we won't hang forever.
199
259
  result = await Promise.race([
200
260
  queue.enqueue('search', async () => {
201
- return await Promise.race([
202
- pool.execute({ type: 'search', query, queuedAt: startTime, taskTimeoutMs: timeoutMs }),
203
- timeoutPromise
204
- ]);
205
- }, signal),
261
+ // The shared timeoutPromise is deliberately NOT raced in here (its timer
262
+ // dies with the outer race); the backstop below carries its own timer, so
263
+ // a QUEUED task can never be dispatched against a disarmed guard, and a
264
+ // worker dying mid-task cannot pin the queue slot forever.
265
+ return await executePoolTaskWithDeathBackstop(
266
+ () => pool.execute({ type: 'search', query, queuedAt: startTime, taskTimeoutMs: timeoutMs }),
267
+ timeoutMs,
268
+ 'Search',
269
+ );
270
+ }, queueSignal),
206
271
  timeoutPromise
207
272
  ]);
208
273
  logger.debug(`[BrowserTaskScheduler] Search completed: "${query}" in ${Date.now() - startTime}ms`);
@@ -215,6 +280,18 @@ export class BrowserTaskScheduler implements IScheduler {
215
280
  logger.debug(`[BrowserTaskScheduler] Search abandoned during shutdown: "${query}"`);
216
281
  throw error;
217
282
  }
283
+ // The caller's own signal aborting (follower socket closed / research run
284
+ // cancelled) is what PriorityTaskQueue's abort-while-queued/running
285
+ // rejection above reports. This is routine, expected cancellation, not a
286
+ // fault — cancelling a run routinely cancels its in-flight browser tasks,
287
+ // so this fires often. Checking the signal directly (rather than matching
288
+ // the queue's rejection text) mirrors how BrowserServer's own catch block
289
+ // and task-execution-service.ts already classify this. Demote to DEBUG so
290
+ // it doesn't inflate ERROR counts / browser_search_errors_total.
291
+ if (signal?.aborted) {
292
+ logger.debug(`[BrowserTaskScheduler] Search cancelled by caller: "${query}"`);
293
+ throw error;
294
+ }
218
295
  logger.error(`[BrowserTaskScheduler] Search failed: "${query}"`, error);
219
296
  metrics.increment('browser_search_errors_total', 1);
220
297
  errorTracker.trackError(error instanceof Error ? error : String(error), {
@@ -226,11 +303,14 @@ export class BrowserTaskScheduler implements IScheduler {
226
303
  throw error;
227
304
  } finally {
228
305
  if (timeoutId) clearTimeout(timeoutId);
306
+ settled.abort();
229
307
  }
230
308
 
231
309
  const duration = Date.now() - startTime;
232
- metrics.observe('browser_search_duration_ms', duration, { status: 'success' });
233
- metrics.increment('browser_search_requests_total', 1, { status: 'success' });
310
+ // In-band worker error checked FIRST: a task the worker reports as failed
311
+ // must count only as an error. Recording success unconditionally before
312
+ // this check double-counted every worker-reported failure as
313
+ // success+error, so dashboards read healthy through a full outage.
234
314
  if (result.error) {
235
315
  metrics.increment('browser_search_requests_total', 1, { status: 'error' });
236
316
  errorTracker.trackError(new Error(result.error), {
@@ -242,6 +322,8 @@ export class BrowserTaskScheduler implements IScheduler {
242
322
  });
243
323
  throw new Error(result.error);
244
324
  }
325
+ metrics.observe('browser_search_duration_ms', duration, { status: 'success' });
326
+ metrics.increment('browser_search_requests_total', 1, { status: 'success' });
245
327
  return result.results;
246
328
  }
247
329
 
@@ -265,15 +347,20 @@ export class BrowserTaskScheduler implements IScheduler {
265
347
  });
266
348
 
267
349
  let result: any;
350
+ // See runSearch: outer settle releases the queue claim; the backstop bounds
351
+ // a dead worker's never-settling execute().
352
+ const settled = new AbortController();
353
+ const queueSignal = signal ? AbortSignal.any([signal, settled.signal]) : settled.signal;
268
354
  try {
269
355
  const queue = this.getPriorityQueue(config);
270
356
  result = await Promise.race([
271
357
  queue.enqueue('scrape', async () => {
272
- return await Promise.race([
273
- pool.execute({ type: 'scrape', url, queuedAt: startTime, taskTimeoutMs: timeoutMs }),
274
- timeoutPromise
275
- ]);
276
- }, signal),
358
+ return await executePoolTaskWithDeathBackstop(
359
+ () => pool.execute({ type: 'scrape', url, queuedAt: startTime, taskTimeoutMs: timeoutMs }),
360
+ timeoutMs,
361
+ 'Scrape',
362
+ );
363
+ }, queueSignal),
277
364
  timeoutPromise
278
365
  ]);
279
366
  } catch (error) {
@@ -283,6 +370,12 @@ export class BrowserTaskScheduler implements IScheduler {
283
370
  logger.debug(`[BrowserTaskScheduler] Scrape abandoned during shutdown: "${url}"`);
284
371
  throw error;
285
372
  }
373
+ // See runSearch: the caller's own signal aborting is routine cancellation
374
+ // (follower disconnect / run cancelled), not a fault — demote to DEBUG.
375
+ if (signal?.aborted) {
376
+ logger.debug(`[BrowserTaskScheduler] Scrape cancelled by caller: "${url}"`);
377
+ throw error;
378
+ }
286
379
  metrics.increment('browser_scrape_errors_total', 1);
287
380
  errorTracker.trackError(error instanceof Error ? error : String(error), {
288
381
  component: 'browser-manager',
@@ -292,11 +385,11 @@ export class BrowserTaskScheduler implements IScheduler {
292
385
  throw error;
293
386
  } finally {
294
387
  if (timeoutId) clearTimeout(timeoutId);
388
+ settled.abort();
295
389
  }
296
390
 
297
391
  const duration = Date.now() - startTime;
298
- metrics.observe('browser_scrape_duration_ms', duration, { status: 'success' });
299
- metrics.increment('browser_scrape_requests_total', 1, { status: 'success' });
392
+ // In-band error first — success-side recording must not precede it (see runSearch).
300
393
  if (result.error) {
301
394
  metrics.increment('browser_scrape_requests_total', 1, { status: 'error' });
302
395
  errorTracker.trackError(new Error(result.error), {
@@ -307,6 +400,8 @@ export class BrowserTaskScheduler implements IScheduler {
307
400
  });
308
401
  throw new Error(result.error);
309
402
  }
403
+ metrics.observe('browser_scrape_duration_ms', duration, { status: 'success' });
404
+ metrics.increment('browser_scrape_requests_total', 1, { status: 'success' });
310
405
  return result;
311
406
  }
312
407
 
@@ -324,17 +419,20 @@ export class BrowserTaskScheduler implements IScheduler {
324
419
  });
325
420
 
326
421
  let result: { success: boolean; error?: string };
422
+ // See runSearch: outer settle releases the queue claim; the backstop bounds
423
+ // a dead worker's never-settling execute().
424
+ const settled = new AbortController();
425
+ const queueSignal = signal ? AbortSignal.any([signal, settled.signal]) : settled.signal;
327
426
  try {
328
427
  const queue = this.getPriorityQueue(config);
329
428
  result = await Promise.race([
330
429
  queue.enqueue('healthcheck', async () => {
331
- const execPromise = pool.execute({ type: 'healthcheck', queuedAt: startTime, taskTimeoutMs: timeoutMs });
332
- execPromise.catch((err: Error) => logger.debug(`[BrowserTaskScheduler] Background healthcheck task rejection: ${err.message}`));
333
- return await Promise.race([
334
- execPromise,
335
- timeoutPromise
336
- ]);
337
- }, signal),
430
+ return await executePoolTaskWithDeathBackstop(() => {
431
+ const execPromise = pool.execute({ type: 'healthcheck', queuedAt: startTime, taskTimeoutMs: timeoutMs });
432
+ execPromise.catch((err: Error) => logger.debug(`[BrowserTaskScheduler] Background healthcheck task rejection: ${err.message}`));
433
+ return execPromise;
434
+ }, timeoutMs, 'Healthcheck');
435
+ }, queueSignal),
338
436
  timeoutPromise
339
437
  ]) as { success: boolean; error?: string };
340
438
  } catch (error) {
@@ -344,6 +442,13 @@ export class BrowserTaskScheduler implements IScheduler {
344
442
  logger.debug(`[BrowserTaskScheduler] Healthcheck abandoned during shutdown`);
345
443
  throw error;
346
444
  }
445
+ // See runSearch: the caller's own signal aborting is routine cancellation,
446
+ // not a fault — demote to DEBUG. Reachable now that startServer() forwards
447
+ // a signal into onHealthCheck (see BrowserServer's onHealthCheck wiring).
448
+ if (signal?.aborted) {
449
+ logger.debug(`[BrowserTaskScheduler] Healthcheck cancelled by caller`);
450
+ throw error;
451
+ }
347
452
  metrics.increment('browser_healthcheck_errors_total', 1);
348
453
  errorTracker.trackError(error instanceof Error ? error : String(error), {
349
454
  component: 'browser-manager',
@@ -353,13 +458,14 @@ export class BrowserTaskScheduler implements IScheduler {
353
458
  throw error;
354
459
  } finally {
355
460
  if (timeoutId) clearTimeout(timeoutId);
461
+ settled.abort();
356
462
  }
357
463
 
358
464
  const duration = Date.now() - startTime;
359
- metrics.observe('browser_healthcheck_duration_ms', duration, { status: 'success' });
360
- metrics.increment('browser_healthcheck_requests_total', 1, { status: 'success' });
361
- metrics.setGauge('browser_pool_health', 1);
362
- logger.debug(`[Scheduler] Healthcheck completed in ${duration}ms`);
465
+ // In-band error first — success-side recording must not precede it (see
466
+ // runSearch). This path additionally owns the browser_pool_health gauge:
467
+ // setting it to 1 before the check flickered the pool healthy on every
468
+ // failed probe during an outage.
363
469
  if (result.error) {
364
470
  metrics.increment('browser_healthcheck_requests_total', 1, { status: 'error' });
365
471
  metrics.setGauge('browser_pool_health', 0);
@@ -371,6 +477,10 @@ export class BrowserTaskScheduler implements IScheduler {
371
477
  });
372
478
  throw new Error(result.error);
373
479
  }
480
+ metrics.observe('browser_healthcheck_duration_ms', duration, { status: 'success' });
481
+ metrics.increment('browser_healthcheck_requests_total', 1, { status: 'success' });
482
+ metrics.setGauge('browser_pool_health', 1);
483
+ logger.debug(`[Scheduler] Healthcheck completed in ${duration}ms`);
374
484
  return result;
375
485
  }
376
486
 
@@ -411,12 +521,21 @@ export class BrowserTaskScheduler implements IScheduler {
411
521
  // reference would be moot. Guarding here keeps shutdown from logging a
412
522
  // spurious "Cannot get service 'scheduler' during container disposal".
413
523
  if (!this.container.isDisposing) {
414
- const schedulerService = await getService<ISchedulerInternals>(ServiceNames.SCHEDULER, undefined, this.container);
415
- const currentScheduler = schedulerService.getSchedulerInstance();
416
- if (currentScheduler && 'schedulerId' in currentScheduler && currentScheduler.schedulerId === this.schedulerId) {
417
- schedulerService.setSchedulerInstance(null);
418
- schedulerService.setSchedulerVersion(null);
419
- schedulerService.setSchedulerInitializationPromise(null);
524
+ try {
525
+ const schedulerService = await getService<ISchedulerInternals>(ServiceNames.SCHEDULER, undefined, this.container);
526
+ const currentScheduler = schedulerService.getSchedulerInstance();
527
+ if (currentScheduler && 'schedulerId' in currentScheduler && currentScheduler.schedulerId === this.schedulerId) {
528
+ schedulerService.setSchedulerInstance(null);
529
+ schedulerService.setSchedulerVersion(null);
530
+ schedulerService.setSchedulerInitializationPromise(null);
531
+ }
532
+ } catch (err) {
533
+ // Disposal can begin between the isDisposing check above and the
534
+ // getService call. isShuttingDown is already latched true, so a
535
+ // throw here would abort shutdown permanently — drain, state-clear,
536
+ // server-stop and pool teardown would all be skipped. The reference
537
+ // being unclearable is the same moot case the guard above covers.
538
+ logger.debug('[Scheduler] Could not clear scheduler service reference during shutdown:', err);
420
539
  }
421
540
  }
422
541
 
@@ -427,7 +546,12 @@ export class BrowserTaskScheduler implements IScheduler {
427
546
  logger.warn('[Scheduler] Could not read browser server state during shutdown:', err);
428
547
  }
429
548
  if (serverInfo?.pid === process.pid && serverInfo?.schedulerId === this.schedulerId) {
430
- await this.stateManager.clearBrowserServer().catch((err) => {
549
+ // CAS: this belief-check (pid + schedulerId match) happens outside any
550
+ // lock, so pass it as `expected` rather than relying on it as an
551
+ // assumption — if a successor claimed the slot between the read above
552
+ // and this clear, the mismatch makes the clear a no-op instead of
553
+ // deregistering the new owner.
554
+ await this.stateManager.clearBrowserServer({ pid: process.pid, schedulerId: this.schedulerId }).catch((err) => {
431
555
  logger.warn('[Scheduler] Failed to clear browser server state during shutdown:', err);
432
556
  });
433
557
  }
@@ -9,8 +9,9 @@ const DEFAULT_STALE_MS = 30 * 24 * 60 * 60 * 1000; // 30 days
9
9
  * Firefox/Camoufox profile lock files, cross-platform. A live browser holds:
10
10
  * - Unix: `lock` — a SYMLINK whose target encodes the owner: "<ip>:+<pid>" or "+<pid>",
11
11
  * plus `.parentlock` (an fcntl-locked empty file).
12
- * - Windows: `parent.lock` — a file kept OPEN by the live process (so the OS refuses to
13
- * delete it, which is itself a safety net against reclaiming an in-use profile).
12
+ * - Windows: `parent.lock` — a file kept OPEN by the live process. That is a liveness
13
+ * SIGNAL (see isWindowsParentLockHeld), not a safety net: a recursive delete
14
+ * has already removed the rest of the profile before it reaches this file.
14
15
  * The previous code checked for `.lock`, which Firefox/Camoufox never creates, so its
15
16
  * lock-awareness was inert. These are the real names.
16
17
  */
@@ -20,6 +21,37 @@ const LOCK_FILE_NAMES = ['lock', '.parentlock', 'parent.lock'];
20
21
  * so a just-starting concurrent run is not reclaimed out from under itself. */
21
22
  const LOCK_FRESHNESS_MS = 2 * 60 * 1000;
22
23
 
24
+ /**
25
+ * Windows: is `parent.lock` still held open by a live browser?
26
+ *
27
+ * Firefox opens this file with no sharing, so while the browser runs ANY other
28
+ * open of it fails with a sharing violation. That makes the probe positive
29
+ * evidence of a live owner — the same standing as the Unix `lock` symlink's PID.
30
+ *
31
+ * Recency cannot substitute for it here: `parent.lock`'s mtime is stamped once at
32
+ * browser startup and never refreshed, so every browser alive longer than
33
+ * LOCK_FRESHNESS_MS read as dead — the normal case for a minutes-long research
34
+ * run. Nor does "the OS refuses to delete a held-open file" save the profile:
35
+ * `fs.rm({recursive:true})` unlinks entries one at a time, so prefs, cookies and
36
+ * places are already gone by the time it reaches the locked file and fails.
37
+ *
38
+ * Returns null when the answer is not knowable, which callers treat as live —
39
+ * sparing a leaked profile costs disk, deleting a live one corrupts a running run.
40
+ */
41
+ async function isWindowsParentLockHeld(lockPath: string): Promise<boolean | null> {
42
+ let handle: Awaited<ReturnType<typeof fs.open>> | undefined;
43
+ try {
44
+ handle = await fs.open(lockPath, 'r');
45
+ } catch (e) {
46
+ const code = (e as NodeJS.ErrnoException)?.code;
47
+ if (code === 'ENOENT') return false; // vanished — no live owner
48
+ if (code === 'EBUSY' || code === 'EPERM' || code === 'EACCES') return true; // held open
49
+ return null; // unexpected — don't guess
50
+ }
51
+ await handle.close().catch(() => {});
52
+ return false;
53
+ }
54
+
23
55
  /** Parse the owner PID out of a Firefox `lock` symlink target ("<ip>:+<pid>" / "+<pid>"). */
24
56
  function parsePidFromLockTarget(target: string): number | null {
25
57
  const m = /\+(\d+)\s*$/.exec(target);
@@ -62,14 +94,62 @@ async function isProfileLive(profileDir: string): Promise<boolean> {
62
94
  // unreadable symlink — fall back to freshness below
63
95
  }
64
96
  }
65
- // `.parentlock` / `parent.lock` (no PID encoded), or an unreadable `lock`: use recency
66
- // as the liveness proxy. On Windows the file is also held open, so an attempted delete
67
- // of a genuinely-live profile fails harmlessly regardless of this heuristic.
97
+ // Windows has no `lock` symlink, so `parent.lock` is the only signal there. Probe
98
+ // whether it is still held open — authoritative, unlike recency, which reads every
99
+ // browser older than LOCK_FRESHNESS_MS as dead because the mtime is never refreshed.
100
+ if (process.platform === 'win32' && name === 'parent.lock') {
101
+ const held = await isWindowsParentLockHeld(lockPath);
102
+ if (held !== null) return held;
103
+ return true; // indeterminate — spare the profile
104
+ }
105
+ // `.parentlock` (no PID encoded) or an unreadable `lock`: use recency as the proxy.
68
106
  return Date.now() - lst.mtimeMs < LOCK_FRESHNESS_MS;
69
107
  }
70
108
  return false;
71
109
  }
72
110
 
111
+ /** Canonicalize a path for identity comparison: realpath when resolvable (macOS
112
+ * /tmp is a symlink to /private/tmp), else a plain resolve; case-folded on
113
+ * Windows, whose filesystem compares case-insensitively. */
114
+ async function canonicalizeForCompare(p: string): Promise<string> {
115
+ let resolved: string;
116
+ try {
117
+ resolved = await fs.realpath(p);
118
+ } catch {
119
+ resolved = path.resolve(p);
120
+ }
121
+ return process.platform === 'win32' ? resolved.toLowerCase() : resolved;
122
+ }
123
+
124
+ /**
125
+ * May the unrecognized-entry deletion branch run against this explicit baseDir?
126
+ *
127
+ * That branch recursively rm's ANY stale subdirectory, so it is only safe inside
128
+ * a directory pi-research owns outright. Two conditions gate it: the dir must not
129
+ * BE the shared system temp dir (realpath-compared on both sides — a
130
+ * PI_RESEARCH_TMP_DIR pointed at /tmp once made every same-user /tmp entry — ssh
131
+ * agents, build caches — a sweep candidate), and the path must carry a
132
+ * pi-research segment as positive evidence of ownership. When this returns false
133
+ * the prefix-matched playwright profile cleanup still runs; only unknowns are
134
+ * spared.
135
+ */
136
+ async function isPiOwnedSweepDir(dir: string): Promise<boolean> {
137
+ const [realDir, realTmp] = await Promise.all([
138
+ canonicalizeForCompare(dir),
139
+ canonicalizeForCompare(os.tmpdir()),
140
+ ]);
141
+ if (realDir === realTmp) return false;
142
+ const hasPiSegment = (p: string) =>
143
+ p.split(/[\\/]+/).some((segment) => segment.toLowerCase().startsWith('pi-research'));
144
+ // Only the CANONICAL path counts. A symlinked dir whose target lacks the
145
+ // marker is NOT sweep-safe: approving it by its configured name would send the
146
+ // recursive rm THROUGH the symlink into a directory we do not own (e.g. a
147
+ // /tmp/pi-research/profiles link pre-created by another user under a
148
+ // world-writable PI_RESEARCH_TMP_DIR). Unknowns are spared; the prefix-matched
149
+ // playwright profile cleanup still runs either way.
150
+ return hasPiSegment(realDir);
151
+ }
152
+
73
153
  /**
74
154
  * Reclaim leaked Playwright/Camoufox profile directories.
75
155
  *
@@ -96,6 +176,11 @@ export async function cleanupStaleProfiles(
96
176
  let removed = 0;
97
177
  let errors = 0;
98
178
 
179
+ // Explicit-baseDir mode considers EVERY entry a candidate, including ones it
180
+ // does not recognize; deleting unknowns is only permitted inside a dir that is
181
+ // provably pi-owned and not the shared system temp dir (see isPiOwnedSweepDir).
182
+ const sweepUnrecognized = usePrefix ? false : await isPiOwnedSweepDir(scanDir);
183
+
99
184
  try {
100
185
  const entries = await fs.readdir(scanDir);
101
186
  const now = Date.now();
@@ -127,15 +212,16 @@ export async function cleanupStaleProfiles(
127
212
  logger.debug(`[Cleanup] Skipping profile ${entry} — owned by a live browser`);
128
213
  continue;
129
214
  }
130
- await fs.rm(fullPath, { recursive: true, force: true });
215
+ await fs.rm(fullPath, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 });
131
216
  removed++;
132
217
  continue;
133
218
  }
134
219
 
135
220
  // Unrecognized entry (only reachable with an explicit baseDir): be conservative —
136
- // only reclaim if both old AND not live, so we never delete something unknown promptly.
137
- if (now - stats.mtimeMs > staleMs && !(await isProfileLive(fullPath))) {
138
- await fs.rm(fullPath, { recursive: true, force: true });
221
+ // only reclaim inside a pi-owned dir, and only if both old AND not live,
222
+ // so nothing unknown is ever deleted promptly or outside our ownership.
223
+ if (sweepUnrecognized && now - stats.mtimeMs > staleMs && !(await isProfileLive(fullPath))) {
224
+ await fs.rm(fullPath, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 });
139
225
  removed++;
140
226
  }
141
227
  } catch (_e) {
@@ -10,16 +10,17 @@ import * as http from 'node:http';
10
10
  /**
11
11
  * Global HTTP Agent for high-concurrency client requests
12
12
  *
13
- * IMPORTANT: The socket timeout must be longer than the BrowserClient request timeout (120s)
14
- * to prevent premature socket closure when the browser is slow or the queue is deep.
13
+ * IMPORTANT: The socket timeout must exceed the largest possible BrowserClient
14
+ * request timeout (CLIENT_TIMEOUT_CAP_MS = 300s in browser-client.ts) so the
15
+ * request-level timer — whose error text is classified by the retry gates —
16
+ * always answers before the socket layer tears the connection down.
15
17
  */
16
18
  const clientAgent = new http.Agent({
17
19
  keepAlive: true,
18
20
  maxSockets: 100, // Allow up to 100 concurrent requests to the leader
19
21
  maxFreeSockets: 10,
20
- // Set timeout to 180s (3x the client timeout) to handle slow browser responses
21
- // and prevent "socket hang up" errors during peak load
22
- timeout: 180000
22
+ // Above CLIENT_TIMEOUT_CAP_MS (300s) with margin — see the invariant above.
23
+ timeout: 330000
23
24
  });
24
25
 
25
26
  /**
@@ -61,8 +61,9 @@ export function getBrowserCacheDir(): string {
61
61
  * os.tmpdir(): on systems where /tmp is tmpfs (RAM-backed), placing several
62
62
  * browser profiles there consumes RAM and, with an OOM killer like earlyoom
63
63
  * active, can contribute to the whole session being killed. Override with
64
- * PI_RESEARCH_TMP_DIR (config.TMP_DIR) — e.g. point it back at the system temp
65
- * dir to deliberately use tmpfs/RAM when there is memory headroom.
64
+ * PI_RESEARCH_TMP_DIR (config.TMP_DIR) — e.g. point it at the system temp dir
65
+ * to deliberately use tmpfs/RAM when there is memory headroom; profiles then
66
+ * land in <TMP_DIR>/pi-research/profiles, never in TMP_DIR itself.
66
67
  *
67
68
  * The directory is created if missing, because Playwright requires the parent
68
69
  * of its mkdtemp profile dir to already exist.
@@ -71,7 +72,17 @@ export function getBrowserProfileDir(config?: Config): string {
71
72
  const configured = (config || getConfig()).TMP_DIR;
72
73
  let dir: string;
73
74
  if (configured && configured.trim().length > 0) {
74
- dir = configured;
75
+ // Containment invariant: profiles live in a pi-research-owned
76
+ // SUBDIRECTORY of the configured dir, never the configured dir itself.
77
+ // The pool-startup sweep (cleanupStaleProfiles with an explicit baseDir)
78
+ // treats every entry of this directory as a reclaim candidate, so using
79
+ // PI_RESEARCH_TMP_DIR verbatim — e.g. pointed at /tmp per the tmpfs
80
+ // opt-in above — put unrelated same-user directories (ssh agents, build
81
+ // caches) on the sweep's candidate list. The pi-research/profiles shape
82
+ // also matches PI_BROWSER_MARKER's profile-path fallback in
83
+ // browser-cleanup.ts, so orphan detection keeps working for relocated
84
+ // profiles.
85
+ dir = join(configured, 'pi-research', 'profiles');
75
86
  } else {
76
87
  const cacheHome = process.env['XDG_CACHE_HOME'] || join(homedir(), '.cache');
77
88
  dir = join(cacheHome, 'pi-research', 'profiles');
@@ -149,11 +149,18 @@ export class PriorityTaskQueue {
149
149
  // on its own; we swallow its late settlement and free the slot now so the
150
150
  // queue can dispatch the next task immediately.
151
151
  let onAbort: (() => void) | undefined;
152
- const fnPromise = task.fn();
153
- // Prevent an unhandled rejection if abort wins the race and fn() rejects later.
154
- fnPromise.catch(() => { /* handled via the race below or abandoned on abort */ });
155
152
 
156
153
  try {
154
+ // task.fn() is invoked INSIDE the try: a non-async fn that throws
155
+ // synchronously would otherwise escape before the finally that decrements
156
+ // activeCount, permanently inflating the active count (starving the queue
157
+ // by one slot each time) and leaving the caller's enqueue() promise
158
+ // unsettled. Every fn today is async, so this is a latent shape defect
159
+ // rather than a live one — but the correct placement costs nothing.
160
+ const fnPromise = task.fn();
161
+ // Prevent an unhandled rejection if abort wins the race and fn() rejects later.
162
+ fnPromise.catch(() => { /* handled via the race below or abandoned on abort */ });
163
+
157
164
  let result: any;
158
165
  if (task.signal) {
159
166
  const abortPromise = new Promise<never>((_, rej) => {
@@ -132,7 +132,16 @@ export async function forceSchedulerRestart(forceClearRemoteState: boolean = fal
132
132
  }
133
133
 
134
134
  if (shouldClearState) {
135
- await stateManager.clearBrowserServer().catch((error) => {
135
+ // Compare-and-delete against the registration we just read: without a
136
+ // lock spanning the read-decide-clear sequence above, a concurrent
137
+ // process can independently detect the same dead leader, complete its
138
+ // own (properly locked) election in getScheduler(), and publish a
139
+ // fresh registration in this exact window. An unconditional clear here
140
+ // would wipe that brand-new, legitimate leader — it keeps running but
141
+ // becomes undiscoverable via state, so the next caller elects a
142
+ // second, redundant leader. See StateBrowserManager.clearBrowserServer.
143
+ const expected = serverInfo ? { pid: serverInfo.pid, schedulerId: serverInfo.schedulerId } : undefined;
144
+ await stateManager.clearBrowserServer(expected).catch((error) => {
136
145
  logger.warn('[Scheduler] Failed to clear browser server from state:', error);
137
146
  });
138
147
  }
@@ -222,12 +231,18 @@ export async function getScheduler(config?: Config, container: ServiceContainer
222
231
  if (serverInfo.pid !== process.pid) {
223
232
  logger.log(`[Scheduler] Bypassing stale scheduler process (PID ${serverInfo.pid}) by clearing state...`);
224
233
  }
225
- await stateManager.clearBrowserServer();
234
+ // Defense-in-depth CAS: this read-then-clear is entirely inside
235
+ // the browser-init lock, which serializes other getScheduler()
236
+ // callers, but callers that clear without taking this lock
237
+ // (forceSchedulerRestart, scheduler shutdown) are not excluded —
238
+ // pass what was just read so a mismatch no-ops instead of
239
+ // deleting a registration this code didn't observe.
240
+ await stateManager.clearBrowserServer({ pid: serverInfo.pid, schedulerId: serverInfo.schedulerId });
226
241
  } else {
227
242
  const portOk = await isPortListening(serverInfo.port);
228
243
  if (!portOk) {
229
244
  logger.warn(`[Scheduler] PID ${serverInfo.pid} is alive but port ${serverInfo.port} is not listening — clearing stale state and starting fresh.`);
230
- await stateManager.clearBrowserServer().catch((e) => {
245
+ await stateManager.clearBrowserServer({ pid: serverInfo.pid, schedulerId: serverInfo.schedulerId }).catch((e) => {
231
246
  logger.warn('[Scheduler] Failed to clear stale browser server state:', e);
232
247
  });
233
248
  } else {
@@ -282,6 +297,25 @@ export async function getScheduler(config?: Config, container: ServiceContainer
282
297
  let wonElection = false;
283
298
  let winnerPort = port;
284
299
  let winnerAuthSecret: string | undefined;
300
+ // Read our start-time BEFORE taking the state lock (the lookup can shell
301
+ // out on macOS/Windows; getCurrentProcessStartTime caches, so this is a
302
+ // single cheap read). Recording it alongside the PID is what makes
303
+ // StateManager.isPidAlive's PID-reuse guard work: that guard passes
304
+ // `state.browserServer.startTime` as expectedStartTime, so an election
305
+ // that writes only the PID leaves it permanently undefined and the guard
306
+ // degrades to a bare signal-0 check — which a recycled PID passes,
307
+ // making every follower adopt an unrelated process as the browser leader.
308
+ // Null on a platform where it cannot be read → the entry omits it and
309
+ // liveness falls back to that bare check, same as pre-existing entries.
310
+ const myStartTime = await (async () => {
311
+ try {
312
+ const processLifecycle = await getService<IProcessLifecycle>(ServiceNames.PROCESS_LIFECYCLE, undefined, container);
313
+ return await processLifecycle.getCurrentProcessStartTime();
314
+ } catch (err) {
315
+ logger.debug('[Scheduler] Could not read own process start time (PID-reuse guard degrades to signal-0):', err);
316
+ return null;
317
+ }
318
+ })();
285
319
  try {
286
320
  await stateManager.updateState(async (state) => {
287
321
  if (state.browserServer) {
@@ -294,7 +328,13 @@ export async function getScheduler(config?: Config, container: ServiceContainer
294
328
  }
295
329
  }
296
330
  const secret = getBrowserServerAuthSecret();
297
- state.browserServer = { port, pid: process.pid, schedulerId, authSecret: secret };
331
+ state.browserServer = {
332
+ port,
333
+ pid: process.pid,
334
+ schedulerId,
335
+ authSecret: secret,
336
+ ...(myStartTime !== null ? { startTime: myStartTime } : {}),
337
+ };
298
338
  state.schedulerVersion = schedulerVersion;
299
339
  wonElection = true;
300
340
  return state;