@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.
- package/LICENSE +1 -1
- package/README.md +64 -115
- package/dist/cli.mjs +23211 -0
- package/dist/prompts/research-knowledge-relevance-triage.md +35 -0
- package/dist/prompts/research-knowledge-search-extractor.md +41 -0
- package/dist/prompts/research-tool-usage.md +92 -0
- package/dist/prompts/researcher.md +89 -0
- package/dist/prompts/system-coordinator.md +75 -0
- package/dist/prompts/system-lead-evaluator.md +118 -0
- package/dist/thread-worker.mjs +2611 -0
- package/dist/webgpu-probe.mjs +80 -0
- package/docs/AGENT-SKILL.md +102 -0
- package/docs/ARCHITECTURE.md +306 -80
- package/docs/CONFIGURATION.md +249 -0
- package/docs/KNOWLEDGE-STORE.md +213 -0
- package/docs/PI-EXTENSION.md +91 -0
- package/docs/SDK.md +113 -0
- package/docs/THIRD-PARTY-NOTICES.md +64 -0
- package/package.json +102 -36
- package/scripts/build.cjs +140 -0
- package/scripts/cleanup.cjs +149 -0
- package/scripts/prepare.cjs +28 -0
- package/scripts/setup.cjs +134 -0
- package/skills/pi-research/README.md +90 -0
- package/skills/pi-research/SKILL.md +102 -0
- package/skills/pi-research/references/configuration.md +77 -0
- package/skills/pi-research/scripts/run.mjs +237 -0
- package/src/cleanup/research-cleanup.ts +140 -0
- package/src/cli.ts +1044 -0
- package/src/config.ts +1036 -147
- package/src/constants.ts +115 -84
- package/src/core/health-check-service.ts +265 -0
- package/src/core/interfaces/health-check-interfaces.ts +50 -0
- package/src/core/interfaces/knowledge-interfaces.ts +190 -0
- package/src/core/interfaces/observer-interfaces.ts +67 -0
- package/src/core/interfaces/orchestration-interfaces.ts +86 -0
- package/src/core/interfaces/planning-interfaces.ts +100 -0
- package/src/core/interfaces/process-interfaces.ts +37 -0
- package/src/core/interfaces/research-plan-types.ts +52 -0
- package/src/core/interfaces/scheduler-interfaces.ts +91 -0
- package/src/core/interfaces/service-names.ts +39 -0
- package/src/core/interfaces/state-manager-interfaces.ts +29 -0
- package/src/core/interfaces/steering-interfaces.ts +31 -0
- package/src/core/llm/agentic-repair.ts +169 -0
- package/src/core/llm/inject-date.ts +52 -0
- package/src/core/llm/llm-timeout.ts +64 -0
- package/src/core/llm/llm-utils.ts +86 -0
- package/src/core/llm/model-registry-factory.ts +287 -0
- package/src/core/llm/pi-ai-completion.ts +29 -0
- package/src/core/llm/prompts.ts +50 -0
- package/src/core/llm/research-model-resolver.ts +91 -0
- package/src/core/planning-constants.ts +13 -0
- package/src/core/planning-service.ts +649 -0
- package/src/core/planning-utils.ts +299 -0
- package/src/core/scheduler-factory.ts +48 -0
- package/src/core/scheduler-service.ts +123 -0
- package/src/core/service-initialization.ts +194 -0
- package/src/core/service-interfaces.ts +48 -0
- package/src/core/service-registry.ts +659 -0
- package/src/healthcheck/index.ts +271 -265
- package/src/healthcheck/registry.ts +130 -0
- package/src/index.ts +639 -50
- package/src/infrastructure/browser/browser-cleanup.ts +309 -0
- package/src/infrastructure/browser/browser-client.ts +199 -0
- package/src/infrastructure/browser/browser-error-utils.ts +160 -0
- package/src/infrastructure/browser/browser-lifecycle.ts +120 -0
- package/src/infrastructure/browser/browser-server.ts +174 -0
- package/src/infrastructure/browser/browser-task-scheduler.ts +461 -0
- package/src/infrastructure/browser/cleanup-utils.ts +155 -0
- package/src/infrastructure/browser/client-agent.ts +31 -0
- package/src/infrastructure/browser/config.ts +288 -0
- package/src/infrastructure/browser/ensure-browser.ts +205 -0
- package/src/infrastructure/browser/index.ts +37 -0
- package/src/infrastructure/browser/priority-task-queue.ts +226 -0
- package/src/infrastructure/browser/scheduler-factory.ts +354 -0
- package/src/infrastructure/browser/task-execution-service.ts +209 -0
- package/src/infrastructure/browser/thread-worker-browser.ts +282 -0
- package/src/infrastructure/browser/thread-worker-lifecycle.ts +323 -0
- package/src/infrastructure/browser/thread-worker-messaging.ts +662 -0
- package/src/infrastructure/browser/thread-worker-types.ts +25 -0
- package/src/infrastructure/browser/thread-worker.mjs +2611 -0
- package/src/infrastructure/browser/thread-worker.ts +168 -0
- package/src/infrastructure/browser/worker-pool-manager.ts +449 -0
- package/src/infrastructure/embedding/embedding-client.ts +205 -0
- package/src/infrastructure/embedding/embedding-factory.ts +324 -0
- package/src/infrastructure/embedding/embedding-server.ts +535 -0
- package/src/infrastructure/file-lock-service.ts +592 -0
- package/src/infrastructure/gpu-resource-service.ts +191 -0
- package/src/infrastructure/knowledge-store-service.ts +475 -0
- package/src/infrastructure/metrics-service.ts +129 -0
- package/src/infrastructure/process-lifecycle-service.ts +253 -0
- package/src/infrastructure/scheduler-factory-service.ts +58 -0
- package/src/infrastructure/service-initialization.ts +292 -0
- package/src/infrastructure/state/state-backup-manager.ts +290 -0
- package/src/infrastructure/state/state-browser-api.ts +62 -0
- package/src/infrastructure/state/state-browser-manager.ts +70 -0
- package/src/infrastructure/state/state-manager-service.ts +264 -0
- package/src/infrastructure/state/state-manager.ts +547 -0
- package/src/infrastructure/state/state-metrics.ts +92 -0
- package/src/infrastructure/state/state-migration.ts +103 -0
- package/src/infrastructure/state/state-path-configuration.ts +119 -0
- package/src/infrastructure/state/state-session-api.ts +94 -0
- package/src/infrastructure/state/state-session-manager.ts +127 -0
- package/src/infrastructure/state/state-validator.ts +93 -0
- package/src/infrastructure/types/state-types.ts +85 -0
- package/src/knowledge/chunker.ts +157 -0
- package/src/knowledge/embedder-init.ts +413 -0
- package/src/knowledge/embedder-utils.ts +296 -0
- package/src/knowledge/embedder.ts +592 -0
- package/src/knowledge/index.ts +126 -0
- package/src/knowledge/lancedb-loader.ts +35 -0
- package/src/knowledge/migration.ts +17 -0
- package/src/knowledge/model-config.ts +132 -0
- package/src/knowledge/onnx-env.ts +48 -0
- package/src/knowledge/store-operations.ts +291 -0
- package/src/knowledge/store-schema.ts +79 -0
- package/src/knowledge/store.ts +1251 -0
- package/src/knowledge/transformers-loader.ts +31 -0
- package/src/knowledge/webgpu-probe.mjs +80 -0
- package/src/knowledge/webgpu-viability.ts +255 -0
- package/src/knowledge/writer-queue.ts +252 -0
- package/src/logger.ts +316 -122
- package/src/observers/research-observer-impl.ts +459 -0
- package/src/orchestration/deep-research-orchestrator.ts +524 -773
- package/src/orchestration/deep-research-types.ts +47 -35
- package/src/orchestration/headless-observer.ts +114 -0
- package/src/orchestration/orchestration-types.ts +33 -0
- package/src/orchestration/quick-research-orchestrator.ts +463 -0
- package/src/orchestration/research-orchestration-service.ts +549 -0
- package/src/orchestration/research-session-service.ts +178 -0
- package/src/orchestration/research-synthesis-service.ts +348 -0
- package/src/orchestration/researcher-executor.ts +394 -0
- package/src/orchestration/researcher.ts +115 -42
- package/src/orchestration/service-initialization.ts +109 -0
- package/src/orchestration/session-context.ts +7 -3
- package/src/orchestration/session-state.ts +672 -0
- package/src/prompts/research-knowledge-relevance-triage.md +35 -0
- package/src/prompts/research-knowledge-search-extractor.md +41 -0
- package/src/prompts/research-tool-usage.md +63 -16
- package/src/prompts/researcher.md +59 -136
- package/src/prompts/system-coordinator.md +64 -13
- package/src/prompts/system-lead-evaluator.md +82 -189
- package/src/research-config.ts +872 -0
- package/src/sdk.ts +847 -0
- package/src/security/cisa-kev.ts +72 -9
- package/src/security/cvss.ts +92 -0
- package/src/security/ecosystem.ts +45 -0
- package/src/security/github-advisories.ts +168 -428
- package/src/security/github-advisory-types.ts +282 -0
- package/src/security/index.ts +63 -177
- package/src/security/nvd-client.ts +550 -0
- package/src/security/nvd-types.ts +179 -0
- package/src/security/osv-client.ts +246 -0
- package/src/security/osv-types.ts +285 -0
- package/src/security/types.ts +67 -53
- package/src/skill-install/skill-installer.ts +519 -0
- package/src/stackexchange/index.ts +81 -27
- package/src/stackexchange/output/compact.ts +7 -6
- package/src/stackexchange/output/table.ts +11 -7
- package/src/stackexchange/queries.ts +0 -10
- package/src/stackexchange/rest-client.ts +141 -66
- package/src/stackexchange/types.ts +0 -17
- package/src/tool.ts +15 -423
- package/src/tools/grep.ts +32 -310
- package/src/tools/health-tool-definition.ts +101 -0
- package/src/tools/index.ts +38 -9
- package/src/tools/research-knowledge-search.ts +974 -0
- package/src/tools/research-knowledge-types.ts +77 -0
- package/src/tools/research-tool-definition.ts +494 -0
- package/src/tools/scrape.ts +286 -388
- package/src/tools/search.ts +98 -153
- package/src/tools/security.ts +107 -80
- package/src/tools/stackexchange.ts +58 -35
- package/src/tools/youtube-transcript.ts +163 -0
- package/src/tui/knowledge-search-panel.ts +86 -0
- package/src/tui/pulse.ts +86 -0
- package/src/tui/research-health.ts +124 -0
- package/src/tui/research-panel-color-utils.ts +340 -0
- package/src/tui/research-panel-format.ts +54 -0
- package/src/tui/research-panel-state.ts +404 -0
- package/src/tui/research-panel-wave.ts +280 -0
- package/src/tui/research-panel.ts +339 -435
- package/src/tui/research-tui-manager.ts +233 -0
- package/src/tui/tui-controller.ts +137 -0
- package/src/types/extension-context.ts +18 -49
- package/src/types/index.ts +61 -0
- package/src/types/llm.ts +68 -29
- package/src/types/research-panel-types.ts +61 -0
- package/src/utils/circuit-breaker.ts +146 -0
- package/src/utils/citation-utils.ts +97 -0
- package/src/utils/disk-space-checker.ts +55 -0
- package/src/utils/error-tracker.ts +265 -0
- package/src/utils/host-config.ts +45 -0
- package/src/utils/input-validation.ts +8 -10
- package/src/utils/json-utils.ts +150 -16
- package/src/utils/llm-usage.ts +54 -0
- package/src/utils/log-rotation.ts +125 -0
- package/src/utils/log-utils.ts +159 -0
- package/src/utils/make-resource-loader.ts +3 -1
- package/src/utils/metrics-summary.ts +459 -0
- package/src/utils/metrics.ts +302 -0
- package/src/utils/research-export.ts +28 -12
- package/src/utils/safe-unref.ts +31 -0
- package/src/utils/shared-links.ts +195 -90
- package/src/utils/shutdown-manager.ts +65 -0
- package/src/utils/stdio-capture.ts +407 -0
- package/src/utils/text-utils.ts +264 -7
- package/src/utils/tool-usage-tracker.ts +83 -16
- package/src/utils/url-utils.ts +255 -0
- package/src/utils/user-agent.ts +28 -0
- package/src/web-research/browser-search.ts +179 -0
- package/src/web-research/retry-utils.ts +173 -77
- package/src/web-research/scraper-types.ts +72 -0
- package/src/web-research/scraper-utils.ts +434 -0
- package/src/web-research/search.ts +78 -218
- package/src/web-research/types.ts +9 -24
- package/src/web-research/utils.ts +2 -143
- package/src/web-research/web-scraper.ts +477 -0
- package/src/youtube/index.ts +154 -0
- package/src/youtube/potoken.ts +259 -0
- package/src/youtube/transcript-client.ts +390 -0
- package/src/youtube/video-id.ts +118 -0
- package/config/searxng/default-settings.yml +0 -189
- package/config/searxng/limiter.toml +0 -34
- package/config/tooling/eslint.config.js +0 -73
- package/config/tooling/vitest.config.integration.ts +0 -17
- package/config/tooling/vitest.config.ts +0 -40
- package/config/tooling/vitest.config.unit.ts +0 -12
- package/docs/assets/README-banner.jpg +0 -0
- package/scripts/setup.js +0 -174
- package/src/infrastructure/searxng-lifecycle.ts +0 -577
- package/src/infrastructure/searxng-manager.ts +0 -1183
- package/src/infrastructure/state-manager.ts +0 -918
- package/src/orchestration/deep-research-reducer.ts +0 -129
- package/src/orchestration/id-utils.ts +0 -63
- package/src/security/nvd.ts +0 -532
- package/src/security/osv.ts +0 -487
- package/src/stackexchange/cache.ts +0 -111
- package/src/stackexchange/output/index.ts +0 -7
- package/src/stackexchange/output/json.ts +0 -21
- package/src/tui/searxng-status.ts +0 -80
- package/src/utils/inject-date.ts +0 -29
- package/src/utils/research-error.ts +0 -205
- package/src/utils/searxng-config.ts +0 -94
- package/src/utils/session-state.ts +0 -295
- package/src/web-research/dependencies.ts +0 -20
- package/src/web-research/scrapers.ts +0 -935
- package/tsconfig.json +0 -40
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Browser Cleanup Utilities
|
|
3
|
+
*
|
|
4
|
+
* Functions for cleaning up orphaned browser processes and related resources.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { exec } from 'node:child_process';
|
|
8
|
+
import { promisify } from 'node:util';
|
|
9
|
+
import * as os from 'node:os';
|
|
10
|
+
import { logger } from '../../logger.ts';
|
|
11
|
+
|
|
12
|
+
const execAsync = promisify(exec);
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Find and kill orphaned Camoufox browser processes.
|
|
16
|
+
*
|
|
17
|
+
* Orphaned processes are those that:
|
|
18
|
+
* 1. Are running Camoufox/Firefox browsers
|
|
19
|
+
* 2. Have a parent PID that no longer exists
|
|
20
|
+
* 3. Are not part of an active browser pool
|
|
21
|
+
*
|
|
22
|
+
* This should be called after pool destruction to clean up any stragglers.
|
|
23
|
+
*/
|
|
24
|
+
export async function cleanupOrphanedCamoufoxProcesses(): Promise<void> {
|
|
25
|
+
const platform = os.platform();
|
|
26
|
+
|
|
27
|
+
try {
|
|
28
|
+
if (platform === 'darwin' || platform === 'linux') {
|
|
29
|
+
await cleanupOrphanedProcessesUnix();
|
|
30
|
+
} else if (platform === 'win32') {
|
|
31
|
+
await cleanupOrphanedProcessesWindows();
|
|
32
|
+
} else {
|
|
33
|
+
logger.warn(`[BrowserCleanup] Platform ${platform} not supported for orphan cleanup`);
|
|
34
|
+
}
|
|
35
|
+
} catch (error) {
|
|
36
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
37
|
+
logger.warn(`[BrowserCleanup] Failed to cleanup orphaned processes: ${msg}`);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Marker that identifies a browser process as one WE launched (Camoufox via
|
|
43
|
+
* Playwright), never the user's own system Firefox. We match on the launched
|
|
44
|
+
* EXECUTABLE — `camoufox-bin` (Linux), `camoufox.exe` (Windows) or
|
|
45
|
+
* `…/MacOS/camoufox` (macOS), per camoufox-js's LAUNCH_FILE — plus our
|
|
46
|
+
* `…/pi-research/profiles/…` profile path (the fallback when PI_RESEARCH_TMP_DIR
|
|
47
|
+
* relocates profiles). Anchoring on the executable name (rather than a bare
|
|
48
|
+
* `camoufox` substring) keeps an incidental `tail camoufox.log`, `grep camoufox`,
|
|
49
|
+
* or an editor buffer from ever matching — the system Firefox matches none of
|
|
50
|
+
* these. (A user's OWN orphaned standalone Camoufox is the one indistinguishable
|
|
51
|
+
* case; it is gated further by the strict orphan conditions below.)
|
|
52
|
+
*/
|
|
53
|
+
export const PI_BROWSER_MARKER = /camoufox-bin\b|camoufox\.exe\b|[/\\]MacOS[/\\]camoufox\b|[/\\]pi-research[/\\]profiles[/\\]/i;
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Cleanup orphaned processes on Unix-like systems (macOS, Linux)
|
|
57
|
+
*/
|
|
58
|
+
async function cleanupOrphanedProcessesUnix(): Promise<void> {
|
|
59
|
+
try {
|
|
60
|
+
// Scan by FULL command line (args), not comm, so we can positively identify our
|
|
61
|
+
// own Camoufox processes and never touch the user's system Firefox.
|
|
62
|
+
const { stdout } = await execAsync(
|
|
63
|
+
'ps -eo pid,ppid,args 2>/dev/null',
|
|
64
|
+
);
|
|
65
|
+
|
|
66
|
+
const lines = stdout.trim().split('\n');
|
|
67
|
+
const cleanupTasks: Promise<void>[] = [];
|
|
68
|
+
|
|
69
|
+
for (const line of lines) {
|
|
70
|
+
// Capture PID, PPID, and the remaining args from each line.
|
|
71
|
+
const match = line.trim().match(/^\s*(\d+)\s+(\d+)\s+(.*)$/);
|
|
72
|
+
if (!match || !match[1] || !match[2]) continue;
|
|
73
|
+
|
|
74
|
+
const args = match[3] || '';
|
|
75
|
+
// Only ever consider OUR Camoufox processes — never the user's Firefox.
|
|
76
|
+
if (!PI_BROWSER_MARKER.test(args)) continue;
|
|
77
|
+
|
|
78
|
+
const pid = parseInt(match[1], 10);
|
|
79
|
+
const ppid = parseInt(match[2], 10);
|
|
80
|
+
const comm = 'camoufox';
|
|
81
|
+
// Never target ourselves.
|
|
82
|
+
if (pid === process.pid) continue;
|
|
83
|
+
|
|
84
|
+
// Determine if orphan:
|
|
85
|
+
// 1. PPID is 1 (adopted by init)
|
|
86
|
+
// 2. Parent PID is dead (process.kill(ppid, 0) throws)
|
|
87
|
+
let isOrphan = false;
|
|
88
|
+
if (ppid === 1) {
|
|
89
|
+
isOrphan = true;
|
|
90
|
+
} else {
|
|
91
|
+
try {
|
|
92
|
+
process.kill(ppid, 0);
|
|
93
|
+
isOrphan = false;
|
|
94
|
+
} catch (e) {
|
|
95
|
+
// ESRCH = parent is gone (genuine orphan). EPERM = parent is ALIVE but
|
|
96
|
+
// owned by another user — NOT an orphan. Conflating them would let a
|
|
97
|
+
// multi-user host / CI side-car kill a live, foreign-owned browser.
|
|
98
|
+
isOrphan = (e as NodeJS.ErrnoException)?.code !== 'EPERM';
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
if (isOrphan) {
|
|
103
|
+
cleanupTasks.push((async () => {
|
|
104
|
+
logger.log(`[BrowserCleanup] Found orphaned ${comm} process: PID ${pid}, parent PID ${ppid}`);
|
|
105
|
+
|
|
106
|
+
try {
|
|
107
|
+
// Kill the orphaned process gracefully
|
|
108
|
+
process.kill(pid, 'SIGTERM');
|
|
109
|
+
|
|
110
|
+
// Give it a moment to exit gracefully (wait in parallel)
|
|
111
|
+
await new Promise(resolve => setTimeout(resolve, 500));
|
|
112
|
+
|
|
113
|
+
// Check if it's still running and force kill if needed
|
|
114
|
+
try {
|
|
115
|
+
process.kill(pid, 0);
|
|
116
|
+
// Still running, force kill
|
|
117
|
+
process.kill(pid, 'SIGKILL');
|
|
118
|
+
logger.warn(`[BrowserCleanup] Force killed orphaned process: PID ${pid}`);
|
|
119
|
+
} catch {
|
|
120
|
+
// Process exited
|
|
121
|
+
logger.log(`[BrowserCleanup] Terminated orphaned process: PID ${pid}`);
|
|
122
|
+
}
|
|
123
|
+
} catch (killError) {
|
|
124
|
+
const msg = killError instanceof Error ? killError.message : String(killError);
|
|
125
|
+
logger.warn(`[BrowserCleanup] Failed to kill orphaned process PID ${pid}: ${msg}`);
|
|
126
|
+
}
|
|
127
|
+
})());
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
if (cleanupTasks.length > 0) {
|
|
132
|
+
await Promise.all(cleanupTasks);
|
|
133
|
+
logger.log(`[BrowserCleanup] Cleaned up ${cleanupTasks.length} orphaned browser process(es)`);
|
|
134
|
+
}
|
|
135
|
+
} catch (error) {
|
|
136
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
137
|
+
logger.warn(`[BrowserCleanup] Failed to find orphaned processes: ${msg}`);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
interface WinProc { pid: number; ppid: number; name: string; commandLine: string; }
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Snapshot all Windows processes with their parent PID and FULL command line.
|
|
145
|
+
*
|
|
146
|
+
* Uses PowerShell/CIM rather than `wmic` (removed by default in Windows 11 24H2
|
|
147
|
+
* and Server 2025). A single call returns the whole process table, which lets us
|
|
148
|
+
* (a) positively identify OUR Camoufox via {@link PI_BROWSER_MARKER} on the
|
|
149
|
+
* command line — never the user's own firefox.exe — and (b) detect dead parents
|
|
150
|
+
* from the same snapshot without a per-PID query.
|
|
151
|
+
*/
|
|
152
|
+
async function queryWindowsProcesses(): Promise<WinProc[]> {
|
|
153
|
+
const cmd =
|
|
154
|
+
'powershell -NoProfile -NonInteractive -Command ' +
|
|
155
|
+
'"Get-CimInstance Win32_Process | Select-Object ProcessId,ParentProcessId,Name,CommandLine | ConvertTo-Json -Compress"';
|
|
156
|
+
const { stdout } = await execAsync(cmd, { maxBuffer: 1024 * 1024 * 16 }).catch(() => ({ stdout: '' }));
|
|
157
|
+
if (!stdout.trim()) return [];
|
|
158
|
+
|
|
159
|
+
let parsed: unknown;
|
|
160
|
+
try {
|
|
161
|
+
parsed = JSON.parse(stdout);
|
|
162
|
+
} catch {
|
|
163
|
+
return [];
|
|
164
|
+
}
|
|
165
|
+
// ConvertTo-Json emits a bare object for a single result, an array otherwise.
|
|
166
|
+
const arr = Array.isArray(parsed) ? parsed : [parsed];
|
|
167
|
+
const procs: WinProc[] = [];
|
|
168
|
+
for (const p of arr) {
|
|
169
|
+
if (!p || typeof p !== 'object') continue;
|
|
170
|
+
const rec = p as Record<string, unknown>;
|
|
171
|
+
const pid = Number(rec['ProcessId']);
|
|
172
|
+
if (!Number.isFinite(pid)) continue;
|
|
173
|
+
const ppid = Number(rec['ParentProcessId']);
|
|
174
|
+
const name = rec['Name'];
|
|
175
|
+
const commandLine = rec['CommandLine'];
|
|
176
|
+
procs.push({
|
|
177
|
+
pid,
|
|
178
|
+
// 0 is an "unknown parent" sentinel (unparseable/missing ParentProcessId).
|
|
179
|
+
// It must NEVER be treated as orphaned — see cleanupOrphanedProcessesWindows.
|
|
180
|
+
ppid: Number.isFinite(ppid) && ppid > 0 ? ppid : 0,
|
|
181
|
+
name: typeof name === 'string' ? name : '',
|
|
182
|
+
commandLine: typeof commandLine === 'string' ? commandLine : '',
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
return procs;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Cleanup orphaned processes on Windows
|
|
190
|
+
*/
|
|
191
|
+
async function cleanupOrphanedProcessesWindows(): Promise<void> {
|
|
192
|
+
try {
|
|
193
|
+
const procs = await queryWindowsProcesses();
|
|
194
|
+
if (procs.length === 0) return;
|
|
195
|
+
|
|
196
|
+
// Live-PID set from the same snapshot. A process whose ParentProcessId is
|
|
197
|
+
// absent from this set has a dead parent — the Windows analogue of the Unix
|
|
198
|
+
// path's `process.kill(ppid, 0)` liveness probe. (Windows does NOT reparent
|
|
199
|
+
// to PID 1, and never resets a dead parent's PID to 0, so absence-from-table
|
|
200
|
+
// is the correct signal.)
|
|
201
|
+
const alivePids = new Set(procs.map((p) => p.pid));
|
|
202
|
+
const cleanupTasks: Promise<void>[] = [];
|
|
203
|
+
|
|
204
|
+
for (const proc of procs) {
|
|
205
|
+
// ONLY ever consider our own Camoufox — match the full command line so the
|
|
206
|
+
// user's personal firefox.exe is never a candidate. This is the safety
|
|
207
|
+
// guarantee the old image-name-only match lacked.
|
|
208
|
+
if (!PI_BROWSER_MARKER.test(proc.commandLine)) continue;
|
|
209
|
+
if (proc.pid === process.pid) continue;
|
|
210
|
+
// Unknown parent (sentinel 0): we cannot prove it is orphaned, so we must
|
|
211
|
+
// NOT kill it — a live pool browser must never be force-killed on a guess.
|
|
212
|
+
if (proc.ppid <= 0) continue;
|
|
213
|
+
// Orphaned iff the parent is gone from the live process table.
|
|
214
|
+
if (alivePids.has(proc.ppid)) continue;
|
|
215
|
+
|
|
216
|
+
cleanupTasks.push((async () => {
|
|
217
|
+
logger.log(`[BrowserCleanup] Found orphaned Camoufox process: PID ${proc.pid}, dead parent PID ${proc.ppid}`);
|
|
218
|
+
try {
|
|
219
|
+
await execAsync(`taskkill /PID ${proc.pid} /F /T`);
|
|
220
|
+
logger.log(`[BrowserCleanup] Terminated orphaned process: PID ${proc.pid}`);
|
|
221
|
+
} catch (killError) {
|
|
222
|
+
const msg = killError instanceof Error ? killError.message : String(killError);
|
|
223
|
+
logger.warn(`[BrowserCleanup] Failed to kill orphaned process PID ${proc.pid}: ${msg}`);
|
|
224
|
+
}
|
|
225
|
+
})());
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
if (cleanupTasks.length > 0) {
|
|
229
|
+
await Promise.all(cleanupTasks);
|
|
230
|
+
logger.log(`[BrowserCleanup] Cleaned up ${cleanupTasks.length} orphaned browser process(es)`);
|
|
231
|
+
}
|
|
232
|
+
} catch (error) {
|
|
233
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
234
|
+
logger.warn(`[BrowserCleanup] Failed to find orphaned processes on Windows: ${msg}`);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
// ============================================================================
|
|
240
|
+
// Browser Manager Cleanup Functions
|
|
241
|
+
// ============================================================================
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Snapshot browser PIDs that are children of the given worker PIDs
|
|
245
|
+
*/
|
|
246
|
+
export async function getBrowserPidsForWorkers(workerPids: number[]): Promise<number[]> {
|
|
247
|
+
const platform = os.platform();
|
|
248
|
+
const pids: number[] = [];
|
|
249
|
+
if (!workerPids || workerPids.length === 0) return pids;
|
|
250
|
+
|
|
251
|
+
try {
|
|
252
|
+
if (platform === 'darwin' || platform === 'linux') {
|
|
253
|
+
const { stdout } = await execAsync('ps -eo pid,ppid,comm | grep -E "(firefox|camoufox)" | grep -v grep').catch(() => ({ stdout: '' }));
|
|
254
|
+
const lines = stdout.trim().split('\n');
|
|
255
|
+
for (const line of lines) {
|
|
256
|
+
const match = line.trim().match(/^\s*(\d+)\s+(\d+)\s+/);
|
|
257
|
+
if (match && match[1] && match[2]) {
|
|
258
|
+
const pid = parseInt(match[1], 10);
|
|
259
|
+
const ppid = parseInt(match[2], 10);
|
|
260
|
+
if (workerPids.includes(ppid)) pids.push(pid);
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
} else if (platform === 'win32') {
|
|
264
|
+
// wmic-free: one CIM snapshot, matched on the full command line so only
|
|
265
|
+
// OUR Camoufox children of the given workers are returned.
|
|
266
|
+
const procs = await queryWindowsProcesses();
|
|
267
|
+
for (const proc of procs) {
|
|
268
|
+
if (!PI_BROWSER_MARKER.test(proc.commandLine)) continue;
|
|
269
|
+
if (workerPids.includes(proc.ppid)) pids.push(proc.pid);
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
} catch (err) {
|
|
273
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
274
|
+
logger.warn(`[BrowserCleanup] Failed to get browser PIDs for workers: ${msg}`);
|
|
275
|
+
}
|
|
276
|
+
return pids;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* Kill specific browser processes in parallel
|
|
281
|
+
*/
|
|
282
|
+
export async function killBrowserProcesses(pids: number[]): Promise<void> {
|
|
283
|
+
if (!pids || pids.length === 0) return;
|
|
284
|
+
const platform = os.platform();
|
|
285
|
+
|
|
286
|
+
// Parallelize killing processes for speed
|
|
287
|
+
await Promise.all(pids.map(async (pid) => {
|
|
288
|
+
try {
|
|
289
|
+
if (platform === 'win32') {
|
|
290
|
+
await execAsync(`taskkill /PID ${pid} /F /T`);
|
|
291
|
+
logger.debug(`[BrowserCleanup] Terminated worker browser process: PID ${pid}`);
|
|
292
|
+
} else {
|
|
293
|
+
process.kill(pid, 'SIGTERM');
|
|
294
|
+
// Reduce wait time for graceful exit from 500ms to 200ms
|
|
295
|
+
await new Promise(resolve => setTimeout(resolve, 200));
|
|
296
|
+
try {
|
|
297
|
+
process.kill(pid, 0);
|
|
298
|
+
process.kill(pid, 'SIGKILL');
|
|
299
|
+
logger.debug(`[BrowserCleanup] Force killed worker browser process: PID ${pid}`);
|
|
300
|
+
} catch {
|
|
301
|
+
logger.debug(`[BrowserCleanup] Terminated worker browser process: PID ${pid}`);
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
} catch (err) {
|
|
305
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
306
|
+
logger.debug(`[BrowserCleanup] Failed to kill browser process PID ${pid}: ${msg}`);
|
|
307
|
+
}
|
|
308
|
+
}));
|
|
309
|
+
}
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Browser Client for Remote Scheduler Communication
|
|
3
|
+
*
|
|
4
|
+
* HTTP client that communicates with a remote scheduler via HTTP.
|
|
5
|
+
* Extracted from browser-manager.ts for better separation of concerns.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import * as http from 'node:http';
|
|
9
|
+
import type { Config } from '../../config.ts';
|
|
10
|
+
import type { SearchResult } from '../../web-research/types.ts';
|
|
11
|
+
import type { IScheduler } from '../../core/interfaces/scheduler-interfaces.ts';
|
|
12
|
+
import { logger } from '../../logger.ts';
|
|
13
|
+
import { errorTracker } from '../../utils/error-tracker.ts';
|
|
14
|
+
import { getClientAgent } from './client-agent.ts';
|
|
15
|
+
import type { NodeError } from '../../types/index.ts';
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* HTTP client that communicates with a remote scheduler.
|
|
19
|
+
* Used when this process is not the leader.
|
|
20
|
+
*/
|
|
21
|
+
export class BrowserClient implements IScheduler {
|
|
22
|
+
private readonly authSecret: string;
|
|
23
|
+
|
|
24
|
+
constructor(private readonly port: number, authSecret?: string) {
|
|
25
|
+
this.authSecret = authSecret ?? process.env['PI_BROWSER_AUTH_SECRET'] ?? '';
|
|
26
|
+
logger.log(`[BrowserClient] Connecting to global scheduler at http://127.0.0.1:${port}`);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
private async request<T>(path: string, data: any, signal?: AbortSignal): Promise<T> {
|
|
30
|
+
const start = Date.now();
|
|
31
|
+
// Extract operation from path for error tracking
|
|
32
|
+
const operation = path.includes('/search') ? 'search' :
|
|
33
|
+
path.includes('/scrape') ? 'browser-task' :
|
|
34
|
+
path.includes('/healthcheck') ? 'healthcheck' : 'network';
|
|
35
|
+
|
|
36
|
+
return new Promise((resolve, reject) => {
|
|
37
|
+
const agent = getClientAgent();
|
|
38
|
+
// 60s timeout for client requests (1/3 of original 180s)
|
|
39
|
+
const timeoutMs = 60000;
|
|
40
|
+
let resolved = false;
|
|
41
|
+
const controller = new AbortController();
|
|
42
|
+
|
|
43
|
+
// Merge outer signal with our local controller
|
|
44
|
+
let abortCleanup: (() => void) | undefined;
|
|
45
|
+
if (signal) {
|
|
46
|
+
if (signal.aborted) {
|
|
47
|
+
return reject(new Error('Aborted'));
|
|
48
|
+
}
|
|
49
|
+
const onAbort = () => {
|
|
50
|
+
if (resolved) return;
|
|
51
|
+
resolved = true;
|
|
52
|
+
controller.abort();
|
|
53
|
+
reject(new Error('Aborted'));
|
|
54
|
+
};
|
|
55
|
+
signal.addEventListener('abort', onAbort, { once: true });
|
|
56
|
+
abortCleanup = () => signal.removeEventListener('abort', onAbort);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const timer = setTimeout(() => {
|
|
60
|
+
if (!resolved) {
|
|
61
|
+
resolved = true;
|
|
62
|
+
// Remove the outer-signal abort listener on timeout too. Without this,
|
|
63
|
+
// a long-lived run signal shared across many client requests accumulates
|
|
64
|
+
// one never-removed listener per timed-out request for the run's duration.
|
|
65
|
+
abortCleanup?.();
|
|
66
|
+
controller.abort();
|
|
67
|
+
const error = new Error(`[BrowserClient] Request to ${path} timed out after ${timeoutMs}ms (Shared queue may be deep)`);
|
|
68
|
+
errorTracker.trackError(error, {
|
|
69
|
+
component: 'browser-manager',
|
|
70
|
+
operation,
|
|
71
|
+
errorType: 'timeout',
|
|
72
|
+
});
|
|
73
|
+
reject(error);
|
|
74
|
+
}
|
|
75
|
+
}, timeoutMs);
|
|
76
|
+
// FIX (#34): Don't keep the event loop alive for timeout timers
|
|
77
|
+
if (timer.unref) timer.unref();
|
|
78
|
+
|
|
79
|
+
const req = http.request({
|
|
80
|
+
hostname: '127.0.0.1',
|
|
81
|
+
port: this.port,
|
|
82
|
+
path,
|
|
83
|
+
method: 'POST',
|
|
84
|
+
agent,
|
|
85
|
+
signal: controller.signal,
|
|
86
|
+
headers: {
|
|
87
|
+
'Content-Type': 'application/json',
|
|
88
|
+
// FIX (#21): Send auth token to authenticate with browser server
|
|
89
|
+
'X-Browser-Auth': this.authSecret,
|
|
90
|
+
}
|
|
91
|
+
}, (res) => {
|
|
92
|
+
// Do NOT clear the timeout/abort here. The response callback fires
|
|
93
|
+
// when headers arrive, but the body is still streaming — clearing
|
|
94
|
+
// now would let a stalled body hang forever and make an external
|
|
95
|
+
// abort a no-op. Keep both armed until 'end' or 'error'.
|
|
96
|
+
let body = '';
|
|
97
|
+
res.on('data', chunk => body += chunk);
|
|
98
|
+
res.on('end', () => {
|
|
99
|
+
if (resolved) return;
|
|
100
|
+
resolved = true;
|
|
101
|
+
clearTimeout(timer);
|
|
102
|
+
abortCleanup?.();
|
|
103
|
+
const duration = Date.now() - start;
|
|
104
|
+
try {
|
|
105
|
+
const parsed = JSON.parse(body);
|
|
106
|
+
if (res.statusCode !== 200) {
|
|
107
|
+
const error = new Error(parsed.error || `HTTP ${res.statusCode}`);
|
|
108
|
+
errorTracker.trackError(error, {
|
|
109
|
+
component: 'browser-manager',
|
|
110
|
+
operation,
|
|
111
|
+
errorType: 'http_error',
|
|
112
|
+
});
|
|
113
|
+
reject(error);
|
|
114
|
+
} else {
|
|
115
|
+
logger.debug(`[BrowserClient] Request ${path} completed in ${duration}ms`);
|
|
116
|
+
resolve(parsed);
|
|
117
|
+
}
|
|
118
|
+
} catch (_e) {
|
|
119
|
+
// FIX (#23): Truncate response body in error to prevent data leakage
|
|
120
|
+
const preview = body.length > 200 ? body.slice(0, 200) + '...' : body;
|
|
121
|
+
const error = new Error(`Failed to parse response (status ${res.statusCode}): ${preview}`);
|
|
122
|
+
errorTracker.trackError(error, {
|
|
123
|
+
component: 'browser-manager',
|
|
124
|
+
operation,
|
|
125
|
+
errorType: 'parse_error',
|
|
126
|
+
});
|
|
127
|
+
reject(error);
|
|
128
|
+
}
|
|
129
|
+
});
|
|
130
|
+
res.on('error', (err) => {
|
|
131
|
+
if (resolved) return;
|
|
132
|
+
resolved = true;
|
|
133
|
+
clearTimeout(timer);
|
|
134
|
+
abortCleanup?.();
|
|
135
|
+
const error = new Error(`[BrowserClient] Response stream error on ${path}: ${err.message}`);
|
|
136
|
+
errorTracker.trackError(error, {
|
|
137
|
+
component: 'browser-manager',
|
|
138
|
+
operation,
|
|
139
|
+
errorType: 'response_stream_error',
|
|
140
|
+
});
|
|
141
|
+
reject(error);
|
|
142
|
+
});
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
req.on('error', (err) => {
|
|
146
|
+
if (resolved) return;
|
|
147
|
+
resolved = true;
|
|
148
|
+
clearTimeout(timer);
|
|
149
|
+
abortCleanup?.();
|
|
150
|
+
|
|
151
|
+
// If it's just a timeout abort error, the timer already rejected it.
|
|
152
|
+
if (err.name === 'AbortError') return;
|
|
153
|
+
// Enhance error message with socket-specific details
|
|
154
|
+
const nodeErr = err as NodeError;
|
|
155
|
+
let errorMsg: string;
|
|
156
|
+
let errorType: string;
|
|
157
|
+
if (nodeErr.code === 'ECONNRESET' || nodeErr.code === 'EPIPE') {
|
|
158
|
+
errorMsg = `Browser pool socket ${path} closed (pool likely busy or restarting) - ${err.message}`;
|
|
159
|
+
errorType = 'connection_reset';
|
|
160
|
+
} else if (nodeErr.code === 'ECONNREFUSED') {
|
|
161
|
+
errorMsg = `Browser pool ${path} unreachable (server restarting or torn down) - ${err.message}`;
|
|
162
|
+
errorType = 'connection_refused';
|
|
163
|
+
} else if (nodeErr.code === 'ETIMEDOUT') {
|
|
164
|
+
errorMsg = `Browser pool ${path} timed out (slow browser response) - ${err.message}`;
|
|
165
|
+
errorType = 'timeout';
|
|
166
|
+
} else {
|
|
167
|
+
errorMsg = `Browser pool ${path} error: ${err.message}`;
|
|
168
|
+
errorType = 'unknown';
|
|
169
|
+
}
|
|
170
|
+
const error = new Error(errorMsg);
|
|
171
|
+
logger.error(`[BrowserClient] Request to http://127.0.0.1:${this.port}${path} failed:`, errorMsg);
|
|
172
|
+
errorTracker.trackError(error, {
|
|
173
|
+
component: 'browser-manager',
|
|
174
|
+
operation,
|
|
175
|
+
errorType,
|
|
176
|
+
});
|
|
177
|
+
reject(error);
|
|
178
|
+
});
|
|
179
|
+
req.write(JSON.stringify(data));
|
|
180
|
+
req.end();
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
async runSearch(query: string, _config?: Config, signal?: AbortSignal): Promise<SearchResult[]> {
|
|
185
|
+
return this.request('/search', { query }, signal);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
async runScrape(url: string, _config?: Config, signal?: AbortSignal): Promise<any> {
|
|
189
|
+
return this.request('/scrape', { url }, signal);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
async runHealthCheck(_config?: Config, signal?: AbortSignal): Promise<{ success: boolean }> {
|
|
193
|
+
return this.request('/healthcheck', {}, signal);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
async shutdown() {
|
|
197
|
+
// Clients don't shutdown the server
|
|
198
|
+
}
|
|
199
|
+
}
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Browser Error Utilities
|
|
3
|
+
*
|
|
4
|
+
* Error detection and circuit breaker utilities for browser operations.
|
|
5
|
+
* Extracted from browser-manager.ts for better separation of concerns.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { CircuitBreaker } from '../../utils/circuit-breaker.ts';
|
|
9
|
+
import type { NodeError } from '../../types/index.ts';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Check if an error is a transient socket error that can be retried.
|
|
13
|
+
*/
|
|
14
|
+
export function isTransientSocketError(error: unknown): boolean {
|
|
15
|
+
if (!error || typeof error !== 'object') return false;
|
|
16
|
+
const err = error as NodeError;
|
|
17
|
+
return typeof err.message === 'string' && (
|
|
18
|
+
err.message.includes('ECONNREFUSED') ||
|
|
19
|
+
err.message.includes('ECONNRESET') ||
|
|
20
|
+
err.message.includes('socket hang up') ||
|
|
21
|
+
err.message.includes('EPIPE') ||
|
|
22
|
+
err.message.includes('ETIMEDOUT') ||
|
|
23
|
+
err.message.includes('timed out') ||
|
|
24
|
+
err.message.includes('pool busy') ||
|
|
25
|
+
err.message.includes('unreachable') ||
|
|
26
|
+
// WorkerPoolManager throws this during the window between forceSchedulerRestart
|
|
27
|
+
// clearing the scheduler reference and the old pool's 1500ms drain completing.
|
|
28
|
+
// It is a transient state — retry after a short delay succeeds once drain finishes.
|
|
29
|
+
err.message.includes('Worker pool is shutting down') ||
|
|
30
|
+
// Poolifier throws this when pool.execute() is called while the pool is being
|
|
31
|
+
// destroyed (e.g., during scheduler restart). Treat as transient — the pool will
|
|
32
|
+
// be ready for new tasks once destruction completes and a new pool is initialized.
|
|
33
|
+
err.message.includes('Cannot execute a task on destroying pool') ||
|
|
34
|
+
err.message.includes('destroying pool')
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Check if an error is a Cloudflare block/challenge error.
|
|
40
|
+
* These are content-layer rejections, not infrastructure failures — they must NOT
|
|
41
|
+
* count toward the circuit breaker failure threshold. Counting them caused the
|
|
42
|
+
* breaker to open during normal searches and block all subsequent requests.
|
|
43
|
+
*/
|
|
44
|
+
export function isCloudflareBlockError(error: unknown): boolean {
|
|
45
|
+
if (!error || typeof error !== 'object') return false;
|
|
46
|
+
const err = error as NodeError;
|
|
47
|
+
return typeof err.message === 'string' && (
|
|
48
|
+
err.message.includes('Fetch blocked: Cloudflare') ||
|
|
49
|
+
err.message.includes('Cloudflare challenge')
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Check if an error is a task-level timeout (search/scrape/healthcheck timed out).
|
|
55
|
+
* These indicate the remote site is slow or unresponsive — a normal occurrence during
|
|
56
|
+
* a burst of 20 parallel queries. They must NOT trip the circuit breaker; only true
|
|
57
|
+
* socket-layer infrastructure failures (ECONNREFUSED, ECONNRESET, etc.) should do so.
|
|
58
|
+
*/
|
|
59
|
+
export function isTaskTimeoutError(error: unknown): boolean {
|
|
60
|
+
if (!error || typeof error !== 'object') return false;
|
|
61
|
+
const err = error as NodeError;
|
|
62
|
+
return typeof err.message === 'string' && (
|
|
63
|
+
err.message.includes('timed out after') ||
|
|
64
|
+
err.message.includes('Search task timed out') ||
|
|
65
|
+
err.message.includes('Scrape task timed out') ||
|
|
66
|
+
err.message.includes('Health check timed out') ||
|
|
67
|
+
err.message.includes('[BrowserClient] Request to')
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Check if an error is specifically a pool-shutdown / pool-drain error.
|
|
73
|
+
* For these errors the scheduler itself is fine — only the WorkerPool is temporarily
|
|
74
|
+
* draining. The correct recovery is to wait for the drain to finish and retry,
|
|
75
|
+
* NOT to call forceSchedulerRestart (which would start another drain cycle).
|
|
76
|
+
*/
|
|
77
|
+
export function isPoolShutdownError(error: unknown): boolean {
|
|
78
|
+
if (!error || typeof error !== 'object') return false;
|
|
79
|
+
const err = error as NodeError;
|
|
80
|
+
return typeof err.message === 'string' && (
|
|
81
|
+
err.message.includes('Worker pool is shutting down') ||
|
|
82
|
+
err.message.includes('Cannot execute a task on destroying pool') ||
|
|
83
|
+
err.message.includes('destroying pool')
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Session-scoped circuit breaker storage to support concurrent research runs
|
|
88
|
+
const sessionCircuitBreakers = new Map<string, CircuitBreaker>();
|
|
89
|
+
// Hard cap so a cleanup path that ever skips clearSessionCircuitBreaker (e.g. an earlier
|
|
90
|
+
// throw in the cleanup closure) can't grow the map unboundedly over a long-lived TUI
|
|
91
|
+
// process with many sessions. Far above any realistic concurrent-session count. Mirrors
|
|
92
|
+
// the MAX_PATTERNS bound in error-tracker.ts.
|
|
93
|
+
const MAX_SESSION_BREAKERS = 256;
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Default circuit breaker configuration
|
|
97
|
+
*/
|
|
98
|
+
const DEFAULT_BREAKER_CONFIG: any = {
|
|
99
|
+
// Raised from 3→8: parallel search bursts of 20 queries frequently produce
|
|
100
|
+
// simultaneous timeouts on slow/CF-protected sites. A threshold of 3 caused
|
|
101
|
+
// the breaker to open mid-burst, blocking all subsequent requests for the round.
|
|
102
|
+
failureThreshold: 8,
|
|
103
|
+
// Raised from 15s→45s: enough time for one full search burst + retry window
|
|
104
|
+
// to complete before the breaker attempts to half-open.
|
|
105
|
+
resetTimeoutMs: 45000,
|
|
106
|
+
name: 'BrowserPool',
|
|
107
|
+
isTransientError: (error: unknown) => {
|
|
108
|
+
// Pool-drain transients: pool recovers on its own — don't count.
|
|
109
|
+
if (isPoolShutdownError(error)) return false;
|
|
110
|
+
// Cloudflare blocks are content-layer rejections, not infra failures — don't count.
|
|
111
|
+
if (isCloudflareBlockError(error)) return false;
|
|
112
|
+
// Task-level timeouts (slow/unresponsive sites) are expected during parallel
|
|
113
|
+
// search bursts — don't count toward the circuit breaker threshold.
|
|
114
|
+
if (isTaskTimeoutError(error)) return false;
|
|
115
|
+
// Only genuine socket-layer failures (ECONNREFUSED, ECONNRESET, etc.) trip the circuit.
|
|
116
|
+
return isTransientSocketError(error);
|
|
117
|
+
}
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Get or create a session-scoped circuit breaker
|
|
122
|
+
*/
|
|
123
|
+
export function getBrowserCircuitBreaker(sessionId: string): CircuitBreaker {
|
|
124
|
+
let breaker = sessionCircuitBreakers.get(sessionId);
|
|
125
|
+
if (!breaker) {
|
|
126
|
+
// Evict the oldest (insertion-order) entry when at the cap before inserting.
|
|
127
|
+
if (sessionCircuitBreakers.size >= MAX_SESSION_BREAKERS) {
|
|
128
|
+
const oldest = sessionCircuitBreakers.keys().next().value;
|
|
129
|
+
if (oldest !== undefined) sessionCircuitBreakers.delete(oldest);
|
|
130
|
+
}
|
|
131
|
+
breaker = new CircuitBreaker({ ...DEFAULT_BREAKER_CONFIG, name: `BrowserPool-${sessionId}` });
|
|
132
|
+
sessionCircuitBreakers.set(sessionId, breaker);
|
|
133
|
+
}
|
|
134
|
+
return breaker;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Clear a session's circuit breaker
|
|
139
|
+
*/
|
|
140
|
+
export function clearSessionCircuitBreaker(sessionId: string): void {
|
|
141
|
+
sessionCircuitBreakers.delete(sessionId);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Global fallback circuit breaker — used when no sessionId is available.
|
|
146
|
+
*/
|
|
147
|
+
export const browserCircuitBreaker = new CircuitBreaker(DEFAULT_BREAKER_CONFIG);
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Reset all browser circuit breakers
|
|
151
|
+
* Call this in test teardown/setup to prevent cross-test circuit state bleed.
|
|
152
|
+
*/
|
|
153
|
+
export function resetBrowserCircuitBreaker(sessionId?: string): void {
|
|
154
|
+
if (sessionId) {
|
|
155
|
+
clearSessionCircuitBreaker(sessionId);
|
|
156
|
+
} else {
|
|
157
|
+
sessionCircuitBreakers.clear();
|
|
158
|
+
browserCircuitBreaker.reset();
|
|
159
|
+
}
|
|
160
|
+
}
|