@lincoln504/pi-research 1.3.12 → 1.4.3
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 +5670 -4705
- package/dist/prompts/research-tool-usage.md +1 -1
- package/dist/prompts/researcher.md +1 -0
- package/dist/prompts/system-lead-router.md +122 -0
- package/dist/prompts/system-lead-synthesizer.md +46 -0
- package/dist/thread-worker.mjs +169 -62
- package/docs/ARCHITECTURE.md +18 -10
- package/docs/CHANGELOG.md +225 -0
- package/docs/CONFIGURATION.md +106 -7
- package/package.json +2 -2
- package/scripts/build.cjs +8 -1
- package/skills/pi-research/SKILL.md +1 -1
- package/src/cli.ts +5 -1
- package/src/config.ts +22 -12
- 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 +766 -91
- package/src/core/planning-utils.ts +21 -5
- package/src/core/service-initialization.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 +236 -43
- package/src/infrastructure/browser/config.ts +79 -1
- 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 +5 -5
- package/src/infrastructure/browser/thread-worker-browser.ts +87 -36
- package/src/infrastructure/browser/thread-worker-messaging.ts +99 -10
- package/src/infrastructure/browser/thread-worker.mjs +169 -62
- package/src/infrastructure/browser/thread-worker.ts +13 -5
- 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/embedding/embedding-server.ts +34 -0
- package/src/infrastructure/file-lock-service.ts +214 -19
- 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 +46 -19
- package/src/knowledge/embedder.ts +11 -3
- package/src/knowledge/index.ts +36 -10
- package/src/knowledge/store.ts +29 -5
- package/src/logger.ts +11 -0
- package/src/orchestration/deep-research-orchestrator.ts +143 -87
- package/src/orchestration/quick-research-orchestrator.ts +11 -2
- package/src/orchestration/research-orchestration-service.ts +8 -0
- 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/orchestration/session-state.ts +16 -0
- package/src/prompts/research-tool-usage.md +1 -1
- package/src/prompts/researcher.md +1 -0
- package/src/prompts/system-lead-router.md +122 -0
- package/src/prompts/system-lead-synthesizer.md +46 -0
- package/src/sdk.ts +10 -0
- package/src/skill-install/skill-installer.ts +5 -1
- package/src/stackexchange/index.ts +3 -0
- package/src/stackexchange/rest-client.ts +7 -12
- package/src/tools/research-knowledge-search.ts +11 -3
- package/src/tools/research-tool-definition.ts +38 -7
- package/src/tools/scrape.ts +6 -0
- package/src/tools/search.ts +7 -0
- package/src/tools/security.ts +31 -10
- 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/host-config.ts +12 -1
- package/src/utils/llm-usage.ts +33 -6
- package/src/utils/log-utils.ts +10 -0
- package/src/utils/metrics-summary.ts +20 -4
- package/src/utils/metrics.ts +27 -1
- package/src/web-research/browser-search.ts +102 -22
- package/src/web-research/web-scraper.ts +27 -16
- package/src/youtube/potoken.ts +8 -1
- package/src/youtube/transcript-client.ts +56 -5
- 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, COLD_START_ALLOWANCE_MS } 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
|
|
@@ -228,19 +273,48 @@ export class BrowserTaskScheduler implements IScheduler {
|
|
|
228
273
|
const pool = await (await this.getWorkerPoolManager()).ensurePool(config);
|
|
229
274
|
const startTime = Date.now();
|
|
230
275
|
|
|
231
|
-
// Task timeout = the search nav budget (SEARCH_TIMEOUT_MS) +
|
|
232
|
-
//
|
|
233
|
-
//
|
|
234
|
-
//
|
|
276
|
+
// Task timeout = the search nav budget (SEARCH_TIMEOUT_MS) + an overhead margin
|
|
277
|
+
// (BROWSER_TASK_TIMEOUT_MS) + a cold-start allowance (COLD_START_ALLOWANCE_MS).
|
|
278
|
+
// Deriving from SEARCH_TIMEOUT_MS keeps the task ceiling coherent with the
|
|
279
|
+
// worker's own nav timeout — using a flat base here would kill a search before
|
|
280
|
+
// it had used its full nav budget. The cold-start term matters because eager
|
|
281
|
+
// browser warmup is deliberately disabled: the first search dispatched to a
|
|
282
|
+
// freshly created or just-reset worker pays a real Camoufox launch + context
|
|
283
|
+
// creation inline, before any navigation even starts — without this term that
|
|
284
|
+
// legitimate cold start reads as a dead/wedged worker (see COLD_START_ALLOWANCE_MS).
|
|
235
285
|
const cfg = config || getConfig();
|
|
236
|
-
const timeoutMs = cfg.SEARCH_TIMEOUT_MS + cfg.BROWSER_TASK_TIMEOUT_MS;
|
|
237
|
-
|
|
286
|
+
const timeoutMs = cfg.SEARCH_TIMEOUT_MS + cfg.BROWSER_TASK_TIMEOUT_MS + COLD_START_ALLOWANCE_MS;
|
|
287
|
+
|
|
288
|
+
// The deadline measures EXECUTION, not queue wait: the timer is armed by
|
|
289
|
+
// `startDeadline()` from inside the enqueued callback, i.e. once the queue has
|
|
290
|
+
// actually dispatched this task to a worker.
|
|
291
|
+
//
|
|
292
|
+
// It used to be armed here, before enqueuing, so a saturated queue burned a task's
|
|
293
|
+
// entire budget waiting for a slot and it aborted having done no work at all. That
|
|
294
|
+
// made the number of tasks a burst could complete `capacity x (budget / latency)`
|
|
295
|
+
// rather than the number submitted — measured on a live run, 78 of 100 searches
|
|
296
|
+
// aborted at the deadline without ever running. A queue is supposed to absorb
|
|
297
|
+
// volume by making work wait, not by killing whatever it could not reach in time.
|
|
298
|
+
// Waiting is now bounded by the caller's signal and the run-level timeouts; a task
|
|
299
|
+
// that does get dispatched always gets its full budget.
|
|
300
|
+
// `dispatchedAt` is the same instant, kept for MEASUREMENT rather than for the
|
|
301
|
+
// deadline. Reporting `Date.now() - startTime` as the search's duration charged
|
|
302
|
+
// queue wait to the work, and that number is what every latency analysis reads:
|
|
303
|
+
// on the night this was diagnosed it reported a 51s median against a true 5.8s
|
|
304
|
+
// execution median, and invented an eightfold latency regression that never
|
|
305
|
+
// happened. Moving the deadline off queue wait without moving the measurement
|
|
306
|
+
// would have left the misdiagnosis in place while claiming to have fixed it.
|
|
238
307
|
let timeoutId: NodeJS.Timeout | undefined;
|
|
308
|
+
let dispatchedAt = 0;
|
|
309
|
+
let startDeadline: () => void = () => {};
|
|
239
310
|
const timeoutPromise = new Promise<never>((_, reject) => {
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
311
|
+
startDeadline = () => {
|
|
312
|
+
dispatchedAt = Date.now();
|
|
313
|
+
timeoutId = setTimeout(() => {
|
|
314
|
+
reject(new Error(`Search task timed out after ${timeoutMs}ms. query="${query}"`));
|
|
315
|
+
}, timeoutMs);
|
|
316
|
+
if (timeoutId.unref) timeoutId.unref();
|
|
317
|
+
};
|
|
244
318
|
});
|
|
245
319
|
|
|
246
320
|
logger.debug(`[BrowserTaskScheduler] Executing search: "${query}" (Timeout: ${timeoutMs}ms)`);
|
|
@@ -258,19 +332,24 @@ export class BrowserTaskScheduler implements IScheduler {
|
|
|
258
332
|
// This ensures that even if the queue is saturated, we won't hang forever.
|
|
259
333
|
result = await Promise.race([
|
|
260
334
|
queue.enqueue('search', async () => {
|
|
261
|
-
|
|
262
|
-
//
|
|
263
|
-
//
|
|
264
|
-
//
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
335
|
+
startDeadline();
|
|
336
|
+
// Same instant, for the same reason, one layer up: whoever called us
|
|
337
|
+
// arms their own guard here instead of at call time, and disarms it
|
|
338
|
+
// when we settle. A listener that throws must not take the task down.
|
|
339
|
+
try { onTaskEvent?.('dispatched'); } catch { /* caller's guard, caller's problem */ }
|
|
340
|
+
try {
|
|
341
|
+
return await executePoolTaskWithDeathBackstop(
|
|
342
|
+
() => pool.execute({ type: 'search', query, queuedAt: startTime, taskTimeoutMs: timeoutMs }),
|
|
343
|
+
timeoutMs,
|
|
344
|
+
'Search',
|
|
345
|
+
);
|
|
346
|
+
} finally {
|
|
347
|
+
try { onTaskEvent?.('settled'); } catch { /* as above */ }
|
|
348
|
+
}
|
|
270
349
|
}, queueSignal),
|
|
271
350
|
timeoutPromise
|
|
272
351
|
]);
|
|
273
|
-
logger.debug(`[BrowserTaskScheduler] Search completed: "${query}" in ${
|
|
352
|
+
logger.debug(`[BrowserTaskScheduler] Search completed: "${query}" in ${executionMs(dispatchedAt, startTime)}ms (queued ${queueWaitMs(dispatchedAt, startTime)}ms)`);
|
|
274
353
|
} catch (error) {
|
|
275
354
|
// A pool-shutdown/destroy error means the run is being torn down (quit/SIGTERM
|
|
276
355
|
// mid-search-burst): the task never ran because dispose() destroyed the pool.
|
|
@@ -304,9 +383,16 @@ export class BrowserTaskScheduler implements IScheduler {
|
|
|
304
383
|
} finally {
|
|
305
384
|
if (timeoutId) clearTimeout(timeoutId);
|
|
306
385
|
settled.abort();
|
|
386
|
+
// Recorded here rather than on the success path below, because the tasks
|
|
387
|
+
// that wait longest are precisely the ones that do not succeed — measuring
|
|
388
|
+
// queue wait only on completed tasks censors the tail this metric exists to
|
|
389
|
+
// expose. A task that never reached a worker has no dispatch instant, so its
|
|
390
|
+
// wait is the whole elapsed time and is labelled as such; averaging it in as
|
|
391
|
+
// a zero would have understated saturation exactly when it was worst.
|
|
392
|
+
observeQueueWait('browser_search_queue_wait_ms', dispatchedAt, startTime);
|
|
307
393
|
}
|
|
308
394
|
|
|
309
|
-
const duration =
|
|
395
|
+
const duration = executionMs(dispatchedAt, startTime);
|
|
310
396
|
// In-band worker error checked FIRST: a task the worker reports as failed
|
|
311
397
|
// must count only as an error. Recording success unconditionally before
|
|
312
398
|
// this check double-counted every worker-reported failure as
|
|
@@ -327,23 +413,32 @@ export class BrowserTaskScheduler implements IScheduler {
|
|
|
327
413
|
return result.results;
|
|
328
414
|
}
|
|
329
415
|
|
|
330
|
-
async runScrape(url: string, config?: Config, signal?: AbortSignal): Promise<ScrapeResult> {
|
|
416
|
+
async runScrape(url: string, config?: Config, signal?: AbortSignal, onTaskEvent?: TaskDispatchListener): Promise<ScrapeResult> {
|
|
331
417
|
if (this.isShuttingDown) throw new Error('Worker pool is shutting down');
|
|
332
418
|
this.resetIdleTimer();
|
|
333
419
|
const pool = await (await this.getWorkerPoolManager()).ensurePool(config);
|
|
334
420
|
const startTime = Date.now();
|
|
335
|
-
// Task timeout = the scrape nav budget (SCRAPE_TIMEOUT_MS) +
|
|
336
|
-
//
|
|
421
|
+
// Task timeout = the scrape nav budget (SCRAPE_TIMEOUT_MS) + an overhead margin,
|
|
422
|
+
// mirroring the search path so both stay coherent. The mocked branch skips the
|
|
423
|
+
// cold-start allowance deliberately: PI_RESEARCH_MOCK_SCRAPE implies no real
|
|
424
|
+
// Camoufox launch is expected for scrape tasks, so there is nothing to budget for.
|
|
337
425
|
const cfg = config || getConfig();
|
|
338
426
|
const isMocking = process.env['PI_RESEARCH_MOCK_SCRAPE'] === 'true';
|
|
339
|
-
const timeoutMs = cfg.SCRAPE_TIMEOUT_MS + (isMocking ? 5000 : cfg.BROWSER_TASK_TIMEOUT_MS);
|
|
427
|
+
const timeoutMs = cfg.SCRAPE_TIMEOUT_MS + (isMocking ? 5000 : cfg.BROWSER_TASK_TIMEOUT_MS + COLD_START_ALLOWANCE_MS);
|
|
340
428
|
|
|
429
|
+
// Armed on dispatch, not on enqueue — see runSearch for why.
|
|
430
|
+
// See runSearch: dispatch time is tracked for measurement, not for the deadline.
|
|
341
431
|
let timeoutId: NodeJS.Timeout | undefined;
|
|
432
|
+
let dispatchedAt = 0;
|
|
433
|
+
let startDeadline: () => void = () => {};
|
|
342
434
|
const timeoutPromise = new Promise<never>((_, reject) => {
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
435
|
+
startDeadline = () => {
|
|
436
|
+
dispatchedAt = Date.now();
|
|
437
|
+
timeoutId = setTimeout(() => {
|
|
438
|
+
reject(new Error(`Scrape task timed out after ${timeoutMs}ms. url="${url}"`));
|
|
439
|
+
}, timeoutMs);
|
|
440
|
+
if (timeoutId.unref) timeoutId.unref();
|
|
441
|
+
};
|
|
347
442
|
});
|
|
348
443
|
|
|
349
444
|
let result: any;
|
|
@@ -355,11 +450,18 @@ export class BrowserTaskScheduler implements IScheduler {
|
|
|
355
450
|
const queue = this.getPriorityQueue(config);
|
|
356
451
|
result = await Promise.race([
|
|
357
452
|
queue.enqueue('scrape', async () => {
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
453
|
+
startDeadline();
|
|
454
|
+
// See runSearch: the caller's guard is armed on dispatch and disarmed on settle.
|
|
455
|
+
try { onTaskEvent?.('dispatched'); } catch { /* caller's guard, caller's problem */ }
|
|
456
|
+
try {
|
|
457
|
+
return await executePoolTaskWithDeathBackstop(
|
|
458
|
+
() => pool.execute({ type: 'scrape', url, queuedAt: startTime, taskTimeoutMs: timeoutMs }),
|
|
459
|
+
timeoutMs,
|
|
460
|
+
'Scrape',
|
|
461
|
+
);
|
|
462
|
+
} finally {
|
|
463
|
+
try { onTaskEvent?.('settled'); } catch { /* as above */ }
|
|
464
|
+
}
|
|
363
465
|
}, queueSignal),
|
|
364
466
|
timeoutPromise
|
|
365
467
|
]);
|
|
@@ -386,9 +488,11 @@ export class BrowserTaskScheduler implements IScheduler {
|
|
|
386
488
|
} finally {
|
|
387
489
|
if (timeoutId) clearTimeout(timeoutId);
|
|
388
490
|
settled.abort();
|
|
491
|
+
// See runSearch: recorded on every outcome, not only on success.
|
|
492
|
+
observeQueueWait('browser_scrape_queue_wait_ms', dispatchedAt, startTime);
|
|
389
493
|
}
|
|
390
494
|
|
|
391
|
-
const duration =
|
|
495
|
+
const duration = executionMs(dispatchedAt, startTime);
|
|
392
496
|
// In-band error first — success-side recording must not precede it (see runSearch).
|
|
393
497
|
if (result.error) {
|
|
394
498
|
metrics.increment('browser_scrape_requests_total', 1, { status: 'error' });
|
|
@@ -405,17 +509,103 @@ export class BrowserTaskScheduler implements IScheduler {
|
|
|
405
509
|
return result;
|
|
406
510
|
}
|
|
407
511
|
|
|
408
|
-
async runHealthCheck(config?: Config, signal?: AbortSignal): Promise<{ success: boolean }> {
|
|
512
|
+
async runHealthCheck(config?: Config, signal?: AbortSignal, budgetOverrideMs?: number): Promise<{ success: boolean }> {
|
|
409
513
|
if (this.isShuttingDown) throw new Error('Worker pool is shutting down');
|
|
410
514
|
this.resetIdleTimer();
|
|
411
515
|
const pool = await (await this.getWorkerPoolManager()).ensurePool(config);
|
|
412
516
|
const startTime = Date.now();
|
|
413
517
|
const isMocking = process.env['PI_RESEARCH_MOCK_SEARCH'] === 'true' || process.env['PI_RESEARCH_MOCK_SCRAPE'] === 'true';
|
|
414
|
-
|
|
518
|
+
// Derived, not hardcoded — see getHealthCheckBudgetMs. The wait-phase verdict
|
|
519
|
+
// below rests on this budget out-waiting any task that can hold a slot, and a
|
|
520
|
+
// fixed 105s made that true only for the default timeouts. `budgetOverrideMs`
|
|
521
|
+
// exists so the verdict is reachable from a test at all: at 105s it is not,
|
|
522
|
+
// which is exactly how a previous version of it shipped unreachable and stayed
|
|
523
|
+
// that way.
|
|
524
|
+
const timeoutMs = budgetOverrideMs ?? getHealthCheckBudgetMs(config) / (isMocking ? 4 : 1);
|
|
525
|
+
|
|
526
|
+
// A probe that never got a worker used to fail as "timed out (including queue
|
|
527
|
+
// wait)", and the registry reported BrowserRuntime CRITICALLY UNHEALTHY —
|
|
528
|
+
// during a burst in which the pool was healthily executing the run's own
|
|
529
|
+
// searches. Twice on the night this was measured.
|
|
530
|
+
//
|
|
531
|
+
// Arming the deadline on DISPATCH is the whole fix, and it is sufficient. Three
|
|
532
|
+
// attempts were made to add a second, wait-phase verdict that would excuse a
|
|
533
|
+
// saturated pool, and all three were wrong. They are recorded here because each
|
|
534
|
+
// one reads sensibly and the next reader will otherwise reach for one of them:
|
|
535
|
+
//
|
|
536
|
+
// 1. "did the queue dispatch anything recently" — unreachable. Healthcheck
|
|
537
|
+
// tasks have strict queue priority, so if a slot frees while this probe
|
|
538
|
+
// waits, the queue dispatches THIS probe and cancels the timer. The timer
|
|
539
|
+
// can only fire in a window where nothing dispatched at all.
|
|
540
|
+
// 2. "are the slots occupied" — wrong in the dangerous direction. activeCount
|
|
541
|
+
// rises before pool.execute, so six workers hung inside a browser launch
|
|
542
|
+
// are six occupied slots, and the exact incident this was written for
|
|
543
|
+
// would have reported HEALTHY.
|
|
544
|
+
// 3. "has a task completed successfully recently" — unreachable for the same
|
|
545
|
+
// reason as (1). A success frees a slot, a freed slot dispatches this
|
|
546
|
+
// probe, so at expiry the last success is necessarily older than the whole
|
|
547
|
+
// budget. The condition it tested was false by construction.
|
|
548
|
+
//
|
|
549
|
+
// The general form of the mistake: with strict priority, a probe that waited
|
|
550
|
+
// out its entire budget without being dispatched IS the evidence of a wedge.
|
|
551
|
+
// There is no second signal to consult, and every attempt to find one either
|
|
552
|
+
// could not fire or fired for a dead pool. The numbers below are kept for the
|
|
553
|
+
// DIAGNOSTIC message — an operator needs to know whether the slots were busy
|
|
554
|
+
// and when work last succeeded — and not for the verdict.
|
|
555
|
+
//
|
|
556
|
+
// False positives are bounded by arithmetic rather than by judgement: the probe
|
|
557
|
+
// allows 105s while every task that can hold a slot is itself deadlined well
|
|
558
|
+
// under that (search 55s, scrape 25s plus backstop), so a slot frees inside the
|
|
559
|
+
// budget unless the pool has genuinely stopped.
|
|
560
|
+
//
|
|
561
|
+
// Once dispatched, the deadline measures execution alone (armed below, like
|
|
562
|
+
// runSearch/runScrape), so a slow probe still fails on its own merits.
|
|
415
563
|
let timeoutId: NodeJS.Timeout | undefined;
|
|
564
|
+
let dispatched = false;
|
|
565
|
+
let hcDispatchedAt = 0;
|
|
566
|
+
let startDeadline: () => void = () => {};
|
|
567
|
+
// Captured BEFORE the timer can run. Resolving the queue inside the callback
|
|
568
|
+
// would call getPriorityQueue() on a scheduler that may have shut down in the
|
|
569
|
+
// meantime, which builds a FRESH queue on the dead instance — undoing the
|
|
570
|
+
// isShutdown latch that exists to reject enqueues racing teardown, and
|
|
571
|
+
// reporting a routine shutdown as a wedge because a new queue has dispatched
|
|
572
|
+
// nothing.
|
|
573
|
+
const healthQueue = this.getPriorityQueue(config);
|
|
574
|
+
// Rejects only. It used to be typed as resolvable because a branch here answered
|
|
575
|
+
// "healthy" without running the probe; nothing does that now, and the type says so.
|
|
416
576
|
const timeoutPromise = new Promise<never>((_, reject) => {
|
|
417
|
-
|
|
577
|
+
const onWaitExpired = () => {
|
|
578
|
+
if (dispatched) return;
|
|
579
|
+
const lastSuccessAt = healthQueue.getLastSuccessAt();
|
|
580
|
+
const sinceSuccess = lastSuccessAt > 0 ? Date.now() - lastSuccessAt : Number.POSITIVE_INFINITY;
|
|
581
|
+
// The wording is load-bearing, not prose. isBusyPoolHealthFailure
|
|
582
|
+
// (src/healthcheck/index.ts) decides whether a failed probe aborts the
|
|
583
|
+
// run, and it decides by matching /tim(e|ed) out|timeout/ on this very
|
|
584
|
+
// string. An earlier rewrite of this message dropped the word entirely —
|
|
585
|
+
// so a probe that queued out under load stopped reading as a timeout, the
|
|
586
|
+
// tolerance gate rejected it, and a BUSY pool aborted the run at
|
|
587
|
+
// quick-research-orchestrator and research-health. The commit that did it
|
|
588
|
+
// was the one meant to stop a busy pool being reported as dead; it moved
|
|
589
|
+
// the abort one layer up. Pinned by a test that drives this exact string
|
|
590
|
+
// through that exact classifier.
|
|
591
|
+
//
|
|
592
|
+
// The rest is diagnosis: slots busy with nothing completing is a worker
|
|
593
|
+
// fault, slots free is a queue fault (the queue stopped dispatching), and
|
|
594
|
+
// the last-success age separates "stopped just now" from "never started".
|
|
595
|
+
reject(new Error(
|
|
596
|
+
`Health check timed out after ${timeoutMs}ms without reaching a worker — ${healthQueue.getActiveCount()} of ${healthQueue.getConcurrency()} slots busy, ` +
|
|
597
|
+
`${lastSuccessAt > 0 ? `last successful task ${sinceSuccess}ms ago` : 'no task has ever completed successfully'}.`,
|
|
598
|
+
));
|
|
599
|
+
};
|
|
600
|
+
timeoutId = setTimeout(onWaitExpired, timeoutMs);
|
|
418
601
|
if (timeoutId.unref) timeoutId.unref();
|
|
602
|
+
startDeadline = () => {
|
|
603
|
+
dispatched = true;
|
|
604
|
+
hcDispatchedAt = Date.now();
|
|
605
|
+
clearTimeout(timeoutId);
|
|
606
|
+
timeoutId = setTimeout(() => reject(new Error(`Health check timed out after ${timeoutMs}ms.`)), timeoutMs);
|
|
607
|
+
if (timeoutId.unref) timeoutId.unref();
|
|
608
|
+
};
|
|
419
609
|
});
|
|
420
610
|
|
|
421
611
|
let result: { success: boolean; error?: string };
|
|
@@ -424,9 +614,11 @@ export class BrowserTaskScheduler implements IScheduler {
|
|
|
424
614
|
const settled = new AbortController();
|
|
425
615
|
const queueSignal = signal ? AbortSignal.any([signal, settled.signal]) : settled.signal;
|
|
426
616
|
try {
|
|
427
|
-
|
|
617
|
+
// Same instance the wait-expiry message reads, so the diagnosis cannot
|
|
618
|
+
// describe a different queue from the one this probe waited on.
|
|
428
619
|
result = await Promise.race([
|
|
429
|
-
|
|
620
|
+
healthQueue.enqueue('healthcheck', async () => {
|
|
621
|
+
startDeadline();
|
|
430
622
|
return await executePoolTaskWithDeathBackstop(() => {
|
|
431
623
|
const execPromise = pool.execute({ type: 'healthcheck', queuedAt: startTime, taskTimeoutMs: timeoutMs });
|
|
432
624
|
execPromise.catch((err: Error) => logger.debug(`[BrowserTaskScheduler] Background healthcheck task rejection: ${err.message}`));
|
|
@@ -459,9 +651,10 @@ export class BrowserTaskScheduler implements IScheduler {
|
|
|
459
651
|
} finally {
|
|
460
652
|
if (timeoutId) clearTimeout(timeoutId);
|
|
461
653
|
settled.abort();
|
|
654
|
+
observeQueueWait('browser_healthcheck_queue_wait_ms', hcDispatchedAt, startTime);
|
|
462
655
|
}
|
|
463
656
|
|
|
464
|
-
const duration =
|
|
657
|
+
const duration = executionMs(hcDispatchedAt, startTime);
|
|
465
658
|
// In-band error first — success-side recording must not precede it (see
|
|
466
659
|
// runSearch). This path additionally owns the browser_pool_health gauge:
|
|
467
660
|
// setting it to 1 before the check flickered the pool healthy on every
|
|
@@ -327,4 +327,82 @@ export function isBrowserAvailable(): boolean {
|
|
|
327
327
|
function isFullMockMode(): boolean {
|
|
328
328
|
return process.env['PI_RESEARCH_MOCK_SEARCH'] === 'true' &&
|
|
329
329
|
process.env['PI_RESEARCH_MOCK_SCRAPE'] === 'true';
|
|
330
|
-
}
|
|
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, kept as a floor rather than a default: the
|
|
350
|
+
* derived value now exceeds it even at default timeouts once COLD_START_ALLOWANCE_MS
|
|
351
|
+
* (below) is folded into `longestSlotHold` — a cold Firefox launch was always able to
|
|
352
|
+
* hold a slot for close to that long, the 105s floor just didn't know it. The floor
|
|
353
|
+
* still matters for a hypothetical config where the derived value would otherwise land
|
|
354
|
+
* below it.
|
|
355
|
+
*/
|
|
356
|
+
export const HEALTHCHECK_MIN_BUDGET_MS = 45_000 + 60_000;
|
|
357
|
+
|
|
358
|
+
/** Navigation attempts executeHealthCheck makes: primary, retry, fallback endpoint. */
|
|
359
|
+
const HEALTHCHECK_NAV_ATTEMPTS = 3;
|
|
360
|
+
|
|
361
|
+
/**
|
|
362
|
+
* Hard ceiling on a single Camoufox browser launch (see launchOnce() in
|
|
363
|
+
* thread-worker-browser.ts, the single source of truth this mirrors — CI runners
|
|
364
|
+
* (2 vCPU) can take up to ~60s to start Firefox under load, floored generously
|
|
365
|
+
* above that).
|
|
366
|
+
*/
|
|
367
|
+
export const BROWSER_LAUNCH_TIMEOUT_MS = 90_000;
|
|
368
|
+
|
|
369
|
+
/**
|
|
370
|
+
* Hard ceiling on creating one BrowserContext against an already-launched browser
|
|
371
|
+
* (see acquireTaskContext() in thread-worker-browser.ts, the single source of
|
|
372
|
+
* truth this mirrors).
|
|
373
|
+
*/
|
|
374
|
+
export const CONTEXT_CREATION_TIMEOUT_MS = 30_000;
|
|
375
|
+
|
|
376
|
+
/**
|
|
377
|
+
* Worst-case time a worker can spend getting a browser+context ready before it
|
|
378
|
+
* starts a task's actual work. Eager warmup is deliberately disabled (thundering-
|
|
379
|
+
* herd avoidance — see thread-worker.ts), so the first task dispatched to a
|
|
380
|
+
* freshly created or just-reset worker pays this in full, inline with the task
|
|
381
|
+
* itself. Every deadline that bounds how long a task may legitimately hold a
|
|
382
|
+
* worker slot has to include this on top of its own nav budget, or a completely
|
|
383
|
+
* normal cold start on a fresh pool reads as a dead/wedged worker — the false
|
|
384
|
+
* positive `runSearch`/`runScrape`'s task-timeout ceiling and this budget were
|
|
385
|
+
* both built to eliminate, just triggered by a different clock.
|
|
386
|
+
*
|
|
387
|
+
* Deliberately NOT part of BROWSER_TASK_TIMEOUT_MS: that value is user-tunable
|
|
388
|
+
* overhead for per-task work, and cold-start cost isn't something a user should
|
|
389
|
+
* need to account for by hand when adjusting it.
|
|
390
|
+
*/
|
|
391
|
+
export const COLD_START_ALLOWANCE_MS = BROWSER_LAUNCH_TIMEOUT_MS + CONTEXT_CREATION_TIMEOUT_MS;
|
|
392
|
+
|
|
393
|
+
export function getHealthCheckBudgetMs(config?: Config): number {
|
|
394
|
+
const c = (config || getConfig()) as Partial<Config>;
|
|
395
|
+
// Each field is read defensively. A missing or non-finite value would otherwise
|
|
396
|
+
// propagate a NaN into `setTimeout`, which Node coerces to 1ms — turning the health
|
|
397
|
+
// probe's deadline into an instant failure. Falling back to the schema default is
|
|
398
|
+
// the only reading that cannot silently invert the bound.
|
|
399
|
+
const ms = (value: unknown, fallback: number): number =>
|
|
400
|
+
typeof value === 'number' && Number.isFinite(value) ? value : fallback;
|
|
401
|
+
const longestSlotHold =
|
|
402
|
+
Math.max(ms(c.SEARCH_TIMEOUT_MS, 45_000), ms(c.SCRAPE_TIMEOUT_MS, 15_000))
|
|
403
|
+
+ ms(c.BROWSER_TASK_TIMEOUT_MS, 10_000)
|
|
404
|
+
+ COLD_START_ALLOWANCE_MS;
|
|
405
|
+
// Mirrors the worker's own floor in executeHealthCheck.
|
|
406
|
+
const probeWork = HEALTHCHECK_NAV_ATTEMPTS * Math.max(10_000, ms(c.HEALTH_CHECK_TIMEOUT_MS, 10_000));
|
|
407
|
+
return Math.max(HEALTHCHECK_MIN_BUDGET_MS, longestSlotHold + probeWork);
|
|
408
|
+
}
|
|
@@ -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
|
|
|
@@ -18,7 +18,7 @@ import { getServiceContainer, getService } from '../../core/service-registry.ts'
|
|
|
18
18
|
import type { ServiceContainer } from '../../core/service-registry.ts';
|
|
19
19
|
import { ServiceNames } from '../../core/service-interfaces.ts';
|
|
20
20
|
import type { ISchedulerFactory, IScheduler } from '../../core/scheduler-factory.ts';
|
|
21
|
-
import type { ISchedulerInternals } from '../../core/interfaces/scheduler-interfaces.ts';
|
|
21
|
+
import type { ISchedulerInternals, TaskDispatchListener } from '../../core/interfaces/scheduler-interfaces.ts';
|
|
22
22
|
import type { IStateManager } from '../../core/interfaces/state-manager-interfaces.ts';
|
|
23
23
|
import { BrowserClient } from './browser-client.ts';
|
|
24
24
|
|
|
@@ -331,7 +331,7 @@ export async function runBrowserHealthCheck(config?: Config, retries = 1, signal
|
|
|
331
331
|
/**
|
|
332
332
|
* Run a worker search query with retry logic.
|
|
333
333
|
*/
|
|
334
|
-
export async function runWorkerSearch(query: string, config?: Config, signal?: AbortSignal, retries = 1, sessionId?: string, container: ServiceContainer = getServiceContainer(), handoverRetries = LEADER_HANDOVER_RETRIES): Promise<SearchResult[]> {
|
|
334
|
+
export async function runWorkerSearch(query: string, config?: Config, signal?: AbortSignal, retries = 1, sessionId?: string, container: ServiceContainer = getServiceContainer(), handoverRetries = LEADER_HANDOVER_RETRIES, onTaskEvent?: TaskDispatchListener): Promise<SearchResult[]> {
|
|
335
335
|
if (signal?.aborted) throw new Error('Aborted');
|
|
336
336
|
|
|
337
337
|
const breaker = sessionId ? getBrowserCircuitBreaker(sessionId) : browserCircuitBreaker;
|
|
@@ -340,7 +340,7 @@ export async function runWorkerSearch(query: string, config?: Config, signal?: A
|
|
|
340
340
|
return await breaker.execute(async () => {
|
|
341
341
|
if (signal?.aborted) throw new Error('Aborted');
|
|
342
342
|
const scheduler = await getScheduler(config, container);
|
|
343
|
-
return await scheduler.runSearch(query, config, signal);
|
|
343
|
+
return await scheduler.runSearch(query, config, signal, onTaskEvent);
|
|
344
344
|
});
|
|
345
345
|
} catch (error: unknown) {
|
|
346
346
|
if (signal?.aborted || (error instanceof Error && error.message === 'Aborted')) throw new Error('Aborted', { cause: error });
|
|
@@ -358,7 +358,7 @@ export async function runWorkerSearch(query: string, config?: Config, signal?: A
|
|
|
358
358
|
logger.warn(`[BrowserManager] Leader handover during search (handover attempts left: ${handoverRetries}): ${briefErrorMessage(error)}...`);
|
|
359
359
|
await recoverFromLeaderHandover(container);
|
|
360
360
|
await new Promise((resolve) => setTimeout(resolve, HANDOVER_BACKOFF_MS + Math.floor(Math.random() * 400)));
|
|
361
|
-
return runWorkerSearch(query, config, signal, retries, sessionId, container, handoverRetries - 1);
|
|
361
|
+
return runWorkerSearch(query, config, signal, retries, sessionId, container, handoverRetries - 1, onTaskEvent);
|
|
362
362
|
}
|
|
363
363
|
|
|
364
364
|
if (retries > 0 && isTransientSocketError(error) && !isTaskTimeoutError(error) && !isCloudflareBlockError(error)) {
|
|
@@ -381,7 +381,7 @@ export async function runWorkerSearch(query: string, config?: Config, signal?: A
|
|
|
381
381
|
|
|
382
382
|
const jitter = 100 + Math.floor(Math.random() * 400);
|
|
383
383
|
await new Promise(resolve => setTimeout(resolve, jitter));
|
|
384
|
-
return runWorkerSearch(query, config, signal, retries - 1, sessionId, container, handoverRetries);
|
|
384
|
+
return runWorkerSearch(query, config, signal, retries - 1, sessionId, container, handoverRetries, onTaskEvent);
|
|
385
385
|
}
|
|
386
386
|
throw error;
|
|
387
387
|
}
|