@lincoln504/pi-research 1.3.11 → 1.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/dist/cli.mjs +5558 -4596
- package/dist/prompts/research-tool-usage.md +1 -1
- package/dist/prompts/researcher.md +1 -0
- package/dist/prompts/system-coordinator.md +1 -1
- package/dist/prompts/system-lead-router.md +122 -0
- package/dist/prompts/system-lead-synthesizer.md +46 -0
- package/dist/thread-worker.mjs +123 -30
- package/docs/ARCHITECTURE.md +69 -24
- package/docs/CHANGELOG.md +215 -0
- package/docs/CONFIGURATION.md +108 -8
- package/package.json +19 -11
- package/scripts/build.cjs +8 -1
- package/scripts/cleanup.cjs +26 -5
- package/scripts/setup.cjs +25 -1
- package/skills/pi-research/SKILL.md +1 -1
- package/src/cli.ts +41 -2
- package/src/config.ts +12 -8
- package/src/constants.ts +1 -1
- package/src/core/interfaces/observer-interfaces.ts +3 -3
- package/src/core/interfaces/orchestration-interfaces.ts +2 -0
- package/src/core/interfaces/planning-interfaces.ts +7 -1
- package/src/core/interfaces/scheduler-interfaces.ts +18 -2
- package/src/core/llm/agentic-repair.ts +7 -2
- package/src/core/llm/inject-date.ts +4 -1
- package/src/core/llm/llm-utils.ts +7 -0
- package/src/core/planning-service.ts +719 -91
- package/src/core/planning-utils.ts +21 -5
- package/src/core/service-initialization.ts +19 -3
- package/src/core/service-registry.ts +19 -3
- package/src/healthcheck/index.ts +11 -2
- package/src/index.ts +4 -3
- package/src/infrastructure/browser/browser-client.ts +68 -34
- package/src/infrastructure/browser/browser-task-scheduler.ts +220 -34
- package/src/infrastructure/browser/config.ts +77 -6
- package/src/infrastructure/browser/priority-task-queue.ts +55 -1
- package/src/infrastructure/browser/scheduler-factory.ts +12 -1
- package/src/infrastructure/browser/task-execution-service.ts +17 -7
- package/src/infrastructure/browser/thread-worker-browser.ts +66 -20
- package/src/infrastructure/browser/thread-worker-messaging.ts +38 -6
- package/src/infrastructure/browser/thread-worker.mjs +123 -30
- package/src/infrastructure/browser/thread-worker.ts +2 -2
- package/src/infrastructure/browser/worker-pool-manager.ts +127 -12
- package/src/infrastructure/embedding/embedding-client.ts +14 -4
- package/src/infrastructure/embedding/embedding-factory.ts +83 -15
- package/src/infrastructure/file-lock-service.ts +214 -19
- package/src/infrastructure/gpu-resource-service.ts +15 -1
- package/src/infrastructure/knowledge-store-service.ts +14 -0
- package/src/infrastructure/research-run-semaphore.ts +1 -1
- package/src/infrastructure/service-initialization.ts +7 -0
- package/src/knowledge/embedder-init.ts +60 -18
- package/src/knowledge/store.ts +11 -5
- package/src/logger.ts +11 -0
- package/src/orchestration/deep-research-orchestrator.ts +141 -87
- package/src/orchestration/quick-research-orchestrator.ts +11 -2
- package/src/orchestration/research-orchestration-service.ts +30 -1
- package/src/orchestration/research-synthesis-service.ts +53 -1
- package/src/orchestration/researcher-executor.ts +53 -2
- package/src/orchestration/researcher.ts +15 -2
- package/src/prompts/research-tool-usage.md +1 -1
- package/src/prompts/researcher.md +1 -0
- package/src/prompts/system-coordinator.md +1 -1
- package/src/prompts/system-lead-router.md +122 -0
- package/src/prompts/system-lead-synthesizer.md +46 -0
- package/src/sdk.ts +10 -1
- package/src/security/nvd-client.ts +55 -4
- package/src/security/osv-client.ts +13 -1
- package/src/tools/research-knowledge-search.ts +29 -4
- package/src/tools/scrape.ts +6 -0
- package/src/tools/search.ts +7 -0
- package/src/tools/security.ts +27 -9
- package/src/tools/stackexchange.ts +13 -7
- package/src/tools/youtube-transcript.ts +26 -12
- package/src/utils/cancellation.ts +36 -0
- package/src/utils/coverage-digest.ts +204 -0
- package/src/utils/llm-usage.ts +33 -6
- package/src/utils/log-utils.ts +16 -1
- package/src/utils/metrics-summary.ts +20 -4
- package/src/utils/metrics.ts +27 -1
- package/src/web-research/browser-search.ts +93 -20
- package/src/web-research/scraper-utils.ts +55 -14
- package/dist/prompts/system-lead-evaluator.md +0 -118
- package/src/prompts/system-lead-evaluator.md +0 -118
|
@@ -19,10 +19,11 @@ import type { IStateManager } from '../../core/interfaces/state-manager-interfac
|
|
|
19
19
|
import { BrowserServer, getBrowserServerAuthSecret } from './browser-server.ts';
|
|
20
20
|
import { isPoolShutdownError } from './browser-error-utils.ts';
|
|
21
21
|
import type { WorkerPoolManager } from './worker-pool-manager.ts';
|
|
22
|
-
import type { IScheduler } from '../../core/interfaces/scheduler-interfaces.ts';
|
|
22
|
+
import type { IScheduler, TaskDispatchListener } from '../../core/interfaces/scheduler-interfaces.ts';
|
|
23
23
|
import { cleanupOrphanedCamoufoxProcesses, getBrowserPidsForWorkers, killBrowserProcesses } from './browser-cleanup.ts';
|
|
24
24
|
import { raceWithDeadline } from '../../utils/safe-unref.ts';
|
|
25
25
|
import { PriorityTaskQueue } from './priority-task-queue.ts';
|
|
26
|
+
import { getHealthCheckBudgetMs } from './config.ts';
|
|
26
27
|
|
|
27
28
|
/**
|
|
28
29
|
* Grace added to the worker's own task budget before the dead-worker backstop
|
|
@@ -74,6 +75,47 @@ export async function executePoolTaskWithDeathBackstop<T>(
|
|
|
74
75
|
* Browser task scheduler - manages the worker pool and executes tasks.
|
|
75
76
|
* Only the leader process has an instance of this scheduler.
|
|
76
77
|
*/
|
|
78
|
+
/**
|
|
79
|
+
* How long a task actually RAN, given when it was dispatched and when it was
|
|
80
|
+
* submitted.
|
|
81
|
+
*
|
|
82
|
+
* Duration used to be `now - submittedAt`, which folds queue wait into the work.
|
|
83
|
+
* That is the number `Search completed ... in Nms` prints and the number
|
|
84
|
+
* `browser_search_duration_ms` records, so it is what every latency investigation
|
|
85
|
+
* reads — and during a saturated burst it reported a 51s median against a true
|
|
86
|
+
* 5.8s execution median. A whole evening went into explaining a slowdown that had
|
|
87
|
+
* not happened. Moving the deadline off queue wait while leaving the measurement
|
|
88
|
+
* on it would have preserved exactly that misdiagnosis.
|
|
89
|
+
*
|
|
90
|
+
* A task that never reached a worker has no execution time; report its full
|
|
91
|
+
* elapsed span rather than zero, so a timeout is not silently recorded as instant.
|
|
92
|
+
*/
|
|
93
|
+
export function executionMs(dispatchedAt: number, submittedAt: number, now: number = Date.now()): number {
|
|
94
|
+
return dispatchedAt > 0 ? now - dispatchedAt : now - submittedAt;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** How long a task sat in the queue before a worker took it. Zero if never dispatched. */
|
|
98
|
+
export function queueWaitMs(dispatchedAt: number, submittedAt: number): number {
|
|
99
|
+
return dispatchedAt > 0 ? dispatchedAt - submittedAt : 0;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Record a task's queue wait, whether or not it was ever dispatched.
|
|
104
|
+
*
|
|
105
|
+
* Split by outcome because the two are different quantities and must not be summed
|
|
106
|
+
* into one distribution: a dispatched task's wait ENDED, while an abandoned task's
|
|
107
|
+
* wait was still running when the caller gave up, so the latter is a lower bound.
|
|
108
|
+
* Folding abandonments in as `queueWaitMs`'s zero would pull the distribution
|
|
109
|
+
* DOWNWARD in exactly the bursts where it should spike.
|
|
110
|
+
*/
|
|
111
|
+
export function observeQueueWait(metric: string, dispatchedAt: number, submittedAt: number): void {
|
|
112
|
+
metrics.observe(
|
|
113
|
+
metric,
|
|
114
|
+
dispatchedAt > 0 ? dispatchedAt - submittedAt : Date.now() - submittedAt,
|
|
115
|
+
{ dispatched: dispatchedAt > 0 ? 'yes' : 'no' },
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
|
|
77
119
|
export class BrowserTaskScheduler implements IScheduler {
|
|
78
120
|
private workerPoolManager: WorkerPoolManager | null = null;
|
|
79
121
|
private server: BrowserServer | null = null;
|
|
@@ -169,6 +211,9 @@ export class BrowserTaskScheduler implements IScheduler {
|
|
|
169
211
|
// Decay (not reset) so a slow worker crash-loop can still accumulate to
|
|
170
212
|
// the auto-recovery threshold instead of being zeroed on every tick.
|
|
171
213
|
poolManager.decayConsecutiveErrors();
|
|
214
|
+
// Sampled on the leadership tick rather than per task: this is a
|
|
215
|
+
// distribution property, not a per-task one, and it should read zero.
|
|
216
|
+
poolManager.recordDispatchHealth();
|
|
172
217
|
} catch (err) {
|
|
173
218
|
logger.warn('[Scheduler] Leadership check error:', err);
|
|
174
219
|
} finally {
|
|
@@ -218,7 +263,7 @@ export class BrowserTaskScheduler implements IScheduler {
|
|
|
218
263
|
return this.server.start();
|
|
219
264
|
}
|
|
220
265
|
|
|
221
|
-
async runSearch(query: string, config?: Config, signal?: AbortSignal): Promise<SearchResult[]> {
|
|
266
|
+
async runSearch(query: string, config?: Config, signal?: AbortSignal, onTaskEvent?: TaskDispatchListener): Promise<SearchResult[]> {
|
|
222
267
|
// Reject tasks once this scheduler has begun teardown. shutdown() nulls priorityQueue and
|
|
223
268
|
// clears the scheduler reference (a future run gets a fresh scheduler), so getPriorityQueue()
|
|
224
269
|
// here would otherwise build a FRESH, non-shutdown queue on this dead instance and dispatch
|
|
@@ -235,12 +280,36 @@ export class BrowserTaskScheduler implements IScheduler {
|
|
|
235
280
|
const cfg = config || getConfig();
|
|
236
281
|
const timeoutMs = cfg.SEARCH_TIMEOUT_MS + cfg.BROWSER_TASK_TIMEOUT_MS;
|
|
237
282
|
|
|
283
|
+
// The deadline measures EXECUTION, not queue wait: the timer is armed by
|
|
284
|
+
// `startDeadline()` from inside the enqueued callback, i.e. once the queue has
|
|
285
|
+
// actually dispatched this task to a worker.
|
|
286
|
+
//
|
|
287
|
+
// It used to be armed here, before enqueuing, so a saturated queue burned a task's
|
|
288
|
+
// entire budget waiting for a slot and it aborted having done no work at all. That
|
|
289
|
+
// made the number of tasks a burst could complete `capacity x (budget / latency)`
|
|
290
|
+
// rather than the number submitted — measured on a live run, 78 of 100 searches
|
|
291
|
+
// aborted at the deadline without ever running. A queue is supposed to absorb
|
|
292
|
+
// volume by making work wait, not by killing whatever it could not reach in time.
|
|
293
|
+
// Waiting is now bounded by the caller's signal and the run-level timeouts; a task
|
|
294
|
+
// that does get dispatched always gets its full budget.
|
|
295
|
+
// `dispatchedAt` is the same instant, kept for MEASUREMENT rather than for the
|
|
296
|
+
// deadline. Reporting `Date.now() - startTime` as the search's duration charged
|
|
297
|
+
// queue wait to the work, and that number is what every latency analysis reads:
|
|
298
|
+
// on the night this was diagnosed it reported a 51s median against a true 5.8s
|
|
299
|
+
// execution median, and invented an eightfold latency regression that never
|
|
300
|
+
// happened. Moving the deadline off queue wait without moving the measurement
|
|
301
|
+
// would have left the misdiagnosis in place while claiming to have fixed it.
|
|
238
302
|
let timeoutId: NodeJS.Timeout | undefined;
|
|
303
|
+
let dispatchedAt = 0;
|
|
304
|
+
let startDeadline: () => void = () => {};
|
|
239
305
|
const timeoutPromise = new Promise<never>((_, reject) => {
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
306
|
+
startDeadline = () => {
|
|
307
|
+
dispatchedAt = Date.now();
|
|
308
|
+
timeoutId = setTimeout(() => {
|
|
309
|
+
reject(new Error(`Search task timed out after ${timeoutMs}ms. query="${query}"`));
|
|
310
|
+
}, timeoutMs);
|
|
311
|
+
if (timeoutId.unref) timeoutId.unref();
|
|
312
|
+
};
|
|
244
313
|
});
|
|
245
314
|
|
|
246
315
|
logger.debug(`[BrowserTaskScheduler] Executing search: "${query}" (Timeout: ${timeoutMs}ms)`);
|
|
@@ -258,19 +327,24 @@ export class BrowserTaskScheduler implements IScheduler {
|
|
|
258
327
|
// This ensures that even if the queue is saturated, we won't hang forever.
|
|
259
328
|
result = await Promise.race([
|
|
260
329
|
queue.enqueue('search', async () => {
|
|
261
|
-
|
|
262
|
-
//
|
|
263
|
-
//
|
|
264
|
-
//
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
330
|
+
startDeadline();
|
|
331
|
+
// Same instant, for the same reason, one layer up: whoever called us
|
|
332
|
+
// arms their own guard here instead of at call time, and disarms it
|
|
333
|
+
// when we settle. A listener that throws must not take the task down.
|
|
334
|
+
try { onTaskEvent?.('dispatched'); } catch { /* caller's guard, caller's problem */ }
|
|
335
|
+
try {
|
|
336
|
+
return await executePoolTaskWithDeathBackstop(
|
|
337
|
+
() => pool.execute({ type: 'search', query, queuedAt: startTime, taskTimeoutMs: timeoutMs }),
|
|
338
|
+
timeoutMs,
|
|
339
|
+
'Search',
|
|
340
|
+
);
|
|
341
|
+
} finally {
|
|
342
|
+
try { onTaskEvent?.('settled'); } catch { /* as above */ }
|
|
343
|
+
}
|
|
270
344
|
}, queueSignal),
|
|
271
345
|
timeoutPromise
|
|
272
346
|
]);
|
|
273
|
-
logger.debug(`[BrowserTaskScheduler] Search completed: "${query}" in ${
|
|
347
|
+
logger.debug(`[BrowserTaskScheduler] Search completed: "${query}" in ${executionMs(dispatchedAt, startTime)}ms (queued ${queueWaitMs(dispatchedAt, startTime)}ms)`);
|
|
274
348
|
} catch (error) {
|
|
275
349
|
// A pool-shutdown/destroy error means the run is being torn down (quit/SIGTERM
|
|
276
350
|
// mid-search-burst): the task never ran because dispose() destroyed the pool.
|
|
@@ -304,9 +378,16 @@ export class BrowserTaskScheduler implements IScheduler {
|
|
|
304
378
|
} finally {
|
|
305
379
|
if (timeoutId) clearTimeout(timeoutId);
|
|
306
380
|
settled.abort();
|
|
381
|
+
// Recorded here rather than on the success path below, because the tasks
|
|
382
|
+
// that wait longest are precisely the ones that do not succeed — measuring
|
|
383
|
+
// queue wait only on completed tasks censors the tail this metric exists to
|
|
384
|
+
// expose. A task that never reached a worker has no dispatch instant, so its
|
|
385
|
+
// wait is the whole elapsed time and is labelled as such; averaging it in as
|
|
386
|
+
// a zero would have understated saturation exactly when it was worst.
|
|
387
|
+
observeQueueWait('browser_search_queue_wait_ms', dispatchedAt, startTime);
|
|
307
388
|
}
|
|
308
389
|
|
|
309
|
-
const duration =
|
|
390
|
+
const duration = executionMs(dispatchedAt, startTime);
|
|
310
391
|
// In-band worker error checked FIRST: a task the worker reports as failed
|
|
311
392
|
// must count only as an error. Recording success unconditionally before
|
|
312
393
|
// this check double-counted every worker-reported failure as
|
|
@@ -327,7 +408,7 @@ export class BrowserTaskScheduler implements IScheduler {
|
|
|
327
408
|
return result.results;
|
|
328
409
|
}
|
|
329
410
|
|
|
330
|
-
async runScrape(url: string, config?: Config, signal?: AbortSignal): Promise<ScrapeResult> {
|
|
411
|
+
async runScrape(url: string, config?: Config, signal?: AbortSignal, onTaskEvent?: TaskDispatchListener): Promise<ScrapeResult> {
|
|
331
412
|
if (this.isShuttingDown) throw new Error('Worker pool is shutting down');
|
|
332
413
|
this.resetIdleTimer();
|
|
333
414
|
const pool = await (await this.getWorkerPoolManager()).ensurePool(config);
|
|
@@ -338,12 +419,19 @@ export class BrowserTaskScheduler implements IScheduler {
|
|
|
338
419
|
const isMocking = process.env['PI_RESEARCH_MOCK_SCRAPE'] === 'true';
|
|
339
420
|
const timeoutMs = cfg.SCRAPE_TIMEOUT_MS + (isMocking ? 5000 : cfg.BROWSER_TASK_TIMEOUT_MS);
|
|
340
421
|
|
|
422
|
+
// Armed on dispatch, not on enqueue — see runSearch for why.
|
|
423
|
+
// See runSearch: dispatch time is tracked for measurement, not for the deadline.
|
|
341
424
|
let timeoutId: NodeJS.Timeout | undefined;
|
|
425
|
+
let dispatchedAt = 0;
|
|
426
|
+
let startDeadline: () => void = () => {};
|
|
342
427
|
const timeoutPromise = new Promise<never>((_, reject) => {
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
428
|
+
startDeadline = () => {
|
|
429
|
+
dispatchedAt = Date.now();
|
|
430
|
+
timeoutId = setTimeout(() => {
|
|
431
|
+
reject(new Error(`Scrape task timed out after ${timeoutMs}ms. url="${url}"`));
|
|
432
|
+
}, timeoutMs);
|
|
433
|
+
if (timeoutId.unref) timeoutId.unref();
|
|
434
|
+
};
|
|
347
435
|
});
|
|
348
436
|
|
|
349
437
|
let result: any;
|
|
@@ -355,11 +443,18 @@ export class BrowserTaskScheduler implements IScheduler {
|
|
|
355
443
|
const queue = this.getPriorityQueue(config);
|
|
356
444
|
result = await Promise.race([
|
|
357
445
|
queue.enqueue('scrape', async () => {
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
446
|
+
startDeadline();
|
|
447
|
+
// See runSearch: the caller's guard is armed on dispatch and disarmed on settle.
|
|
448
|
+
try { onTaskEvent?.('dispatched'); } catch { /* caller's guard, caller's problem */ }
|
|
449
|
+
try {
|
|
450
|
+
return await executePoolTaskWithDeathBackstop(
|
|
451
|
+
() => pool.execute({ type: 'scrape', url, queuedAt: startTime, taskTimeoutMs: timeoutMs }),
|
|
452
|
+
timeoutMs,
|
|
453
|
+
'Scrape',
|
|
454
|
+
);
|
|
455
|
+
} finally {
|
|
456
|
+
try { onTaskEvent?.('settled'); } catch { /* as above */ }
|
|
457
|
+
}
|
|
363
458
|
}, queueSignal),
|
|
364
459
|
timeoutPromise
|
|
365
460
|
]);
|
|
@@ -386,9 +481,11 @@ export class BrowserTaskScheduler implements IScheduler {
|
|
|
386
481
|
} finally {
|
|
387
482
|
if (timeoutId) clearTimeout(timeoutId);
|
|
388
483
|
settled.abort();
|
|
484
|
+
// See runSearch: recorded on every outcome, not only on success.
|
|
485
|
+
observeQueueWait('browser_scrape_queue_wait_ms', dispatchedAt, startTime);
|
|
389
486
|
}
|
|
390
487
|
|
|
391
|
-
const duration =
|
|
488
|
+
const duration = executionMs(dispatchedAt, startTime);
|
|
392
489
|
// In-band error first — success-side recording must not precede it (see runSearch).
|
|
393
490
|
if (result.error) {
|
|
394
491
|
metrics.increment('browser_scrape_requests_total', 1, { status: 'error' });
|
|
@@ -405,17 +502,103 @@ export class BrowserTaskScheduler implements IScheduler {
|
|
|
405
502
|
return result;
|
|
406
503
|
}
|
|
407
504
|
|
|
408
|
-
async runHealthCheck(config?: Config, signal?: AbortSignal): Promise<{ success: boolean }> {
|
|
505
|
+
async runHealthCheck(config?: Config, signal?: AbortSignal, budgetOverrideMs?: number): Promise<{ success: boolean }> {
|
|
409
506
|
if (this.isShuttingDown) throw new Error('Worker pool is shutting down');
|
|
410
507
|
this.resetIdleTimer();
|
|
411
508
|
const pool = await (await this.getWorkerPoolManager()).ensurePool(config);
|
|
412
509
|
const startTime = Date.now();
|
|
413
510
|
const isMocking = process.env['PI_RESEARCH_MOCK_SEARCH'] === 'true' || process.env['PI_RESEARCH_MOCK_SCRAPE'] === 'true';
|
|
414
|
-
|
|
511
|
+
// Derived, not hardcoded — see getHealthCheckBudgetMs. The wait-phase verdict
|
|
512
|
+
// below rests on this budget out-waiting any task that can hold a slot, and a
|
|
513
|
+
// fixed 105s made that true only for the default timeouts. `budgetOverrideMs`
|
|
514
|
+
// exists so the verdict is reachable from a test at all: at 105s it is not,
|
|
515
|
+
// which is exactly how a previous version of it shipped unreachable and stayed
|
|
516
|
+
// that way.
|
|
517
|
+
const timeoutMs = budgetOverrideMs ?? getHealthCheckBudgetMs(config) / (isMocking ? 4 : 1);
|
|
518
|
+
|
|
519
|
+
// A probe that never got a worker used to fail as "timed out (including queue
|
|
520
|
+
// wait)", and the registry reported BrowserRuntime CRITICALLY UNHEALTHY —
|
|
521
|
+
// during a burst in which the pool was healthily executing the run's own
|
|
522
|
+
// searches. Twice on the night this was measured.
|
|
523
|
+
//
|
|
524
|
+
// Arming the deadline on DISPATCH is the whole fix, and it is sufficient. Three
|
|
525
|
+
// attempts were made to add a second, wait-phase verdict that would excuse a
|
|
526
|
+
// saturated pool, and all three were wrong. They are recorded here because each
|
|
527
|
+
// one reads sensibly and the next reader will otherwise reach for one of them:
|
|
528
|
+
//
|
|
529
|
+
// 1. "did the queue dispatch anything recently" — unreachable. Healthcheck
|
|
530
|
+
// tasks have strict queue priority, so if a slot frees while this probe
|
|
531
|
+
// waits, the queue dispatches THIS probe and cancels the timer. The timer
|
|
532
|
+
// can only fire in a window where nothing dispatched at all.
|
|
533
|
+
// 2. "are the slots occupied" — wrong in the dangerous direction. activeCount
|
|
534
|
+
// rises before pool.execute, so six workers hung inside a browser launch
|
|
535
|
+
// are six occupied slots, and the exact incident this was written for
|
|
536
|
+
// would have reported HEALTHY.
|
|
537
|
+
// 3. "has a task completed successfully recently" — unreachable for the same
|
|
538
|
+
// reason as (1). A success frees a slot, a freed slot dispatches this
|
|
539
|
+
// probe, so at expiry the last success is necessarily older than the whole
|
|
540
|
+
// budget. The condition it tested was false by construction.
|
|
541
|
+
//
|
|
542
|
+
// The general form of the mistake: with strict priority, a probe that waited
|
|
543
|
+
// out its entire budget without being dispatched IS the evidence of a wedge.
|
|
544
|
+
// There is no second signal to consult, and every attempt to find one either
|
|
545
|
+
// could not fire or fired for a dead pool. The numbers below are kept for the
|
|
546
|
+
// DIAGNOSTIC message — an operator needs to know whether the slots were busy
|
|
547
|
+
// and when work last succeeded — and not for the verdict.
|
|
548
|
+
//
|
|
549
|
+
// False positives are bounded by arithmetic rather than by judgement: the probe
|
|
550
|
+
// allows 105s while every task that can hold a slot is itself deadlined well
|
|
551
|
+
// under that (search 55s, scrape 25s plus backstop), so a slot frees inside the
|
|
552
|
+
// budget unless the pool has genuinely stopped.
|
|
553
|
+
//
|
|
554
|
+
// Once dispatched, the deadline measures execution alone (armed below, like
|
|
555
|
+
// runSearch/runScrape), so a slow probe still fails on its own merits.
|
|
415
556
|
let timeoutId: NodeJS.Timeout | undefined;
|
|
557
|
+
let dispatched = false;
|
|
558
|
+
let hcDispatchedAt = 0;
|
|
559
|
+
let startDeadline: () => void = () => {};
|
|
560
|
+
// Captured BEFORE the timer can run. Resolving the queue inside the callback
|
|
561
|
+
// would call getPriorityQueue() on a scheduler that may have shut down in the
|
|
562
|
+
// meantime, which builds a FRESH queue on the dead instance — undoing the
|
|
563
|
+
// isShutdown latch that exists to reject enqueues racing teardown, and
|
|
564
|
+
// reporting a routine shutdown as a wedge because a new queue has dispatched
|
|
565
|
+
// nothing.
|
|
566
|
+
const healthQueue = this.getPriorityQueue(config);
|
|
567
|
+
// Rejects only. It used to be typed as resolvable because a branch here answered
|
|
568
|
+
// "healthy" without running the probe; nothing does that now, and the type says so.
|
|
416
569
|
const timeoutPromise = new Promise<never>((_, reject) => {
|
|
417
|
-
|
|
570
|
+
const onWaitExpired = () => {
|
|
571
|
+
if (dispatched) return;
|
|
572
|
+
const lastSuccessAt = healthQueue.getLastSuccessAt();
|
|
573
|
+
const sinceSuccess = lastSuccessAt > 0 ? Date.now() - lastSuccessAt : Number.POSITIVE_INFINITY;
|
|
574
|
+
// The wording is load-bearing, not prose. isBusyPoolHealthFailure
|
|
575
|
+
// (src/healthcheck/index.ts) decides whether a failed probe aborts the
|
|
576
|
+
// run, and it decides by matching /tim(e|ed) out|timeout/ on this very
|
|
577
|
+
// string. An earlier rewrite of this message dropped the word entirely —
|
|
578
|
+
// so a probe that queued out under load stopped reading as a timeout, the
|
|
579
|
+
// tolerance gate rejected it, and a BUSY pool aborted the run at
|
|
580
|
+
// quick-research-orchestrator and research-health. The commit that did it
|
|
581
|
+
// was the one meant to stop a busy pool being reported as dead; it moved
|
|
582
|
+
// the abort one layer up. Pinned by a test that drives this exact string
|
|
583
|
+
// through that exact classifier.
|
|
584
|
+
//
|
|
585
|
+
// The rest is diagnosis: slots busy with nothing completing is a worker
|
|
586
|
+
// fault, slots free is a queue fault (the queue stopped dispatching), and
|
|
587
|
+
// the last-success age separates "stopped just now" from "never started".
|
|
588
|
+
reject(new Error(
|
|
589
|
+
`Health check timed out after ${timeoutMs}ms without reaching a worker — ${healthQueue.getActiveCount()} of ${healthQueue.getConcurrency()} slots busy, ` +
|
|
590
|
+
`${lastSuccessAt > 0 ? `last successful task ${sinceSuccess}ms ago` : 'no task has ever completed successfully'}.`,
|
|
591
|
+
));
|
|
592
|
+
};
|
|
593
|
+
timeoutId = setTimeout(onWaitExpired, timeoutMs);
|
|
418
594
|
if (timeoutId.unref) timeoutId.unref();
|
|
595
|
+
startDeadline = () => {
|
|
596
|
+
dispatched = true;
|
|
597
|
+
hcDispatchedAt = Date.now();
|
|
598
|
+
clearTimeout(timeoutId);
|
|
599
|
+
timeoutId = setTimeout(() => reject(new Error(`Health check timed out after ${timeoutMs}ms.`)), timeoutMs);
|
|
600
|
+
if (timeoutId.unref) timeoutId.unref();
|
|
601
|
+
};
|
|
419
602
|
});
|
|
420
603
|
|
|
421
604
|
let result: { success: boolean; error?: string };
|
|
@@ -424,9 +607,11 @@ export class BrowserTaskScheduler implements IScheduler {
|
|
|
424
607
|
const settled = new AbortController();
|
|
425
608
|
const queueSignal = signal ? AbortSignal.any([signal, settled.signal]) : settled.signal;
|
|
426
609
|
try {
|
|
427
|
-
|
|
610
|
+
// Same instance the wait-expiry message reads, so the diagnosis cannot
|
|
611
|
+
// describe a different queue from the one this probe waited on.
|
|
428
612
|
result = await Promise.race([
|
|
429
|
-
|
|
613
|
+
healthQueue.enqueue('healthcheck', async () => {
|
|
614
|
+
startDeadline();
|
|
430
615
|
return await executePoolTaskWithDeathBackstop(() => {
|
|
431
616
|
const execPromise = pool.execute({ type: 'healthcheck', queuedAt: startTime, taskTimeoutMs: timeoutMs });
|
|
432
617
|
execPromise.catch((err: Error) => logger.debug(`[BrowserTaskScheduler] Background healthcheck task rejection: ${err.message}`));
|
|
@@ -459,9 +644,10 @@ export class BrowserTaskScheduler implements IScheduler {
|
|
|
459
644
|
} finally {
|
|
460
645
|
if (timeoutId) clearTimeout(timeoutId);
|
|
461
646
|
settled.abort();
|
|
647
|
+
observeQueueWait('browser_healthcheck_queue_wait_ms', hcDispatchedAt, startTime);
|
|
462
648
|
}
|
|
463
649
|
|
|
464
|
-
const duration =
|
|
650
|
+
const duration = executionMs(hcDispatchedAt, startTime);
|
|
465
651
|
// In-band error first — success-side recording must not precede it (see
|
|
466
652
|
// runSearch). This path additionally owns the browser_pool_health gauge:
|
|
467
653
|
// setting it to 1 before the check flickered the pool healthy on every
|
|
@@ -31,15 +31,40 @@ function getWindowsCamoufoxDir(): string {
|
|
|
31
31
|
return join(homedir(), 'AppData', 'Local', 'camoufox', 'camoufox', 'Cache');
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
+
/**
|
|
35
|
+
* The custom camoufox location, if the user set one.
|
|
36
|
+
*
|
|
37
|
+
* CAMOUFOX_INSTALL_DIR takes precedence because it is camoufox-js's OWN
|
|
38
|
+
* variable and the only one that can ever move the install: camoufox-js ≥0.12.0
|
|
39
|
+
* resolves INSTALL_DIR from it. PLAYWRIGHT_BROWSERS_PATH is kept as a documented
|
|
40
|
+
* alias, but on its own it only ever moved where we LOOK — camoufox went on
|
|
41
|
+
* installing to the default cache, so setting it made the browser permanently
|
|
42
|
+
* "not found" right after a successful download. Both are exported onward to
|
|
43
|
+
* camoufox-js (getBrowserEnv, scripts/setup.cjs) so that install and lookup
|
|
44
|
+
* cannot disagree.
|
|
45
|
+
*
|
|
46
|
+
* CAVEAT, deliberate: the pinned camoufox-js (0.10.x — see
|
|
47
|
+
* thread-worker-browser.ts for why it cannot move yet) hardcodes
|
|
48
|
+
* `userCacheDir("camoufox")` and honours NEITHER variable, so on the shipped
|
|
49
|
+
* pin a custom location still only relocates lookup and the underlying bug
|
|
50
|
+
* remains. This plumbing is correct and inert rather than wrong: it starts
|
|
51
|
+
* working the moment the pin can advance, and costs nothing until then. Until
|
|
52
|
+
* that happens, treat a custom browser directory as unsupported.
|
|
53
|
+
*/
|
|
54
|
+
function getCustomCamoufoxDir(): string | undefined {
|
|
55
|
+
return process.env['CAMOUFOX_INSTALL_DIR'] || process.env['PLAYWRIGHT_BROWSERS_PATH'] || undefined;
|
|
56
|
+
}
|
|
57
|
+
|
|
34
58
|
/**
|
|
35
59
|
* Get the camoufox binary cache directory.
|
|
36
|
-
* Uses
|
|
60
|
+
* Uses the custom location above if set, otherwise the standard user cache location
|
|
37
61
|
* that camoufox uses by default (~/.cache/camoufox on Linux).
|
|
38
62
|
* We do NOT override HOME — that trick was unreliable and caused install/runtime mismatches.
|
|
39
63
|
*/
|
|
40
64
|
export function getBrowserCacheDir(): string {
|
|
41
|
-
|
|
42
|
-
|
|
65
|
+
const custom = getCustomCamoufoxDir();
|
|
66
|
+
if (custom) {
|
|
67
|
+
return custom;
|
|
43
68
|
}
|
|
44
69
|
// Mirror getCamoufoxBinaryPath() so both functions agree on the cache location
|
|
45
70
|
const osPlatform = platform();
|
|
@@ -109,11 +134,17 @@ export function getBrowserProfileDir(config?: Config): string {
|
|
|
109
134
|
*/
|
|
110
135
|
export function getBrowserEnv(config?: Config): NodeJS.ProcessEnv {
|
|
111
136
|
const env: NodeJS.ProcessEnv = { ...process.env };
|
|
112
|
-
const customPath =
|
|
137
|
+
const customPath = getCustomCamoufoxDir();
|
|
113
138
|
if (customPath) {
|
|
114
139
|
env['PLAYWRIGHT_BROWSERS_PATH'] = customPath;
|
|
140
|
+
// The one that camoufox-js actually reads. Without it the worker's
|
|
141
|
+
// camoufox launches from the DEFAULT cache while everything on this side
|
|
142
|
+
// resolves the custom path — the install/runtime mismatch this file's
|
|
143
|
+
// header warns about, just arriving through a different door.
|
|
144
|
+
env['CAMOUFOX_INSTALL_DIR'] = customPath;
|
|
115
145
|
} else {
|
|
116
146
|
delete env['PLAYWRIGHT_BROWSERS_PATH'];
|
|
147
|
+
delete env['CAMOUFOX_INSTALL_DIR'];
|
|
117
148
|
}
|
|
118
149
|
// Tell thread-workers where to write their lifecycle/error log. Precedence:
|
|
119
150
|
// 1. A user-set PI_RESEARCH_LOG_FILE (already copied from process.env above) wins —
|
|
@@ -164,7 +195,7 @@ export function ensureBrowserCacheDir(): string {
|
|
|
164
195
|
* Matches camoufox-js's own resolution logic.
|
|
165
196
|
*/
|
|
166
197
|
export function getCamoufoxBinaryPath(): string {
|
|
167
|
-
const customPath =
|
|
198
|
+
const customPath = getCustomCamoufoxDir();
|
|
168
199
|
if (customPath) {
|
|
169
200
|
return customPath;
|
|
170
201
|
}
|
|
@@ -296,4 +327,44 @@ export function isBrowserAvailable(): boolean {
|
|
|
296
327
|
function isFullMockMode(): boolean {
|
|
297
328
|
return process.env['PI_RESEARCH_MOCK_SEARCH'] === 'true' &&
|
|
298
329
|
process.env['PI_RESEARCH_MOCK_SCRAPE'] === 'true';
|
|
299
|
-
}
|
|
330
|
+
}
|
|
331
|
+
/**
|
|
332
|
+
* How long the leader's health probe may take, end to end.
|
|
333
|
+
*
|
|
334
|
+
* This was a hardcoded 105s (45s probe + 60s queue-wait margin) and the verdict logic
|
|
335
|
+
* that reads it is built on an arithmetic claim: the probe out-waits any task that can
|
|
336
|
+
* hold a worker slot, so a probe that never reaches a worker really has found a wedge.
|
|
337
|
+
* That claim held for the DEFAULT timeouts and for no others. `SEARCH_TIMEOUT_MS` and
|
|
338
|
+
* `BROWSER_TASK_TIMEOUT_MS` are both settable to 120 000, so a search may legally hold
|
|
339
|
+
* a slot for 240s — and a user who merely raised the search timeout would see healthy
|
|
340
|
+
* pools reported as wedged, which aborts the run at the readiness gate.
|
|
341
|
+
*
|
|
342
|
+
* Deriving it instead makes the invariant hold by construction:
|
|
343
|
+
* - the longest a single task may hold a slot, plus
|
|
344
|
+
* - the probe's own work: the worker runs up to THREE navigation attempts (primary,
|
|
345
|
+
* retry, neutral-endpoint fallback) at `max(10s, HEALTH_CHECK_TIMEOUT_MS)` each,
|
|
346
|
+
* and a budget below that sum makes the fallback ladder unreachable — so raising
|
|
347
|
+
* HEALTH_CHECK_TIMEOUT_MS used to make the health check strictly worse.
|
|
348
|
+
*
|
|
349
|
+
* Floored at the historical 105s so default installs are unchanged.
|
|
350
|
+
*/
|
|
351
|
+
export const HEALTHCHECK_MIN_BUDGET_MS = 45_000 + 60_000;
|
|
352
|
+
|
|
353
|
+
/** Navigation attempts executeHealthCheck makes: primary, retry, fallback endpoint. */
|
|
354
|
+
const HEALTHCHECK_NAV_ATTEMPTS = 3;
|
|
355
|
+
|
|
356
|
+
export function getHealthCheckBudgetMs(config?: Config): number {
|
|
357
|
+
const c = (config || getConfig()) as Partial<Config>;
|
|
358
|
+
// Each field is read defensively. A missing or non-finite value would otherwise
|
|
359
|
+
// propagate a NaN into `setTimeout`, which Node coerces to 1ms — turning the health
|
|
360
|
+
// probe's deadline into an instant failure. Falling back to the schema default is
|
|
361
|
+
// the only reading that cannot silently invert the bound.
|
|
362
|
+
const ms = (value: unknown, fallback: number): number =>
|
|
363
|
+
typeof value === 'number' && Number.isFinite(value) ? value : fallback;
|
|
364
|
+
const longestSlotHold =
|
|
365
|
+
Math.max(ms(c.SEARCH_TIMEOUT_MS, 45_000), ms(c.SCRAPE_TIMEOUT_MS, 15_000))
|
|
366
|
+
+ ms(c.BROWSER_TASK_TIMEOUT_MS, 10_000);
|
|
367
|
+
// Mirrors the worker's own floor in executeHealthCheck.
|
|
368
|
+
const probeWork = HEALTHCHECK_NAV_ATTEMPTS * Math.max(10_000, ms(c.HEALTH_CHECK_TIMEOUT_MS, 10_000));
|
|
369
|
+
return Math.max(HEALTHCHECK_MIN_BUDGET_MS, longestSlotHold + probeWork);
|
|
370
|
+
}
|
|
@@ -2,6 +2,20 @@ import { logger } from '../../logger.ts';
|
|
|
2
2
|
|
|
3
3
|
export type TaskType = 'search' | 'scrape' | 'healthcheck';
|
|
4
4
|
|
|
5
|
+
/**
|
|
6
|
+
* Did this resolved value carry an in-band worker failure?
|
|
7
|
+
*
|
|
8
|
+
* Worker tasks report failure by RESOLVING with `{ error: ... }` rather than by
|
|
9
|
+
* rejecting, so "the promise settled successfully" and "the task succeeded" are
|
|
10
|
+
* different facts about the same value.
|
|
11
|
+
*/
|
|
12
|
+
function isTaskFailureResult(result: unknown): boolean {
|
|
13
|
+
return typeof result === 'object'
|
|
14
|
+
&& result !== null
|
|
15
|
+
&& typeof (result as { error?: unknown }).error === 'string'
|
|
16
|
+
&& (result as { error: string }).error.length > 0;
|
|
17
|
+
}
|
|
18
|
+
|
|
5
19
|
export interface QueuedTask<T> {
|
|
6
20
|
type: TaskType;
|
|
7
21
|
fn: () => Promise<T>;
|
|
@@ -27,6 +41,16 @@ export class PriorityTaskQueue {
|
|
|
27
41
|
private maxTotalConcurrency: number;
|
|
28
42
|
private readonly maxQueueDepth: number;
|
|
29
43
|
private isShutdown = false;
|
|
44
|
+
/** When a task last completed WITHOUT error (epoch ms), or 0 if never.
|
|
45
|
+
*
|
|
46
|
+
* Diagnostic only — it is reported in the health probe's failure message so an
|
|
47
|
+
* operator can tell a pool that stopped a moment ago from one that never started.
|
|
48
|
+
* It was briefly used as a health VERDICT and must not be again: a success frees a
|
|
49
|
+
* slot, a freed slot dispatches the waiting probe (healthchecks have strict
|
|
50
|
+
* priority), so by the time a probe times out this value is necessarily older than
|
|
51
|
+
* its whole budget. Failures deliberately do not count either — a wedged pool still
|
|
52
|
+
* turns its slots over as deadlines and death-backstops fire. */
|
|
53
|
+
private lastSuccessAt = 0;
|
|
30
54
|
|
|
31
55
|
constructor(maxTotalConcurrency: number, maxQueueDepth = 500) {
|
|
32
56
|
this.maxTotalConcurrency = maxTotalConcurrency;
|
|
@@ -90,6 +114,21 @@ export class PriorityTaskQueue {
|
|
|
90
114
|
});
|
|
91
115
|
}
|
|
92
116
|
|
|
117
|
+
/** Epoch ms of the last task that completed successfully, or 0 if none has. */
|
|
118
|
+
getLastSuccessAt(): number {
|
|
119
|
+
return this.lastSuccessAt;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Tasks currently executing on a worker. */
|
|
123
|
+
getActiveCount(): number {
|
|
124
|
+
return this.activeCount;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** How many tasks may execute at once. */
|
|
128
|
+
getConcurrency(): number {
|
|
129
|
+
return this.maxTotalConcurrency;
|
|
130
|
+
}
|
|
131
|
+
|
|
93
132
|
private removeFromQueue(task: QueuedTask<any>): boolean {
|
|
94
133
|
const queues = [this.healthcheckQueue, this.searchQueue, this.scrapeQueue];
|
|
95
134
|
for (const q of queues) {
|
|
@@ -171,6 +210,13 @@ export class PriorityTaskQueue {
|
|
|
171
210
|
} else {
|
|
172
211
|
result = await fnPromise;
|
|
173
212
|
}
|
|
213
|
+
// A worker-reported failure arrives as a RESOLVED value carrying `error` —
|
|
214
|
+
// poolifier delivers it that way, and the scheduler only turns it into a
|
|
215
|
+
// throw afterwards. Resolution alone therefore does not mean the task
|
|
216
|
+
// succeeded: a worker returning `{error:'Task timed out'}` for every task
|
|
217
|
+
// would keep this timestamp fresh through a total outage, which is the one
|
|
218
|
+
// thing it must not do.
|
|
219
|
+
if (!isTaskFailureResult(result)) this.lastSuccessAt = Date.now();
|
|
174
220
|
task.resolve(result);
|
|
175
221
|
} catch (err) {
|
|
176
222
|
task.reject(err);
|
|
@@ -187,8 +233,16 @@ export class PriorityTaskQueue {
|
|
|
187
233
|
* Update the maximum concurrency limit (e.g. if config changes).
|
|
188
234
|
*/
|
|
189
235
|
updateConcurrency(maxTotalConcurrency: number) {
|
|
236
|
+
// Called on a path that runs per task, and the value almost never changes: this
|
|
237
|
+
// logged "Concurrency updated to 6" 709 times in one day, always to the value it
|
|
238
|
+
// already had. Log real changes only — a line that fires constantly and says
|
|
239
|
+
// nothing is what a reader learns to skip past, including on the day it matters.
|
|
240
|
+
// process() still runs unconditionally: it is idempotent, and a raise that
|
|
241
|
+
// arrived while the queue was full must be free to dispatch immediately.
|
|
242
|
+
if (maxTotalConcurrency !== this.maxTotalConcurrency) {
|
|
243
|
+
logger.debug(`[PriorityQueue] Concurrency changed ${this.maxTotalConcurrency} -> ${maxTotalConcurrency}`);
|
|
244
|
+
}
|
|
190
245
|
this.maxTotalConcurrency = maxTotalConcurrency;
|
|
191
|
-
logger.debug(`[PriorityQueue] Concurrency updated to ${maxTotalConcurrency}`);
|
|
192
246
|
this.process();
|
|
193
247
|
}
|
|
194
248
|
|
|
@@ -162,8 +162,19 @@ export async function forceSchedulerRestart(forceClearRemoteState: boolean = fal
|
|
|
162
162
|
|
|
163
163
|
logger.log('[Scheduler] Restart complete. Next call will create fresh scheduler.');
|
|
164
164
|
} finally {
|
|
165
|
+
// Deliberately does NOT dispose the browser-init lock. This function never
|
|
166
|
+
// acquires it, and FileLockService.dispose() is not a release — it drains for
|
|
167
|
+
// 5s and then FORCES the teardown, unlinking the lock file and retiring the
|
|
168
|
+
// instance. A browser init holds that lock for as long as it takes to launch a
|
|
169
|
+
// browser server, which is routinely longer than 5s, and the retry paths in
|
|
170
|
+
// task-execution-service call this function mid-run precisely when an init is
|
|
171
|
+
// likely in flight. The victim's critical section is not cancelled by the
|
|
172
|
+
// teardown, and the next getBrowserInitLock() builds a fresh instance (new uuid,
|
|
173
|
+
// new FIFO queue) over the now-unlinked path, so it acquires immediately: two
|
|
174
|
+
// concurrent browser inits, two elections, two listening servers. The lock is
|
|
175
|
+
// path-scoped and version-independent, so a restart does not invalidate it;
|
|
176
|
+
// the owning SchedulerFactoryService disposes it at container teardown.
|
|
165
177
|
schedulerService.setSchedulerRestartInProgress(false);
|
|
166
|
-
await disposeBrowserInitLock(container).catch(() => {});
|
|
167
178
|
}
|
|
168
179
|
}
|
|
169
180
|
|