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