@lincoln504/pi-research 0.1.12 → 1.0.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 (248) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +64 -115
  3. package/dist/cli.mjs +23211 -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 +102 -0
  13. package/docs/ARCHITECTURE.md +306 -80
  14. package/docs/CONFIGURATION.md +249 -0
  15. package/docs/KNOWLEDGE-STORE.md +213 -0
  16. package/docs/PI-EXTENSION.md +91 -0
  17. package/docs/SDK.md +113 -0
  18. package/docs/THIRD-PARTY-NOTICES.md +64 -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 +1044 -0
  30. package/src/config.ts +1036 -147
  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 +264 -7
  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 -174
  231. package/src/infrastructure/searxng-lifecycle.ts +0 -577
  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,168 @@
1
+ /**
2
+ * Poolifier Worker
3
+ *
4
+ * Executes search and scrape tasks in worker processes using Camoufox.
5
+ *
6
+ * This module coordinates the worker lifecycle, browser management,
7
+ * and task execution using extracted sub-modules.
8
+ */
9
+
10
+ import { ClusterWorker } from 'poolifier';
11
+ import crypto from 'node:crypto';
12
+ import process from 'node:process';
13
+
14
+ // Import extracted modules
15
+ import {
16
+ setWorkerId as setLifecycleWorkerId,
17
+ setupIpcErrorHandler,
18
+ setupUncaughtExceptionHandler,
19
+ setupOrphanProtection,
20
+ createKillHandler,
21
+ setBrowserCleanup,
22
+ } from './thread-worker-lifecycle.ts';
23
+ import {
24
+ setWorkerId,
25
+ initBrowser,
26
+ getContext,
27
+ resetBrowser,
28
+ cleanupBrowser,
29
+ } from './thread-worker-browser.ts';
30
+ import {
31
+ setWorkerId as setMessagingWorkerId,
32
+ executeSearchTask,
33
+ executeScrapeTask,
34
+ executeHealthCheck,
35
+ shouldResetBrowser,
36
+ } from './thread-worker-messaging.ts';
37
+ import type { TaskData, TaskResult } from './thread-worker-types.ts';
38
+
39
+ // Generate a random ID for this worker process to track distribution in logs
40
+ const workerId = crypto.randomBytes(2).toString('hex');
41
+
42
+ // Set worker ID in all modules
43
+ setLifecycleWorkerId(workerId);
44
+ setWorkerId(workerId);
45
+ setMessagingWorkerId(workerId);
46
+
47
+ // Setup IPC error handler
48
+ setupIpcErrorHandler();
49
+
50
+ // Setup uncaught exception handler
51
+ setupUncaughtExceptionHandler();
52
+
53
+ // Setup browser cleanup callback for lifecycle module
54
+ setBrowserCleanup(cleanupBrowser);
55
+
56
+ // Setup orphaned worker protection
57
+ if (process.env['GITHUB_ACTIONS'] !== 'true') {
58
+ setupOrphanProtection();
59
+ }
60
+
61
+ // When both search and scrape are mocked, skip Firefox entirely in task handlers
62
+ // and in the eager warm-up below. Firefox startup in cluster workers takes 60-90s
63
+ // on constrained CI runners; mocked tasks don't need a real browser at all.
64
+ const FULL_MOCK_MODE =
65
+ process.env['PI_RESEARCH_MOCK_SEARCH'] === 'true' &&
66
+ process.env['PI_RESEARCH_MOCK_SCRAPE'] === 'true';
67
+
68
+ /**
69
+ * Main task execution function
70
+ */
71
+ async function runTask(data: TaskData | undefined): Promise<TaskResult> {
72
+ if (!data) {
73
+ return { error: 'No task data provided', duration: 0 };
74
+ }
75
+ const { type, query, url, queuedAt: _queuedAt, taskTimeoutMs } = data;
76
+ const startTime = Date.now();
77
+
78
+ if (FULL_MOCK_MODE) {
79
+ if (type === 'search') {
80
+ return {
81
+ results: [{ title: 'Mock Result', url: 'https://example.com/mock', content: `Mock search result for: ${query ?? ''}` }],
82
+ duration: Date.now() - startTime,
83
+ jitter: 0,
84
+ };
85
+ }
86
+ if (type === 'scrape') {
87
+ return {
88
+ contentType: 'text/html',
89
+ html: `<html><body><p>Mock content for ${url ?? ''}</p></body></html>`,
90
+ duration: Date.now() - startTime,
91
+ jitter: 0,
92
+ };
93
+ }
94
+ if (type === 'healthcheck') {
95
+ return { success: true, navMs: 0, duration: Date.now() - startTime };
96
+ }
97
+ }
98
+
99
+ const abortController = new AbortController();
100
+ let taskTimer: ReturnType<typeof setTimeout> | undefined;
101
+ if (taskTimeoutMs && taskTimeoutMs > 0) {
102
+ taskTimer = setTimeout(() => abortController.abort(new Error(`Task timed out after ${taskTimeoutMs}ms`)), taskTimeoutMs);
103
+ }
104
+
105
+ try {
106
+ await initBrowser();
107
+ const initMs = Date.now() - startTime;
108
+
109
+ let result: any;
110
+ if (type === 'search') {
111
+ if (!query) throw new Error('Search task requires a query');
112
+ const searchResult = await executeSearchTask(getContext(), query);
113
+ result = { results: searchResult.results, duration: Date.now() - startTime, jitter: searchResult.jitter };
114
+ } else if (type === 'scrape') {
115
+ if (!url) throw new Error('Scrape task requires a URL');
116
+ const scrapeResult = await executeScrapeTask(getContext(), url, abortController.signal);
117
+ result = { ...scrapeResult, duration: Date.now() - startTime };
118
+ } else if (type === 'healthcheck') {
119
+ const healthResult = await executeHealthCheck(getContext(), initMs);
120
+ result = { ...healthResult, duration: Date.now() - startTime };
121
+ } else {
122
+ result = { error: 'Unknown task type', duration: Date.now() - startTime };
123
+ }
124
+
125
+ if (abortController.signal.aborted) {
126
+ return { error: `Task timed out after ${taskTimeoutMs}ms`, duration: Date.now() - startTime };
127
+ }
128
+
129
+ return result;
130
+ } catch (error: unknown) {
131
+ const errMsg = error instanceof Error ? error.message : String(error);
132
+
133
+ // A deliberate task-timeout abort closes the page, so the in-flight op rejects with
134
+ // 'Target closed' — which shouldResetBrowser() would otherwise read as a browser
135
+ // crash and needlessly tear down the whole (healthy) browser. Report the timeout and
136
+ // leave the browser intact.
137
+ if (abortController.signal.aborted) {
138
+ return { error: `Task timed out after ${taskTimeoutMs}ms`, duration: Date.now() - startTime };
139
+ }
140
+
141
+ // If the browser crashed or disconnected, clear the instance to force re-initialization on next task
142
+ if (shouldResetBrowser(errMsg)) {
143
+ resetBrowser();
144
+ }
145
+
146
+ return {
147
+ error: errMsg,
148
+ duration: Date.now() - startTime
149
+ };
150
+ } finally {
151
+ if (taskTimer !== undefined) clearTimeout(taskTimer);
152
+ }
153
+ }
154
+
155
+ const worker = new ClusterWorker(runTask, {
156
+ killHandler: createKillHandler(),
157
+ });
158
+
159
+ export default worker;
160
+
161
+ // DEFERRED: Eagerly warming the browser when the worker starts is disabled
162
+ // to prevent "thundering herd" resource spikes. Instead, the browser is
163
+ // initialized lazily upon receipt of the first task in runTask().
164
+ /*
165
+ if (!FULL_MOCK_MODE) {
166
+ initBrowser().catch(() => {});
167
+ }
168
+ */
@@ -0,0 +1,449 @@
1
+ /**
2
+ * Worker Pool Manager
3
+ *
4
+ * Manages the lifecycle of the worker pool for browser operations.
5
+ * Extracted from BrowserTaskScheduler for better separation of concerns.
6
+ */
7
+
8
+ import { dirname, join } from 'node:path';
9
+ import { existsSync } from 'node:fs';
10
+ import { fileURLToPath } from 'node:url';
11
+ import { FixedClusterPool, WorkerChoiceStrategies } from 'poolifier';
12
+ import { logger } from '../../logger.ts';
13
+ import { metrics } from '../../utils/metrics.ts';
14
+ import type { Config } from '../../config.ts';
15
+ import { getConfig } from '../../config.ts';
16
+ import { ensureBrowserCacheDir, getBrowserEnv, getBrowserProfileDir, getMaxWorkers } from './config.ts';
17
+ import { ensureBrowserInstalled } from './ensure-browser.ts';
18
+ import { cleanupStaleProfiles } from './cleanup-utils.ts';
19
+ import { cleanupOrphanedCamoufoxProcesses } from './browser-cleanup.ts';
20
+ import { setupMasterIpcErrorHandler, isBenignClusterIpcError } from './thread-worker-lifecycle.ts';
21
+ import { ServiceLifecycle, type IService } from '../../core/service-registry.ts';
22
+ import { raceWithDeadline } from '../../utils/safe-unref.ts';
23
+ import { ServiceNames } from '../../core/interfaces/service-names.ts';
24
+
25
+ const __filename = fileURLToPath(import.meta.url);
26
+ const __dirname = dirname(__filename);
27
+
28
+ /**
29
+ * Worker pool manager for browser operations.
30
+ */
31
+ export class WorkerPoolManager implements IService {
32
+ readonly name = ServiceNames.WORKER_POOL_MANAGER;
33
+ lifecycle = ServiceLifecycle.UNINITIALIZED;
34
+
35
+ private pool: any | null = null;
36
+ private poolInitializationPromise: Promise<any> | null = null;
37
+ private currentWorkerCount: number | null = null;
38
+ private consecutiveErrors: number = 0;
39
+ private isShuttingDown: boolean = false;
40
+ // Monotonic shutdown generation. Each shutdown() bumps it, so an in-flight
41
+ // ensurePool() that started before the shutdown can detect it even after
42
+ // isShuttingDown has been reset to false (shutdown resets it so the instance
43
+ // is reusable). A bare boolean can't distinguish "no shutdown" from "a
44
+ // shutdown already started AND finished while I was building the pool" —
45
+ // exactly the window that let a freshly-built pool survive un-referenced.
46
+ private generation: number = 0;
47
+ // Monotonic per-pool-INSTANCE token. Bumped every time a new FixedClusterPool is
48
+ // constructed (including a same-generation worker-count recreation, which `generation`
49
+ // does NOT track). Each pool's error/exit handlers capture the epoch they belong to and
50
+ // ignore events once it is superseded, so a trailing error from a destroyed pool's dying
51
+ // worker cannot inflate the live pool's health counter and trip a spurious auto-recovery.
52
+ private poolEpoch: number = 0;
53
+ // FIX (#12): Flag to indicate a pool reset is in progress
54
+ private _resetInProgress: boolean = false;
55
+ // Handle for the schedulePoolReset() destroy timer, so shutdown() can cancel a pending
56
+ // reset instead of letting it fire after teardown and clobber a freshly-rebuilt pool.
57
+ private _resetTimer: ReturnType<typeof setTimeout> | null = null;
58
+
59
+ constructor(
60
+ private readonly onPoolError?: (error: Error, consecutiveErrors: number) => void
61
+ ) {}
62
+
63
+ /**
64
+ * Ensure the pool is initialized with the current config.
65
+ * Recreates the pool if the worker count has changed.
66
+ */
67
+ async ensurePool(config?: Config): Promise<any> {
68
+ const maxWorkers = getMaxWorkers(config);
69
+
70
+ // Fast-fail if a shutdown is in progress — the caller should retry after
71
+ // the shutdown completes (isShuttingDown is reset to false on completion).
72
+ // This check must come BEFORE the cached-pool fast-path below because
73
+ // pool.destroy() may be in flight while this.pool is still non-null.
74
+ // Returning a mid-destroy pool would cause poolifier to throw
75
+ // "Cannot execute a task on destroying pool".
76
+ if (this.isShuttingDown) {
77
+ throw new Error('Worker pool is shutting down');
78
+ }
79
+
80
+ // FIX (#12): If a pool reset is in progress, wait briefly and retry
81
+ // instead of creating a duplicate pool.
82
+ if (this._resetInProgress) {
83
+ const maxWait = 3000;
84
+ const interval = 100;
85
+ const deadline = Date.now() + maxWait;
86
+ while (this._resetInProgress && Date.now() < deadline) {
87
+ await new Promise(r => setTimeout(r, interval));
88
+ }
89
+ if (this._resetInProgress) {
90
+ throw new Error('Worker pool is being reset, please retry');
91
+ }
92
+ // Reset complete — recurse once to get the fresh pool
93
+ return this.ensurePool(config);
94
+ }
95
+
96
+ // If pool exists, worker count matches, and we are not shutting down,
97
+ // return the existing pool immediately.
98
+ if (this.pool && this.currentWorkerCount === maxWorkers) {
99
+ return this.pool;
100
+ }
101
+
102
+ // Use a promise to coalesce concurrent initialization calls
103
+ if (this.poolInitializationPromise) {
104
+ return this.poolInitializationPromise;
105
+ }
106
+
107
+ // Snapshot the generation this init belongs to. If a shutdown bumps it
108
+ // while we await below (ensureBrowserInstalled can be slow), the checks
109
+ // after pool construction tear the new pool down instead of leaking it.
110
+ const myGeneration = this.generation;
111
+ this.poolInitializationPromise = (async () => {
112
+ try {
113
+ if (this.pool && this.currentWorkerCount !== maxWorkers) {
114
+ logger.log(`[WorkerPoolManager] Worker count changed from ${this.currentWorkerCount} to ${maxWorkers}, recreating pool...`);
115
+ await this.pool.destroy();
116
+ this.pool = null;
117
+ }
118
+
119
+ this.currentWorkerCount = maxWorkers;
120
+
121
+ // Install the master-side IPC guard BEFORE any worker is forked, so a
122
+ // `write EPIPE` on a closing worker channel (poolifier dispatch racing a
123
+ // 429-driven respawn) is swallowed instead of crashing the pi host via an
124
+ // unhandled cluster.Worker 'error'. Idempotent — only installs once.
125
+ setupMasterIpcErrorHandler();
126
+
127
+ logger.log(`[WorkerPoolManager] Initializing Unified FixedClusterPool (Size: ${maxWorkers}) on PID ${process.pid}`);
128
+
129
+ ensureBrowserCacheDir();
130
+
131
+ // Provision the camoufox browser if it is missing. This is a no-op
132
+ // (cheap existsSync) when the browser is already installed — i.e. on
133
+ // every install path whose postinstall ran (pi extension, plain npm).
134
+ // It only does work on install flows that skip postinstall (e.g.
135
+ // `npm install --ignore-scripts`), where the binary would
136
+ // otherwise be absent and the first scrape would fail.
137
+ await ensureBrowserInstalled();
138
+
139
+ const browserEnv = getBrowserEnv(config);
140
+
141
+ // Reclaim browser profiles leaked by a previously crashed run
142
+ // (Playwright self-cleans on a normal browser.close(); only an
143
+ // abnormal exit leaves them behind — and on a tmpfs /tmp that is
144
+ // leaked RAM). Lock-aware, so profiles in use by this or a
145
+ // concurrent instance are spared. Fire-and-forget: never block
146
+ // or fail pool startup. Sweeps the dedicated profile dir plus
147
+ // the legacy os.tmpdir() location for pre-upgrade leftovers.
148
+ const STALE_PROFILE_MS = 60 * 60 * 1000; // 1 hour
149
+ void cleanupStaleProfiles(getBrowserProfileDir(config), STALE_PROFILE_MS)
150
+ .catch((e) => logger.debug('[WorkerPoolManager] Profile sweep (profile dir) failed:', e));
151
+ void cleanupStaleProfiles(undefined, STALE_PROFILE_MS)
152
+ .catch((e) => logger.debug('[WorkerPoolManager] Profile sweep (legacy tmp) failed:', e));
153
+
154
+ // SIGKILL backstop: reap Camoufox processes orphaned by a PRIOR run that
155
+ // died abruptly (kill -9, crash) before its graceful shutdown could fire.
156
+ // The sweep is pi-research-specific (matches our camoufox-bin / profile
157
+ // marker, never the user's system Firefox) and orphan-aware (only kills
158
+ // processes whose parent is dead), so camoufox owned by THIS or a concurrent
159
+ // live instance is spared. Fire-and-forget; never block pool startup.
160
+ void cleanupOrphanedCamoufoxProcesses()
161
+ .catch((e) => logger.debug('[WorkerPoolManager] Startup orphan sweep failed:', e));
162
+
163
+ const workerConcurrency = (config || getConfig()).WORKER_CONCURRENCY;
164
+
165
+ // thread-worker.mjs is the esbuild-compiled bundle of thread-worker.ts and its
166
+ // local imports. Using a pre-compiled JS file eliminates any TypeScript loading
167
+ // concern in cluster child processes — no execArgv loader flags needed.
168
+ const workerPath = join(__dirname, './thread-worker.mjs');
169
+
170
+ // Fail loud and actionable if the worker bundle is missing. It is built by the
171
+ // `prepare` lifecycle (scripts/build.cjs), not committed to git, so a clone that
172
+ // skipped install scripts (or a partial install) leaves it absent. Without this
173
+ // guard poolifier throws an opaque ERR_MODULE_NOT_FOUND for the worker FILE and
174
+ // then respawns workers forever — the exact crash-loop that masks the real cause.
175
+ if (!existsSync(workerPath)) {
176
+ throw new Error(
177
+ `Browser worker bundle is missing at ${workerPath}. ` +
178
+ `It is produced by the build step; reinstall the extension (e.g. \`pi update --extensions\`) ` +
179
+ `or run \`npm install\` in the extension directory so the prepare/build script regenerates it.`,
180
+ );
181
+ }
182
+
183
+ // Identity token for THIS pool instance. The handlers below compare it against
184
+ // the live `this.poolEpoch` so a superseded pool's trailing events are ignored.
185
+ const myEpoch = ++this.poolEpoch;
186
+ this.pool = new FixedClusterPool(maxWorkers, workerPath, {
187
+ env: browserEnv,
188
+ // Prevent query leakage via process.argv in forked workers
189
+ workerOptions: { execArgv: [] },
190
+ errorHandler: (e: Error) => {
191
+ // Ignore events from a pool that has since been replaced/destroyed — a
192
+ // trailing error from an old pool's dying worker must not affect the live one.
193
+ if (this.poolEpoch !== myEpoch) return;
194
+ // A benign IPC teardown error (EPIPE/ECONNRESET/channel-closed) from a worker
195
+ // that is shutting down is not a health signal; counting it toward the
196
+ // consecutive-error threshold can trip auto-recovery on a healthy pool.
197
+ if (isBenignClusterIpcError(e)) {
198
+ logger.debug('[WorkerPoolManager] Ignoring benign cluster IPC error:', e);
199
+ return;
200
+ }
201
+ this.consecutiveErrors++;
202
+ metrics.increment('browser_pool_errors_total', 1);
203
+ logger.error('[WorkerPoolManager] Cluster Error:', e);
204
+ if (this.consecutiveErrors >= 3) {
205
+ metrics.increment('browser_pool_unhealthy_events_total', 1);
206
+ logger.error(`[WorkerPoolManager] Worker pool may be unhealthy: ${this.consecutiveErrors} consecutive errors. Consider restarting.`);
207
+ if (this.onPoolError) {
208
+ this.onPoolError(e, this.consecutiveErrors);
209
+ }
210
+ this.schedulePoolReset();
211
+ }
212
+ },
213
+ exitHandler: (code: number) => {
214
+ // null exit code means the worker was killed intentionally (e.g. pool.destroy()).
215
+ // Ignore exits from a superseded pool instance (see errorHandler).
216
+ if (code !== 0 && code !== null && this.poolEpoch === myEpoch) {
217
+ logger.error(`[WorkerPoolManager] Worker exited with code ${code}`);
218
+ this.consecutiveErrors++;
219
+ if (this.consecutiveErrors >= 3) {
220
+ logger.error(`[WorkerPoolManager] Worker pool unhealthy due to 3 consecutive exits. Scheduling auto-recovery...`);
221
+ if (this.onPoolError) {
222
+ this.onPoolError(new Error(`Worker exited with code ${code}`), this.consecutiveErrors);
223
+ }
224
+ this.schedulePoolReset();
225
+ }
226
+ }
227
+ },
228
+ workerChoiceStrategy: WorkerChoiceStrategies.ROUND_ROBIN,
229
+ enableTasksQueue: true,
230
+ tasksQueueOptions: {
231
+ concurrency: workerConcurrency, // configurable via PI_RESEARCH_WORKER_CONCURRENCY
232
+ }
233
+ });
234
+
235
+ // Secondary race check: if a shutdown() was called concurrent with
236
+ // the async pool construction above (between the early check and
237
+ // here). Checking the generation — not just isShuttingDown — catches
238
+ // a shutdown that has already STARTED AND FINISHED during our await
239
+ // (which resets isShuttingDown back to false). shutdown() also awaits
240
+ // this promise, so whichever side runs the destroy, the pool never
241
+ // survives un-referenced.
242
+ if (this.isShuttingDown || this.generation !== myGeneration) {
243
+ logger.warn('[WorkerPoolManager] Pool initialized but a shutdown intervened. Destroying...');
244
+ const built = this.pool;
245
+ // Only null the shared field if it still points at the pool we built —
246
+ // a concurrent path may already have moved on.
247
+ if (this.pool === built) this.pool = null;
248
+ await built.destroy().catch((err: any) => logger.debug('Swallowed pool destroy error:', err));
249
+ throw new Error('Worker pool is shutting down');
250
+ }
251
+
252
+ metrics.setGauge('browser_pool_workers', maxWorkers);
253
+ metrics.increment('browser_pool_initializations_total', 1, { success: 'true' });
254
+
255
+ if (this.lifecycle === ServiceLifecycle.UNINITIALIZED) {
256
+ this.lifecycle = ServiceLifecycle.INITIALIZED;
257
+ }
258
+
259
+ // Clear the coalescing promise on success too (not only on failure).
260
+ // Leaving a resolved promise here means a later ensurePool() call with a
261
+ // CHANGED worker count — which skips the line-79 fast path — would return
262
+ // this stale promise and hand back the OLD pool, silently ignoring the new
263
+ // count. Concurrent callers already hold their own reference to this promise,
264
+ // so clearing it is safe.
265
+ this.poolInitializationPromise = null;
266
+
267
+ return this.pool;
268
+ } catch (error) {
269
+ metrics.increment('browser_pool_initializations_total', 1, { success: 'false' });
270
+ // Clean up the promise ONLY on failure, so next caller can retry.
271
+ // On success, we keep it so concurrent callers get the same resolution.
272
+ this.poolInitializationPromise = null;
273
+ throw error;
274
+ }
275
+ })();
276
+
277
+ return this.poolInitializationPromise;
278
+ }
279
+
280
+ /**
281
+ * Get the current pool instance.
282
+ */
283
+ getPool(): any | null {
284
+ return this.pool;
285
+ }
286
+
287
+ /**
288
+ * Reset consecutive errors counter.
289
+ */
290
+ resetConsecutiveErrors(): void {
291
+ this.consecutiveErrors = 0;
292
+ }
293
+
294
+ /**
295
+ * Decay the consecutive-error counter by one. Called on each healthy leadership
296
+ * tick so a single transient blip fades over time WITHOUT fully zeroing the
297
+ * counter. Blind-resetting to zero every tick (the previous behavior) defeated
298
+ * auto-recovery for any crash-loop slower than the threshold-per-tick window: a
299
+ * worker dying once every few seconds never reached `consecutiveErrors >= 3`
300
+ * because the ~5 s leadership tick kept resetting it. Decay lets a sustained slow
301
+ * failure accumulate to the reset threshold while still forgiving isolated blips.
302
+ */
303
+ decayConsecutiveErrors(): void {
304
+ if (this.consecutiveErrors > 0) this.consecutiveErrors--;
305
+ }
306
+
307
+ /**
308
+ * Schedule an out-of-band pool reset. Called from poolifier event handlers
309
+ * where destroying the pool synchronously would deadlock. Waits 1 s so the
310
+ * current event-handler call-stack unwinds before the pool is destroyed.
311
+ */
312
+ private schedulePoolReset(): void {
313
+ if (this.isShuttingDown) return;
314
+ // Single-shot: poolifier fires BOTH errorHandler and exitHandler for the same
315
+ // pool failure, and each call would overwrite this._resetTimer — orphaning the
316
+ // first timer so shutdown() can no longer clear it (it then fires late and
317
+ // re-destroys an already-dead pool or races a rebuilt one). Bail if a reset is
318
+ // already pending; the flag is cleared once the reset completes.
319
+ if (this._resetInProgress) return;
320
+ const deadPool = this.pool;
321
+ // FIX (#12): Don't nullify pool immediately — mark it as dead but keep the
322
+ // reference so ensurePool() doesn't create a duplicate while the old one
323
+ // is still being destroyed. Instead, use a flag to signal reset is in progress.
324
+ this._resetInProgress = true;
325
+ this.currentWorkerCount = null;
326
+ this.consecutiveErrors = 0;
327
+ // Pin the generation this reset belongs to. shutdown() bumps generation, so if a
328
+ // shutdown (and a subsequent re-init) happens while this timer is pending, the
329
+ // generation no longer matches and the timer must NOT touch the new pool's state.
330
+ const myGen = this.generation;
331
+ metrics.increment('browser_pool_auto_recoveries_total', 1);
332
+ logger.info('[WorkerPoolManager] Pool scheduled for auto-recovery; next ensurePool() will wait for old pool destruction.');
333
+ // Destroy the old pool asynchronously after the event handler returns.
334
+ const t = setTimeout(async () => {
335
+ try {
336
+ // If shutdown() ran while this timer was pending it already destroyed the
337
+ // pool (and bumped the generation), so skip — destroying again is spurious
338
+ // and nulling shared state would clobber a pool rebuilt after the shutdown.
339
+ if (this.isShuttingDown || this.generation !== myGen) return;
340
+ if (deadPool) await deadPool.destroy();
341
+ logger.info('[WorkerPoolManager] Auto-recovery: old pool destroyed.');
342
+ } catch (err) {
343
+ logger.warn('[WorkerPoolManager] Auto-recovery: error destroying old pool:', err);
344
+ } finally {
345
+ this._resetTimer = null;
346
+ // Only clear shared state if we still own this generation (re-check: the
347
+ // await above could have straddled a shutdown).
348
+ if (!this.isShuttingDown && this.generation === myGen) {
349
+ // Now nullify the dead pool and clear the flag
350
+ if (this.pool === deadPool) this.pool = null;
351
+ // Clear the cached init promise too — it still resolves to the now
352
+ // DESTROYED pool. Without this, the next ensurePool() skips the
353
+ // (null) fast-path and returns the stale promise, handing callers a
354
+ // dead pool ("Cannot execute a task on destroying pool"). shutdown()
355
+ // already clears it; auto-recovery must as well.
356
+ this.poolInitializationPromise = null;
357
+ this._resetInProgress = false;
358
+ }
359
+ }
360
+ }, 1000);
361
+ if (t.unref) t.unref();
362
+ this._resetTimer = t;
363
+ }
364
+
365
+ /**
366
+ * Check if the pool is shutting down.
367
+ */
368
+ isPoolShuttingDown(): boolean {
369
+ return this.isShuttingDown;
370
+ }
371
+
372
+ /**
373
+ * Destroy the pool and clean up resources.
374
+ */
375
+ async shutdown(): Promise<void> {
376
+ if (this.isShuttingDown) return;
377
+ this.isShuttingDown = true;
378
+ // Invalidate any in-flight ensurePool() so the pool it builds is torn down
379
+ // by its own generation check rather than surviving past this shutdown.
380
+ this.generation++;
381
+ // Cancel a pending auto-recovery destroy timer: with the generation now bumped it
382
+ // would no-op anyway, but cancelling frees the handle immediately and avoids a
383
+ // late destroy() racing this teardown.
384
+ if (this._resetTimer) { clearTimeout(this._resetTimer); this._resetTimer = null; }
385
+
386
+ // Await an in-flight initialization before tearing down. Without this, a
387
+ // pool whose construction had not yet assigned this.pool when we entered
388
+ // would be built AFTER we returned and leak (cluster workers + Camoufox).
389
+ // The init either self-destroys (generation check) or assigns this.pool,
390
+ // which we then destroy below. Its rejection from the generation check is
391
+ // expected — swallow it.
392
+ const initInFlight = this.poolInitializationPromise;
393
+ if (initInFlight) {
394
+ await initInFlight.catch(() => { /* generation-aborted or failed init — nothing to keep */ });
395
+ }
396
+
397
+ if (this.pool) {
398
+ try {
399
+ // Use a timeout for pool destruction. Workers run async browser teardown
400
+ // in their killHandler (context.close / browser.close via Playwright), so
401
+ // allow enough time for those to complete before the IPC channel closes.
402
+ const destroyPromise = this.pool.destroy();
403
+ destroyPromise.catch((err: Error) => logger.debug(`[WorkerPoolManager] Background pool destroy rejection: ${err.message}`));
404
+ await raceWithDeadline(destroyPromise, 5000);
405
+ } catch (e) {
406
+ logger.warn('[WorkerPoolManager] Pool destruction error:', e);
407
+ }
408
+ // Allow a brief moment for IPC channels and worker browser teardown to complete.
409
+ // Reduced from 1500ms to 200ms because browser processes are now also
410
+ // explicitly cleaned up by the scheduler via killBrowserProcesses.
411
+ await new Promise(resolve => setTimeout(resolve, 200));
412
+ this.pool = null;
413
+ }
414
+
415
+ this.poolInitializationPromise = null;
416
+ this.currentWorkerCount = null;
417
+ this.consecutiveErrors = 0;
418
+ // Clear any in-flight auto-recovery flag too: if a schedulePoolReset() timer
419
+ // was pending when shutdown ran, leaving this true makes the next ensurePool()
420
+ // needlessly busy-wait (up to 3 s) and possibly throw "being reset" even though
421
+ // the pool is ready to re-init.
422
+ this._resetInProgress = false;
423
+ // Reset so this instance can be re-used after shutdown — the service
424
+ // registry keeps the same WorkerPoolManager instance across scheduler
425
+ // restarts, so without this reset ensurePool() would permanently throw.
426
+ this.isShuttingDown = false;
427
+ }
428
+
429
+ async initialize(): Promise<void> {
430
+ if (this.lifecycle === ServiceLifecycle.INITIALIZED) {
431
+ return;
432
+ }
433
+ this.lifecycle = ServiceLifecycle.INITIALIZING;
434
+ logger.debug('[WorkerPoolManager] Initializing...');
435
+ this.lifecycle = ServiceLifecycle.INITIALIZED;
436
+ logger.debug('[WorkerPoolManager] Initialized');
437
+ }
438
+
439
+ async dispose(): Promise<void> {
440
+ if (this.lifecycle === ServiceLifecycle.DISPOSED) {
441
+ return;
442
+ }
443
+ this.lifecycle = ServiceLifecycle.DISPOSING;
444
+ logger.debug('[WorkerPoolManager] Disposing...');
445
+ await this.shutdown();
446
+ this.lifecycle = ServiceLifecycle.DISPOSED;
447
+ logger.debug('[WorkerPoolManager] Disposed');
448
+ }
449
+ }