@lincoln504/pi-research 0.1.13 → 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (248) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +55 -138
  3. package/dist/cli.mjs +23375 -0
  4. package/dist/prompts/research-knowledge-relevance-triage.md +35 -0
  5. package/dist/prompts/research-knowledge-search-extractor.md +41 -0
  6. package/dist/prompts/research-tool-usage.md +92 -0
  7. package/dist/prompts/researcher.md +89 -0
  8. package/dist/prompts/system-coordinator.md +75 -0
  9. package/dist/prompts/system-lead-evaluator.md +118 -0
  10. package/dist/thread-worker.mjs +2611 -0
  11. package/dist/webgpu-probe.mjs +80 -0
  12. package/docs/AGENT-SKILL.md +130 -0
  13. package/docs/ARCHITECTURE.md +306 -80
  14. package/docs/CONFIGURATION.md +249 -0
  15. package/docs/KNOWLEDGE-STORE.md +211 -0
  16. package/docs/PI-EXTENSION.md +91 -0
  17. package/docs/SDK.md +122 -0
  18. package/docs/THIRD-PARTY-NOTICES.md +66 -0
  19. package/package.json +102 -36
  20. package/scripts/build.cjs +140 -0
  21. package/scripts/cleanup.cjs +149 -0
  22. package/scripts/prepare.cjs +28 -0
  23. package/scripts/setup.cjs +134 -0
  24. package/skills/pi-research/README.md +90 -0
  25. package/skills/pi-research/SKILL.md +102 -0
  26. package/skills/pi-research/references/configuration.md +77 -0
  27. package/skills/pi-research/scripts/run.mjs +237 -0
  28. package/src/cleanup/research-cleanup.ts +140 -0
  29. package/src/cli.ts +1195 -0
  30. package/src/config.ts +1032 -155
  31. package/src/constants.ts +115 -84
  32. package/src/core/health-check-service.ts +265 -0
  33. package/src/core/interfaces/health-check-interfaces.ts +50 -0
  34. package/src/core/interfaces/knowledge-interfaces.ts +190 -0
  35. package/src/core/interfaces/observer-interfaces.ts +67 -0
  36. package/src/core/interfaces/orchestration-interfaces.ts +86 -0
  37. package/src/core/interfaces/planning-interfaces.ts +100 -0
  38. package/src/core/interfaces/process-interfaces.ts +37 -0
  39. package/src/core/interfaces/research-plan-types.ts +52 -0
  40. package/src/core/interfaces/scheduler-interfaces.ts +91 -0
  41. package/src/core/interfaces/service-names.ts +39 -0
  42. package/src/core/interfaces/state-manager-interfaces.ts +29 -0
  43. package/src/core/interfaces/steering-interfaces.ts +31 -0
  44. package/src/core/llm/agentic-repair.ts +169 -0
  45. package/src/core/llm/inject-date.ts +52 -0
  46. package/src/core/llm/llm-timeout.ts +64 -0
  47. package/src/core/llm/llm-utils.ts +86 -0
  48. package/src/core/llm/model-registry-factory.ts +287 -0
  49. package/src/core/llm/pi-ai-completion.ts +29 -0
  50. package/src/core/llm/prompts.ts +50 -0
  51. package/src/core/llm/research-model-resolver.ts +91 -0
  52. package/src/core/planning-constants.ts +13 -0
  53. package/src/core/planning-service.ts +649 -0
  54. package/src/core/planning-utils.ts +299 -0
  55. package/src/core/scheduler-factory.ts +48 -0
  56. package/src/core/scheduler-service.ts +123 -0
  57. package/src/core/service-initialization.ts +194 -0
  58. package/src/core/service-interfaces.ts +48 -0
  59. package/src/core/service-registry.ts +659 -0
  60. package/src/healthcheck/index.ts +271 -265
  61. package/src/healthcheck/registry.ts +130 -0
  62. package/src/index.ts +639 -50
  63. package/src/infrastructure/browser/browser-cleanup.ts +309 -0
  64. package/src/infrastructure/browser/browser-client.ts +199 -0
  65. package/src/infrastructure/browser/browser-error-utils.ts +160 -0
  66. package/src/infrastructure/browser/browser-lifecycle.ts +120 -0
  67. package/src/infrastructure/browser/browser-server.ts +174 -0
  68. package/src/infrastructure/browser/browser-task-scheduler.ts +461 -0
  69. package/src/infrastructure/browser/cleanup-utils.ts +155 -0
  70. package/src/infrastructure/browser/client-agent.ts +31 -0
  71. package/src/infrastructure/browser/config.ts +288 -0
  72. package/src/infrastructure/browser/ensure-browser.ts +205 -0
  73. package/src/infrastructure/browser/index.ts +37 -0
  74. package/src/infrastructure/browser/priority-task-queue.ts +226 -0
  75. package/src/infrastructure/browser/scheduler-factory.ts +354 -0
  76. package/src/infrastructure/browser/task-execution-service.ts +209 -0
  77. package/src/infrastructure/browser/thread-worker-browser.ts +282 -0
  78. package/src/infrastructure/browser/thread-worker-lifecycle.ts +323 -0
  79. package/src/infrastructure/browser/thread-worker-messaging.ts +662 -0
  80. package/src/infrastructure/browser/thread-worker-types.ts +25 -0
  81. package/src/infrastructure/browser/thread-worker.mjs +2611 -0
  82. package/src/infrastructure/browser/thread-worker.ts +168 -0
  83. package/src/infrastructure/browser/worker-pool-manager.ts +449 -0
  84. package/src/infrastructure/embedding/embedding-client.ts +205 -0
  85. package/src/infrastructure/embedding/embedding-factory.ts +324 -0
  86. package/src/infrastructure/embedding/embedding-server.ts +535 -0
  87. package/src/infrastructure/file-lock-service.ts +592 -0
  88. package/src/infrastructure/gpu-resource-service.ts +191 -0
  89. package/src/infrastructure/knowledge-store-service.ts +475 -0
  90. package/src/infrastructure/metrics-service.ts +129 -0
  91. package/src/infrastructure/process-lifecycle-service.ts +253 -0
  92. package/src/infrastructure/scheduler-factory-service.ts +58 -0
  93. package/src/infrastructure/service-initialization.ts +292 -0
  94. package/src/infrastructure/state/state-backup-manager.ts +290 -0
  95. package/src/infrastructure/state/state-browser-api.ts +62 -0
  96. package/src/infrastructure/state/state-browser-manager.ts +70 -0
  97. package/src/infrastructure/state/state-manager-service.ts +264 -0
  98. package/src/infrastructure/state/state-manager.ts +547 -0
  99. package/src/infrastructure/state/state-metrics.ts +92 -0
  100. package/src/infrastructure/state/state-migration.ts +103 -0
  101. package/src/infrastructure/state/state-path-configuration.ts +119 -0
  102. package/src/infrastructure/state/state-session-api.ts +94 -0
  103. package/src/infrastructure/state/state-session-manager.ts +127 -0
  104. package/src/infrastructure/state/state-validator.ts +93 -0
  105. package/src/infrastructure/types/state-types.ts +85 -0
  106. package/src/knowledge/chunker.ts +157 -0
  107. package/src/knowledge/embedder-init.ts +413 -0
  108. package/src/knowledge/embedder-utils.ts +296 -0
  109. package/src/knowledge/embedder.ts +592 -0
  110. package/src/knowledge/index.ts +126 -0
  111. package/src/knowledge/lancedb-loader.ts +35 -0
  112. package/src/knowledge/migration.ts +17 -0
  113. package/src/knowledge/model-config.ts +132 -0
  114. package/src/knowledge/onnx-env.ts +48 -0
  115. package/src/knowledge/store-operations.ts +291 -0
  116. package/src/knowledge/store-schema.ts +79 -0
  117. package/src/knowledge/store.ts +1251 -0
  118. package/src/knowledge/transformers-loader.ts +31 -0
  119. package/src/knowledge/webgpu-probe.mjs +80 -0
  120. package/src/knowledge/webgpu-viability.ts +255 -0
  121. package/src/knowledge/writer-queue.ts +252 -0
  122. package/src/logger.ts +316 -122
  123. package/src/observers/research-observer-impl.ts +459 -0
  124. package/src/orchestration/deep-research-orchestrator.ts +524 -773
  125. package/src/orchestration/deep-research-types.ts +47 -35
  126. package/src/orchestration/headless-observer.ts +114 -0
  127. package/src/orchestration/orchestration-types.ts +33 -0
  128. package/src/orchestration/quick-research-orchestrator.ts +463 -0
  129. package/src/orchestration/research-orchestration-service.ts +549 -0
  130. package/src/orchestration/research-session-service.ts +178 -0
  131. package/src/orchestration/research-synthesis-service.ts +348 -0
  132. package/src/orchestration/researcher-executor.ts +394 -0
  133. package/src/orchestration/researcher.ts +115 -42
  134. package/src/orchestration/service-initialization.ts +109 -0
  135. package/src/orchestration/session-context.ts +7 -3
  136. package/src/orchestration/session-state.ts +672 -0
  137. package/src/prompts/research-knowledge-relevance-triage.md +35 -0
  138. package/src/prompts/research-knowledge-search-extractor.md +41 -0
  139. package/src/prompts/research-tool-usage.md +63 -16
  140. package/src/prompts/researcher.md +59 -136
  141. package/src/prompts/system-coordinator.md +64 -13
  142. package/src/prompts/system-lead-evaluator.md +82 -189
  143. package/src/research-config.ts +872 -0
  144. package/src/sdk.ts +847 -0
  145. package/src/security/cisa-kev.ts +72 -9
  146. package/src/security/cvss.ts +92 -0
  147. package/src/security/ecosystem.ts +45 -0
  148. package/src/security/github-advisories.ts +168 -428
  149. package/src/security/github-advisory-types.ts +282 -0
  150. package/src/security/index.ts +63 -177
  151. package/src/security/nvd-client.ts +550 -0
  152. package/src/security/nvd-types.ts +179 -0
  153. package/src/security/osv-client.ts +246 -0
  154. package/src/security/osv-types.ts +285 -0
  155. package/src/security/types.ts +67 -53
  156. package/src/skill-install/skill-installer.ts +519 -0
  157. package/src/stackexchange/index.ts +81 -27
  158. package/src/stackexchange/output/compact.ts +7 -6
  159. package/src/stackexchange/output/table.ts +11 -7
  160. package/src/stackexchange/queries.ts +0 -10
  161. package/src/stackexchange/rest-client.ts +141 -66
  162. package/src/stackexchange/types.ts +0 -17
  163. package/src/tool.ts +15 -423
  164. package/src/tools/grep.ts +32 -310
  165. package/src/tools/health-tool-definition.ts +101 -0
  166. package/src/tools/index.ts +38 -9
  167. package/src/tools/research-knowledge-search.ts +974 -0
  168. package/src/tools/research-knowledge-types.ts +77 -0
  169. package/src/tools/research-tool-definition.ts +494 -0
  170. package/src/tools/scrape.ts +286 -388
  171. package/src/tools/search.ts +98 -153
  172. package/src/tools/security.ts +107 -80
  173. package/src/tools/stackexchange.ts +58 -35
  174. package/src/tools/youtube-transcript.ts +163 -0
  175. package/src/tui/knowledge-search-panel.ts +86 -0
  176. package/src/tui/pulse.ts +86 -0
  177. package/src/tui/research-health.ts +124 -0
  178. package/src/tui/research-panel-color-utils.ts +340 -0
  179. package/src/tui/research-panel-format.ts +54 -0
  180. package/src/tui/research-panel-state.ts +404 -0
  181. package/src/tui/research-panel-wave.ts +280 -0
  182. package/src/tui/research-panel.ts +339 -435
  183. package/src/tui/research-tui-manager.ts +233 -0
  184. package/src/tui/tui-controller.ts +137 -0
  185. package/src/types/extension-context.ts +18 -49
  186. package/src/types/index.ts +61 -0
  187. package/src/types/llm.ts +68 -29
  188. package/src/types/research-panel-types.ts +61 -0
  189. package/src/utils/circuit-breaker.ts +146 -0
  190. package/src/utils/citation-utils.ts +97 -0
  191. package/src/utils/disk-space-checker.ts +55 -0
  192. package/src/utils/error-tracker.ts +265 -0
  193. package/src/utils/host-config.ts +45 -0
  194. package/src/utils/input-validation.ts +8 -10
  195. package/src/utils/json-utils.ts +150 -16
  196. package/src/utils/llm-usage.ts +54 -0
  197. package/src/utils/log-rotation.ts +125 -0
  198. package/src/utils/log-utils.ts +159 -0
  199. package/src/utils/make-resource-loader.ts +3 -1
  200. package/src/utils/metrics-summary.ts +459 -0
  201. package/src/utils/metrics.ts +302 -0
  202. package/src/utils/research-export.ts +28 -12
  203. package/src/utils/safe-unref.ts +31 -0
  204. package/src/utils/shared-links.ts +195 -90
  205. package/src/utils/shutdown-manager.ts +65 -0
  206. package/src/utils/stdio-capture.ts +407 -0
  207. package/src/utils/text-utils.ts +258 -11
  208. package/src/utils/tool-usage-tracker.ts +83 -16
  209. package/src/utils/url-utils.ts +255 -0
  210. package/src/utils/user-agent.ts +28 -0
  211. package/src/web-research/browser-search.ts +179 -0
  212. package/src/web-research/retry-utils.ts +173 -77
  213. package/src/web-research/scraper-types.ts +72 -0
  214. package/src/web-research/scraper-utils.ts +434 -0
  215. package/src/web-research/search.ts +78 -218
  216. package/src/web-research/types.ts +9 -24
  217. package/src/web-research/utils.ts +2 -143
  218. package/src/web-research/web-scraper.ts +477 -0
  219. package/src/youtube/index.ts +154 -0
  220. package/src/youtube/potoken.ts +259 -0
  221. package/src/youtube/transcript-client.ts +390 -0
  222. package/src/youtube/video-id.ts +118 -0
  223. package/config/searxng/default-settings.yml +0 -189
  224. package/config/searxng/limiter.toml +0 -34
  225. package/config/tooling/eslint.config.js +0 -73
  226. package/config/tooling/vitest.config.integration.ts +0 -17
  227. package/config/tooling/vitest.config.ts +0 -40
  228. package/config/tooling/vitest.config.unit.ts +0 -12
  229. package/docs/assets/README-banner.jpg +0 -0
  230. package/scripts/setup.js +0 -212
  231. package/src/infrastructure/searxng-lifecycle.ts +0 -644
  232. package/src/infrastructure/searxng-manager.ts +0 -1183
  233. package/src/infrastructure/state-manager.ts +0 -918
  234. package/src/orchestration/deep-research-reducer.ts +0 -129
  235. package/src/orchestration/id-utils.ts +0 -63
  236. package/src/security/nvd.ts +0 -532
  237. package/src/security/osv.ts +0 -487
  238. package/src/stackexchange/cache.ts +0 -111
  239. package/src/stackexchange/output/index.ts +0 -7
  240. package/src/stackexchange/output/json.ts +0 -21
  241. package/src/tui/searxng-status.ts +0 -80
  242. package/src/utils/inject-date.ts +0 -29
  243. package/src/utils/research-error.ts +0 -205
  244. package/src/utils/searxng-config.ts +0 -94
  245. package/src/utils/session-state.ts +0 -295
  246. package/src/web-research/dependencies.ts +0 -20
  247. package/src/web-research/scrapers.ts +0 -935
  248. package/tsconfig.json +0 -40
@@ -0,0 +1,461 @@
1
+ /**
2
+ * Browser Task Scheduler
3
+ *
4
+ * Manages the browser task scheduler lifecycle and task execution.
5
+ * Refactored from browser-manager.ts for better separation of concerns.
6
+ */
7
+
8
+ import type { Config } from '../../config.ts';
9
+ import { getConfig } from '../../config.ts';
10
+ import type { SearchResult, ScrapeResult } from '../../web-research/types.ts';
11
+ import { logger } from '../../logger.ts';
12
+ import { metrics } from '../../utils/metrics.ts';
13
+ import { errorTracker } from '../../utils/error-tracker.ts';
14
+ import { getService, getServiceContainer } from '../../core/service-registry.ts';
15
+ import type { ServiceContainer } from '../../core/service-registry.ts';
16
+ import { ServiceNames } from '../../core/service-interfaces.ts';
17
+ import type { ISchedulerInternals } from '../../core/interfaces/scheduler-interfaces.ts';
18
+ import type { IStateManager } from '../../core/interfaces/state-manager-interfaces.ts';
19
+ import { BrowserServer, getBrowserServerAuthSecret } from './browser-server.ts';
20
+ import { isPoolShutdownError } from './browser-error-utils.ts';
21
+ import type { WorkerPoolManager } from './worker-pool-manager.ts';
22
+ import type { IScheduler } from '../../core/interfaces/scheduler-interfaces.ts';
23
+ import { cleanupOrphanedCamoufoxProcesses, getBrowserPidsForWorkers, killBrowserProcesses } from './browser-cleanup.ts';
24
+ import { raceWithDeadline } from '../../utils/safe-unref.ts';
25
+ import { PriorityTaskQueue } from './priority-task-queue.ts';
26
+
27
+ /**
28
+ * Browser task scheduler - manages the worker pool and executes tasks.
29
+ * Only the leader process has an instance of this scheduler.
30
+ */
31
+ export class BrowserTaskScheduler implements IScheduler {
32
+ private workerPoolManager: WorkerPoolManager | null = null;
33
+ private server: BrowserServer | null = null;
34
+ private priorityQueue: PriorityTaskQueue | null = null;
35
+ private leadershipTimer: any = null;
36
+ private leadershipMonitorStarted: boolean = false;
37
+ private idleTimer: any = null;
38
+ private consecutiveLeadershipMisses: number = 0;
39
+ private readonly LEADERSHIP_CHECK_INTERVAL_MS: number = 5000;
40
+ private readonly LEADERSHIP_MISS_THRESHOLD: number = 3;
41
+ private isShuttingDown: boolean = false;
42
+ private readonly IDLE_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes (was 30min — wasted RAM)
43
+
44
+ constructor(
45
+ public readonly schedulerId: string,
46
+ private readonly stateManager: IStateManager,
47
+ private readonly container: ServiceContainer = getServiceContainer()
48
+ ) {
49
+ // NB: the leadership monitor is NOT started here. The leadership check
50
+ // compares the persisted browserServer.schedulerId against ours, but this
51
+ // scheduler does not register itself as leader until AFTER construction
52
+ // (startServer + the lock-contended election updateState in the factory).
53
+ // Arming the timer in the constructor meant that, under lock contention,
54
+ // the first checks could fire before registration completed, accumulate
55
+ // misses, and self-shut-down a scheduler that was still legitimately
56
+ // becoming the leader. The factory calls startLeadershipMonitor() once the
57
+ // election is won. The idle timer has no such dependency and starts now.
58
+ this.resetIdleTimer();
59
+ }
60
+
61
+ /**
62
+ * Start the periodic leadership check. Called by the scheduler factory AFTER
63
+ * this process has won the election and registered itself as leader, so the
64
+ * check never races its own registration. Idempotent.
65
+ */
66
+ public startLeadershipMonitor(): void {
67
+ if (this.leadershipMonitorStarted || this.isShuttingDown) return;
68
+ this.leadershipMonitorStarted = true;
69
+ this.startLeadershipCheck();
70
+ }
71
+
72
+ private async getWorkerPoolManager(): Promise<WorkerPoolManager> {
73
+ if (!this.workerPoolManager) {
74
+ this.workerPoolManager = await getService<WorkerPoolManager>(ServiceNames.WORKER_POOL_MANAGER, undefined, this.container);
75
+ await this.workerPoolManager.initialize();
76
+ }
77
+ return this.workerPoolManager;
78
+ }
79
+
80
+ /**
81
+ * Get or create the priority task queue.
82
+ * This is synchronous to prevent races where multiple concurrent requests
83
+ * might create redundant queue instances before the reference is set.
84
+ */
85
+ private getPriorityQueue(config?: Config): PriorityTaskQueue {
86
+ const c = config || getConfig();
87
+ const maxTotalConcurrency = c.WORKER_THREADS * c.WORKER_CONCURRENCY;
88
+ if (!this.priorityQueue) {
89
+ this.priorityQueue = new PriorityTaskQueue(maxTotalConcurrency);
90
+ } else {
91
+ this.priorityQueue.updateConcurrency(maxTotalConcurrency);
92
+ }
93
+ return this.priorityQueue;
94
+ }
95
+
96
+ private startLeadershipCheck() {
97
+ const check = async () => {
98
+ if (this.isShuttingDown) return;
99
+
100
+ try {
101
+ const serverInfo = await this.stateManager.getBrowserServer();
102
+ if (serverInfo?.schedulerId !== this.schedulerId) {
103
+ this.consecutiveLeadershipMisses++;
104
+ metrics.increment('browser_leadership_misses_total', 1);
105
+ metrics.setGauge('browser_is_leader', 0);
106
+ logger.warn(`[Scheduler] Leadership check failed (${this.consecutiveLeadershipMisses}/${this.LEADERSHIP_MISS_THRESHOLD}) - ID: ${this.schedulerId}, Current: ${serverInfo?.schedulerId}`);
107
+
108
+ if (this.consecutiveLeadershipMisses >= this.LEADERSHIP_MISS_THRESHOLD) {
109
+ metrics.increment('browser_leadership_lost_total', 1);
110
+ logger.error(`[Scheduler] Leadership threshold exceeded (${this.consecutiveLeadershipMisses} misses), shutting down pool...`);
111
+ await this.shutdown();
112
+ return;
113
+ }
114
+ } else {
115
+ if (this.consecutiveLeadershipMisses > 0) {
116
+ logger.log(`[Scheduler] Leadership confirmed, resetting miss counter from ${this.consecutiveLeadershipMisses}`);
117
+ this.consecutiveLeadershipMisses = 0;
118
+ }
119
+ metrics.setGauge('browser_is_leader', 1);
120
+ }
121
+
122
+ const poolManager = await this.getWorkerPoolManager();
123
+ // Decay (not reset) so a slow worker crash-loop can still accumulate to
124
+ // the auto-recovery threshold instead of being zeroed on every tick.
125
+ poolManager.decayConsecutiveErrors();
126
+ } catch (err) {
127
+ logger.warn('[Scheduler] Leadership check error:', err);
128
+ } finally {
129
+ if (!this.isShuttingDown) {
130
+ this.leadershipTimer = setTimeout(check, this.LEADERSHIP_CHECK_INTERVAL_MS);
131
+ if (this.leadershipTimer.unref) this.leadershipTimer.unref();
132
+ }
133
+ }
134
+ };
135
+
136
+ this.leadershipTimer = setTimeout(check, this.LEADERSHIP_CHECK_INTERVAL_MS);
137
+ if (this.leadershipTimer.unref) this.leadershipTimer.unref();
138
+ }
139
+
140
+ private resetIdleTimer() {
141
+ if (this.idleTimer) clearTimeout(this.idleTimer);
142
+ this.idleTimer = setTimeout(() => {
143
+ logger.log('[Scheduler] Browser pool idle timeout reached, shutting down...');
144
+ // shutdown() is async and awaits getService()/server.stop(); a rejection here (e.g.
145
+ // getService throwing during a concurrent container disposal) would otherwise surface
146
+ // as an unhandledRejection from this fire-and-forget timer. Swallow it — an idle
147
+ // teardown that fails is non-fatal; the next run gets a fresh scheduler regardless.
148
+ this.shutdown().catch(err => logger.warn('[Scheduler] Idle-timeout shutdown failed (non-fatal):', err));
149
+ }, this.IDLE_TIMEOUT_MS);
150
+ if (this.idleTimer.unref) this.idleTimer.unref();
151
+ }
152
+
153
+ public resetIdleTimerOnActivity(): void {
154
+ this.resetIdleTimer();
155
+ }
156
+
157
+ async startServer(): Promise<number> {
158
+ this.server = new BrowserServer({
159
+ onSearch: (q) => this.runSearch(q),
160
+ onScrape: (u) => this.runScrape(u),
161
+ onHealthCheck: () => this.runHealthCheck(),
162
+ });
163
+ // FIX (#21): Expose auth secret to child processes via env
164
+ process.env['PI_BROWSER_AUTH_SECRET'] = getBrowserServerAuthSecret();
165
+ return this.server.start();
166
+ }
167
+
168
+ async runSearch(query: string, config?: Config, signal?: AbortSignal): Promise<SearchResult[]> {
169
+ // Reject tasks once this scheduler has begun teardown. shutdown() nulls priorityQueue and
170
+ // clears the scheduler reference (a future run gets a fresh scheduler), so getPriorityQueue()
171
+ // here would otherwise build a FRESH, non-shutdown queue on this dead instance and dispatch
172
+ // onto a pool being destroyed. The message matches isPoolShutdownError → benign DEBUG for callers.
173
+ if (this.isShuttingDown) throw new Error('Worker pool is shutting down');
174
+ this.resetIdleTimer();
175
+ const pool = await (await this.getWorkerPoolManager()).ensurePool(config);
176
+ const startTime = Date.now();
177
+
178
+ // Task timeout = the search nav budget (SEARCH_TIMEOUT_MS) + a queue-wait/overhead
179
+ // margin (BROWSER_TASK_TIMEOUT_MS). Deriving from SEARCH_TIMEOUT_MS keeps the task
180
+ // ceiling coherent with the worker's own nav timeout — using a flat base here would
181
+ // kill a search before it had used its full nav budget.
182
+ const cfg = config || getConfig();
183
+ const timeoutMs = cfg.SEARCH_TIMEOUT_MS + cfg.BROWSER_TASK_TIMEOUT_MS;
184
+
185
+ let timeoutId: NodeJS.Timeout | undefined;
186
+ const timeoutPromise = new Promise<never>((_, reject) => {
187
+ timeoutId = setTimeout(() => {
188
+ reject(new Error(`Search task timed out after ${timeoutMs}ms (including queue wait). query="${query}"`));
189
+ }, timeoutMs);
190
+ if (timeoutId.unref) timeoutId.unref();
191
+ });
192
+
193
+ logger.debug(`[BrowserTaskScheduler] Executing search: "${query}" (Timeout: ${timeoutMs}ms)`);
194
+ let result: any;
195
+ try {
196
+ const queue = this.getPriorityQueue(config);
197
+ // Race the enqueue call against the timeoutPromise.
198
+ // This ensures that even if the queue is saturated, we won't hang forever.
199
+ result = await Promise.race([
200
+ queue.enqueue('search', async () => {
201
+ return await Promise.race([
202
+ pool.execute({ type: 'search', query, queuedAt: startTime, taskTimeoutMs: timeoutMs }),
203
+ timeoutPromise
204
+ ]);
205
+ }, signal),
206
+ timeoutPromise
207
+ ]);
208
+ logger.debug(`[BrowserTaskScheduler] Search completed: "${query}" in ${Date.now() - startTime}ms`);
209
+ } catch (error) {
210
+ // A pool-shutdown/destroy error means the run is being torn down (quit/SIGTERM
211
+ // mid-search-burst): the task never ran because dispose() destroyed the pool.
212
+ // That is expected teardown, not a fault — log at DEBUG and don't count it toward
213
+ // the error metric/tracker, so a normal quit-mid-run doesn't inflate ERROR counts.
214
+ if (isPoolShutdownError(error)) {
215
+ logger.debug(`[BrowserTaskScheduler] Search abandoned during shutdown: "${query}"`);
216
+ throw error;
217
+ }
218
+ logger.error(`[BrowserTaskScheduler] Search failed: "${query}"`, error);
219
+ metrics.increment('browser_search_errors_total', 1);
220
+ errorTracker.trackError(error instanceof Error ? error : String(error), {
221
+ component: 'browser-manager',
222
+ operation: 'search',
223
+ query,
224
+ taskType: 'search',
225
+ });
226
+ throw error;
227
+ } finally {
228
+ if (timeoutId) clearTimeout(timeoutId);
229
+ }
230
+
231
+ 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' });
234
+ if (result.error) {
235
+ metrics.increment('browser_search_requests_total', 1, { status: 'error' });
236
+ errorTracker.trackError(new Error(result.error), {
237
+ component: 'browser-manager',
238
+ operation: 'search',
239
+ query,
240
+ taskType: 'search',
241
+ errorType: 'search_error',
242
+ });
243
+ throw new Error(result.error);
244
+ }
245
+ return result.results;
246
+ }
247
+
248
+ async runScrape(url: string, config?: Config, signal?: AbortSignal): Promise<ScrapeResult> {
249
+ if (this.isShuttingDown) throw new Error('Worker pool is shutting down');
250
+ this.resetIdleTimer();
251
+ const pool = await (await this.getWorkerPoolManager()).ensurePool(config);
252
+ const startTime = Date.now();
253
+ // Task timeout = the scrape nav budget (SCRAPE_TIMEOUT_MS) + the queue-wait/overhead
254
+ // margin (BROWSER_TASK_TIMEOUT_MS), mirroring the search path so both stay coherent.
255
+ const cfg = config || getConfig();
256
+ const isMocking = process.env['PI_RESEARCH_MOCK_SCRAPE'] === 'true';
257
+ const timeoutMs = cfg.SCRAPE_TIMEOUT_MS + (isMocking ? 5000 : cfg.BROWSER_TASK_TIMEOUT_MS);
258
+
259
+ let timeoutId: NodeJS.Timeout | undefined;
260
+ const timeoutPromise = new Promise<never>((_, reject) => {
261
+ timeoutId = setTimeout(() => {
262
+ reject(new Error(`Scrape task timed out after ${timeoutMs}ms (including queue wait). url="${url}"`));
263
+ }, timeoutMs);
264
+ if (timeoutId.unref) timeoutId.unref();
265
+ });
266
+
267
+ let result: any;
268
+ try {
269
+ const queue = this.getPriorityQueue(config);
270
+ result = await Promise.race([
271
+ queue.enqueue('scrape', async () => {
272
+ return await Promise.race([
273
+ pool.execute({ type: 'scrape', url, queuedAt: startTime, taskTimeoutMs: timeoutMs }),
274
+ timeoutPromise
275
+ ]);
276
+ }, signal),
277
+ timeoutPromise
278
+ ]);
279
+ } catch (error) {
280
+ // Pool-shutdown/destroy mid-scrape is expected teardown (quit/SIGTERM), not a
281
+ // fault — demote to DEBUG and don't inflate the error metric/tracker (mirrors runSearch).
282
+ if (isPoolShutdownError(error)) {
283
+ logger.debug(`[BrowserTaskScheduler] Scrape abandoned during shutdown: "${url}"`);
284
+ throw error;
285
+ }
286
+ metrics.increment('browser_scrape_errors_total', 1);
287
+ errorTracker.trackError(error instanceof Error ? error : String(error), {
288
+ component: 'browser-manager',
289
+ operation: 'browser-task',
290
+ taskType: 'scrape',
291
+ });
292
+ throw error;
293
+ } finally {
294
+ if (timeoutId) clearTimeout(timeoutId);
295
+ }
296
+
297
+ 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' });
300
+ if (result.error) {
301
+ metrics.increment('browser_scrape_requests_total', 1, { status: 'error' });
302
+ errorTracker.trackError(new Error(result.error), {
303
+ component: 'browser-manager',
304
+ operation: 'browser-task',
305
+ taskType: 'scrape',
306
+ errorType: 'scrape_error',
307
+ });
308
+ throw new Error(result.error);
309
+ }
310
+ return result;
311
+ }
312
+
313
+ async runHealthCheck(config?: Config, signal?: AbortSignal): Promise<{ success: boolean }> {
314
+ if (this.isShuttingDown) throw new Error('Worker pool is shutting down');
315
+ this.resetIdleTimer();
316
+ const pool = await (await this.getWorkerPoolManager()).ensurePool(config);
317
+ const startTime = Date.now();
318
+ const isMocking = process.env['PI_RESEARCH_MOCK_SEARCH'] === 'true' || process.env['PI_RESEARCH_MOCK_SCRAPE'] === 'true';
319
+ const timeoutMs = (45000 + 60000) / (isMocking ? 4 : 1);
320
+ let timeoutId: NodeJS.Timeout | undefined;
321
+ const timeoutPromise = new Promise<never>((_, reject) => {
322
+ timeoutId = setTimeout(() => reject(new Error(`Health check timed out after ${timeoutMs}ms (including queue wait)`)), timeoutMs);
323
+ if (timeoutId.unref) timeoutId.unref();
324
+ });
325
+
326
+ let result: { success: boolean; error?: string };
327
+ try {
328
+ const queue = this.getPriorityQueue(config);
329
+ result = await Promise.race([
330
+ 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),
338
+ timeoutPromise
339
+ ]) as { success: boolean; error?: string };
340
+ } catch (error) {
341
+ // Pool-shutdown/destroy mid-healthcheck is expected teardown, not a fault —
342
+ // demote to DEBUG and don't inflate the error metric/tracker (mirrors runSearch).
343
+ if (isPoolShutdownError(error)) {
344
+ logger.debug(`[BrowserTaskScheduler] Healthcheck abandoned during shutdown`);
345
+ throw error;
346
+ }
347
+ metrics.increment('browser_healthcheck_errors_total', 1);
348
+ errorTracker.trackError(error instanceof Error ? error : String(error), {
349
+ component: 'browser-manager',
350
+ operation: 'healthcheck',
351
+ taskType: 'healthcheck',
352
+ });
353
+ throw error;
354
+ } finally {
355
+ if (timeoutId) clearTimeout(timeoutId);
356
+ }
357
+
358
+ 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`);
363
+ if (result.error) {
364
+ metrics.increment('browser_healthcheck_requests_total', 1, { status: 'error' });
365
+ metrics.setGauge('browser_pool_health', 0);
366
+ errorTracker.trackError(new Error(result.error), {
367
+ component: 'browser-manager',
368
+ operation: 'healthcheck',
369
+ taskType: 'healthcheck',
370
+ errorType: 'healthcheck_error',
371
+ });
372
+ throw new Error(result.error);
373
+ }
374
+ return result;
375
+ }
376
+
377
+ async shutdown() {
378
+ if (this.isShuttingDown) return;
379
+ this.isShuttingDown = true;
380
+
381
+ if (this.idleTimer) {
382
+ clearTimeout(this.idleTimer);
383
+ this.idleTimer = null;
384
+ }
385
+
386
+ if (this.leadershipTimer) {
387
+ clearTimeout(this.leadershipTimer);
388
+ this.leadershipTimer = null;
389
+ }
390
+
391
+ // Clear our reference from the SchedulerService so a future run gets a
392
+ // fresh scheduler. Skip this entirely when the container is being
393
+ // disposed: getService() throws during disposal, and the SchedulerService
394
+ // (and the whole container) is being torn down anyway, so the cleared
395
+ // reference would be moot. Guarding here keeps shutdown from logging a
396
+ // spurious "Cannot get service 'scheduler' during container disposal".
397
+ if (!this.container.isDisposing) {
398
+ const schedulerService = await getService<ISchedulerInternals>(ServiceNames.SCHEDULER, undefined, this.container);
399
+ const currentScheduler = schedulerService.getSchedulerInstance();
400
+ if (currentScheduler && 'schedulerId' in currentScheduler && currentScheduler.schedulerId === this.schedulerId) {
401
+ schedulerService.setSchedulerInstance(null);
402
+ schedulerService.setSchedulerVersion(null);
403
+ schedulerService.setSchedulerInitializationPromise(null);
404
+ }
405
+ }
406
+
407
+ let serverInfo: { port: number; pid: number; schedulerId?: string } | null = null;
408
+ try {
409
+ serverInfo = await this.stateManager.getBrowserServer();
410
+ } catch (err) {
411
+ logger.warn('[Scheduler] Could not read browser server state during shutdown:', err);
412
+ }
413
+ if (serverInfo?.pid === process.pid && serverInfo?.schedulerId === this.schedulerId) {
414
+ await this.stateManager.clearBrowserServer().catch((err) => {
415
+ logger.warn('[Scheduler] Failed to clear browser server state during shutdown:', err);
416
+ });
417
+ }
418
+
419
+ if (this.server) {
420
+ try {
421
+ await raceWithDeadline(this.server.stop(), 2000);
422
+ } catch (e) {
423
+ logger.warn('[Scheduler] Server shutdown error:', e);
424
+ }
425
+ this.server = null;
426
+ }
427
+
428
+ let targetBrowserPids: number[] = [];
429
+ if (this.workerPoolManager) {
430
+ const pool = this.workerPoolManager.getPool();
431
+ if (pool && pool.workerNodes) {
432
+ const workerPids = pool.workerNodes.map((n: any) => n.worker?.process?.pid).filter(Boolean);
433
+ if (workerPids.length > 0) {
434
+ targetBrowserPids = await getBrowserPidsForWorkers(workerPids);
435
+ }
436
+ }
437
+ }
438
+
439
+ if (this.priorityQueue) {
440
+ this.priorityQueue.shutdown();
441
+ this.priorityQueue = null;
442
+ }
443
+
444
+ if (this.workerPoolManager) {
445
+ await this.workerPoolManager.shutdown();
446
+ }
447
+
448
+ if (targetBrowserPids.length > 0) {
449
+ await raceWithDeadline(killBrowserProcesses(targetBrowserPids), 10000);
450
+ }
451
+
452
+ try {
453
+ const orphanPromise = cleanupOrphanedCamoufoxProcesses();
454
+ orphanPromise.catch((err: Error) => logger.debug(`[BrowserTaskScheduler] Background orphan cleanup rejection: ${err.message}`));
455
+ await raceWithDeadline(orphanPromise, 15000);
456
+ } catch (err) {
457
+ const msg = err instanceof Error ? err.message : String(err);
458
+ logger.warn('[Scheduler] Failed to cleanup orphaned browsers:', msg);
459
+ }
460
+ }
461
+ }
@@ -0,0 +1,155 @@
1
+ import * as fs from 'node:fs/promises';
2
+ import * as path from 'node:path';
3
+ import * as os from 'node:os';
4
+ import { logger } from '../../logger.ts';
5
+
6
+ const DEFAULT_STALE_MS = 30 * 24 * 60 * 60 * 1000; // 30 days
7
+
8
+ /**
9
+ * Firefox/Camoufox profile lock files, cross-platform. A live browser holds:
10
+ * - Unix: `lock` — a SYMLINK whose target encodes the owner: "<ip>:+<pid>" or "+<pid>",
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).
14
+ * The previous code checked for `.lock`, which Firefox/Camoufox never creates, so its
15
+ * lock-awareness was inert. These are the real names.
16
+ */
17
+ const LOCK_FILE_NAMES = ['lock', '.parentlock', 'parent.lock'];
18
+
19
+ /** Treat a lock whose owner PID we cannot read as "live" only if touched this recently,
20
+ * so a just-starting concurrent run is not reclaimed out from under itself. */
21
+ const LOCK_FRESHNESS_MS = 2 * 60 * 1000;
22
+
23
+ /** Parse the owner PID out of a Firefox `lock` symlink target ("<ip>:+<pid>" / "+<pid>"). */
24
+ function parsePidFromLockTarget(target: string): number | null {
25
+ const m = /\+(\d+)\s*$/.exec(target);
26
+ if (!m) return null;
27
+ const pid = Number(m[1]);
28
+ return Number.isInteger(pid) && pid > 0 ? pid : null;
29
+ }
30
+
31
+ /** Cross-platform liveness probe for a PID (no signal is actually sent with signal 0). */
32
+ function isPidAlive(pid: number): boolean {
33
+ try {
34
+ process.kill(pid, 0);
35
+ return true;
36
+ } catch (e) {
37
+ // ESRCH = no such process (dead); EPERM = exists but owned by another user (alive).
38
+ return (e as NodeJS.ErrnoException)?.code === 'EPERM';
39
+ }
40
+ }
41
+
42
+ /**
43
+ * Best-effort, cross-platform check for whether a profile directory is held by a LIVE
44
+ * browser. Returns true ONLY on positive evidence of a live owner, so a stale lock left by
45
+ * a crashed process never pins the directory. A directory with no lock file is not live.
46
+ */
47
+ async function isProfileLive(profileDir: string): Promise<boolean> {
48
+ for (const name of LOCK_FILE_NAMES) {
49
+ const lockPath = path.join(profileDir, name);
50
+ let lst;
51
+ try {
52
+ lst = await fs.lstat(lockPath);
53
+ } catch {
54
+ continue; // this lock file is absent — check the next
55
+ }
56
+ // Unix `lock` symlink: authoritative — read the owner PID and probe liveness.
57
+ if (lst.isSymbolicLink()) {
58
+ try {
59
+ const pid = parsePidFromLockTarget(await fs.readlink(lockPath));
60
+ if (pid !== null) return isPidAlive(pid);
61
+ } catch {
62
+ // unreadable symlink — fall back to freshness below
63
+ }
64
+ }
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.
68
+ return Date.now() - lst.mtimeMs < LOCK_FRESHNESS_MS;
69
+ }
70
+ return false;
71
+ }
72
+
73
+ /**
74
+ * Reclaim leaked Playwright/Camoufox profile directories.
75
+ *
76
+ * Playwright self-cleans a profile on a normal `browser.close()`; only an ABNORMAL exit
77
+ * (crash, SIGKILL, OOM-kill) leaves one behind. This runs at pool startup, before this
78
+ * process opens any browser of its own, so any profile WITHOUT a live owning process is
79
+ * definitively orphaned and reclaimed regardless of age or emptiness. Profiles still in use
80
+ * by a concurrent pi-research instance are detected via {@link isProfileLive} and spared.
81
+ *
82
+ * @param baseDir Directory to scan. Defaults to os.tmpdir() (legacy pre-upgrade location);
83
+ * with the default, only `playwright_firefoxdev_profile-` entries are
84
+ * considered. With an explicit baseDir (the dedicated profile dir), every
85
+ * subdirectory is a candidate.
86
+ * @param staleMs Age fallback (default 30 days) used ONLY for unrecognized non-profile
87
+ * entries, which are never reclaimed while they could be recent.
88
+ */
89
+ export async function cleanupStaleProfiles(
90
+ baseDir?: string,
91
+ staleMs = DEFAULT_STALE_MS,
92
+ ): Promise<{ removed: number; errors: number }> {
93
+ const scanDir = baseDir ?? os.tmpdir();
94
+ const usePrefix = baseDir === undefined;
95
+ const prefix = 'playwright_firefoxdev_profile-';
96
+ let removed = 0;
97
+ let errors = 0;
98
+
99
+ try {
100
+ const entries = await fs.readdir(scanDir);
101
+ const now = Date.now();
102
+
103
+ for (const entry of entries) {
104
+ if (usePrefix && !entry.startsWith(prefix)) continue;
105
+
106
+ const fullPath = path.join(scanDir, entry);
107
+ try {
108
+ const stats = await fs.stat(fullPath);
109
+ if (!stats.isDirectory()) continue;
110
+
111
+ const isProfileLike =
112
+ entry.startsWith(prefix) || entry.startsWith('playwright-artifacts-');
113
+
114
+ if (isProfileLike) {
115
+ // Empty dirs (e.g. an artifact dir Playwright emptied but left behind) can never
116
+ // be an active profile — reclaim immediately.
117
+ const inner = await fs.readdir(fullPath);
118
+ if (inner.length === 0) {
119
+ await fs.rmdir(fullPath);
120
+ removed++;
121
+ continue;
122
+ }
123
+ // Non-empty: reclaim unless a live browser still owns it. This is what fixes the
124
+ // real leak — a recent, non-empty, crash-left profile is now reclaimed instead of
125
+ // lingering until a 30-day (or 1-hour) age threshold.
126
+ if (await isProfileLive(fullPath)) {
127
+ logger.debug(`[Cleanup] Skipping profile ${entry} — owned by a live browser`);
128
+ continue;
129
+ }
130
+ await fs.rm(fullPath, { recursive: true, force: true });
131
+ removed++;
132
+ continue;
133
+ }
134
+
135
+ // 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 });
139
+ removed++;
140
+ }
141
+ } catch (_e) {
142
+ // A live Windows profile refuses deletion (file held open) and lands here — safe.
143
+ errors++;
144
+ }
145
+ }
146
+ } catch (e) {
147
+ logger.warn('[Cleanup] Failed to read directory for profile cleanup:', e);
148
+ }
149
+
150
+ if (removed > 0) {
151
+ logger.log(`[Cleanup] Removed ${removed} leaked browser profile directories.`);
152
+ }
153
+
154
+ return { removed, errors };
155
+ }
@@ -0,0 +1,31 @@
1
+ /**
2
+ * HTTP Client Agent for Browser Pool Communication
3
+ *
4
+ * Global HTTP Agent for high-concurrency client requests.
5
+ * Extracted from browser-manager.ts for better separation of concerns.
6
+ */
7
+
8
+ import * as http from 'node:http';
9
+
10
+ /**
11
+ * Global HTTP Agent for high-concurrency client requests
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.
15
+ */
16
+ const clientAgent = new http.Agent({
17
+ keepAlive: true,
18
+ maxSockets: 100, // Allow up to 100 concurrent requests to the leader
19
+ 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
23
+ });
24
+
25
+ /**
26
+ * Get the global HTTP agent for client requests.
27
+ * This is used by shutdownManager to properly destroy the agent on shutdown.
28
+ */
29
+ export function getClientAgent(): http.Agent {
30
+ return clientAgent;
31
+ }