@lincoln504/pi-research 1.3.12 → 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 +5495 -4645
- 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 +94 -19
- package/docs/ARCHITECTURE.md +18 -10
- package/docs/CHANGELOG.md +149 -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 +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/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 +41 -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 +32 -2
- package/src/infrastructure/browser/thread-worker-messaging.ts +38 -6
- package/src/infrastructure/browser/thread-worker.mjs +94 -19
- 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/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 +43 -17
- 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 +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/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/tools/research-knowledge-search.ts +11 -3
- 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/metrics-summary.ts +20 -4
- package/src/utils/metrics.ts +27 -1
- package/src/web-research/browser-search.ts +93 -20
- package/dist/prompts/system-lead-evaluator.md +0 -118
- package/src/prompts/system-lead-evaluator.md +0 -118
|
@@ -259,15 +259,42 @@ async function extractSearchResults(page: any): Promise<any[]> {
|
|
|
259
259
|
/**
|
|
260
260
|
* Execute a search task
|
|
261
261
|
*/
|
|
262
|
+
/**
|
|
263
|
+
* Bind a task-timeout signal to the page it is bounding.
|
|
264
|
+
*
|
|
265
|
+
* `page.setDefaultTimeout` bounds Playwright's action and navigation verbs
|
|
266
|
+
* individually; it does not bound their sum, and it does not bound a Response read
|
|
267
|
+
* at all. Closing the page is what actually ends the work: any in-flight call
|
|
268
|
+
* rejects with 'Target closed', which runTask's catch already maps back to the
|
|
269
|
+
* task timeout. Fire-and-forget, `{ once }` so the listener cannot outlive the task.
|
|
270
|
+
*/
|
|
271
|
+
function bindTaskAbortToPage(page: any, signal?: AbortSignal): void {
|
|
272
|
+
if (!signal) return;
|
|
273
|
+
const onAbort = () => { page.close().catch(() => {}); };
|
|
274
|
+
if (signal.aborted) onAbort();
|
|
275
|
+
else signal.addEventListener('abort', onAbort, { once: true });
|
|
276
|
+
}
|
|
277
|
+
|
|
262
278
|
export async function executeSearchTask(
|
|
263
279
|
_context: any,
|
|
264
|
-
query: string
|
|
280
|
+
query: string,
|
|
281
|
+
signal?: AbortSignal
|
|
265
282
|
): Promise<{ results: any[]; jitter: number }> {
|
|
266
283
|
const page = await createPageSafe(_context);
|
|
267
284
|
const SEARCH_TIMEOUT = parseTimeoutMs(process.env['PI_RESEARCH_SEARCH_TIMEOUT_MS'], 45000);
|
|
268
285
|
page.setDefaultTimeout(SEARCH_TIMEOUT);
|
|
269
286
|
page.setDefaultNavigationTimeout(SEARCH_TIMEOUT);
|
|
270
287
|
|
|
288
|
+
// Same binding the scrape path has used all along, and for the same reason: the
|
|
289
|
+
// worker's task deadline was armed but its signal was only ever handed to
|
|
290
|
+
// executeScrapeTask, so on a search it fired into nothing. Playwright's own
|
|
291
|
+
// per-action timeouts still bound each verb, but the sum of them plus the jitter
|
|
292
|
+
// sleep can outlast the whole task budget — and poolifier keeps counting this node
|
|
293
|
+
// as executing until the worker replies, so the node stays pinned long after the
|
|
294
|
+
// caller was answered. Closing the page rejects whatever is in flight with
|
|
295
|
+
// 'Target closed', which runTask's catch already recognises as the timeout.
|
|
296
|
+
bindTaskAbortToPage(page, signal);
|
|
297
|
+
|
|
271
298
|
try {
|
|
272
299
|
logToDebugFile('DEBUG', `[Worker-${workerId}] Starting search for: ${query}`);
|
|
273
300
|
|
|
@@ -692,11 +719,15 @@ const HEALTH_FALLBACK_URL = 'https://example.com/';
|
|
|
692
719
|
async function executeHealthCheckAttempt(
|
|
693
720
|
_context: any,
|
|
694
721
|
navTimeoutMs: number,
|
|
695
|
-
url: string = HEALTH_PRIMARY_URL
|
|
722
|
+
url: string = HEALTH_PRIMARY_URL,
|
|
723
|
+
signal?: AbortSignal
|
|
696
724
|
): Promise<{ success: boolean; navMs: number }> {
|
|
697
725
|
const page = await createPageSafe(_context);
|
|
698
726
|
page.setDefaultTimeout(navTimeoutMs);
|
|
699
727
|
page.setDefaultNavigationTimeout(navTimeoutMs);
|
|
728
|
+
// See executeSearchTask: the probe runs twice (primary then fallback), so its own
|
|
729
|
+
// nav timeout bounds each attempt but not the pair.
|
|
730
|
+
bindTaskAbortToPage(page, signal);
|
|
700
731
|
|
|
701
732
|
try {
|
|
702
733
|
const navStart = Date.now();
|
|
@@ -721,7 +752,8 @@ async function executeHealthCheckAttempt(
|
|
|
721
752
|
*/
|
|
722
753
|
export async function executeHealthCheck(
|
|
723
754
|
_context: any,
|
|
724
|
-
initMs: number
|
|
755
|
+
initMs: number,
|
|
756
|
+
signal?: AbortSignal
|
|
725
757
|
): Promise<{ success: boolean; navMs: number }> {
|
|
726
758
|
// Nav timeout: read from env (passed through getBrowserEnv), floor at 10s.
|
|
727
759
|
// The outer BrowserTaskScheduler.runHealthCheck() has its own 45s hard deadline.
|
|
@@ -733,14 +765,14 @@ export async function executeHealthCheck(
|
|
|
733
765
|
}
|
|
734
766
|
|
|
735
767
|
try {
|
|
736
|
-
return await executeHealthCheckAttempt(_context, HEALTH_TIMEOUT);
|
|
768
|
+
return await executeHealthCheckAttempt(_context, HEALTH_TIMEOUT, HEALTH_PRIMARY_URL, signal);
|
|
737
769
|
} catch (firstError: unknown) {
|
|
738
770
|
// One retry: if the first attempt fails, the page may have been in a bad state
|
|
739
771
|
// (challenge redirect, partial load, transient network blip). A second attempt
|
|
740
772
|
// on a fresh page helps distinguish a real outage from a one-off failure.
|
|
741
773
|
logToDebugFile('WARN', `[Worker-${workerId}] Health check attempt 1 failed: ${firstError instanceof Error ? firstError.message : String(firstError)}. Retrying once...`);
|
|
742
774
|
try {
|
|
743
|
-
return await executeHealthCheckAttempt(_context, HEALTH_TIMEOUT);
|
|
775
|
+
return await executeHealthCheckAttempt(_context, HEALTH_TIMEOUT, HEALTH_PRIMARY_URL, signal);
|
|
744
776
|
} catch (retryError: unknown) {
|
|
745
777
|
const retryMsg = retryError instanceof Error ? retryError.message : String(retryError);
|
|
746
778
|
// A TIMEOUT is left to propagate: upstream (isBusyPoolHealthFailure) already
|
|
@@ -756,7 +788,7 @@ export async function executeHealthCheck(
|
|
|
756
788
|
// healthy enough to research (search has its own retries/fallbacks downstream).
|
|
757
789
|
logToDebugFile('WARN', `[Worker-${workerId}] Search-provider health probe failed twice (${retryMsg}); verifying open-web reachability via fallback endpoint...`);
|
|
758
790
|
try {
|
|
759
|
-
const fallback = await executeHealthCheckAttempt(_context, HEALTH_TIMEOUT, HEALTH_FALLBACK_URL);
|
|
791
|
+
const fallback = await executeHealthCheckAttempt(_context, HEALTH_TIMEOUT, HEALTH_FALLBACK_URL, signal);
|
|
760
792
|
logToDebugFile('WARN', `[Worker-${workerId}] Fallback endpoint reachable — open web is up, search provider is degraded. Proceeding with research.`);
|
|
761
793
|
return fallback;
|
|
762
794
|
} catch (fallbackError: unknown) {
|
|
@@ -1060,6 +1060,17 @@ var init_logger = __esm({
|
|
|
1060
1060
|
timestamp,
|
|
1061
1061
|
level,
|
|
1062
1062
|
...getLogContext(),
|
|
1063
|
+
// Every process writes to the SAME log file — up to three concurrent research
|
|
1064
|
+
// runs plus the elected browser-pool leader — and until this field existed
|
|
1065
|
+
// there was nothing to tell their lines apart. Reconstructing a lock
|
|
1066
|
+
// contention from the log then meant inferring process boundaries from
|
|
1067
|
+
// timing, which is guesswork: two runs a second apart interleave into what
|
|
1068
|
+
// reads like one process contradicting itself. Twelve bytes a line buys
|
|
1069
|
+
// attributable forensics.
|
|
1070
|
+
//
|
|
1071
|
+
// After the context spread, not before: which process wrote a line is not
|
|
1072
|
+
// something a scoped context gets to claim otherwise.
|
|
1073
|
+
pid: process.pid,
|
|
1063
1074
|
message,
|
|
1064
1075
|
...firstError ? { errorMessage: redactSecrets(firstError.message), errorStack: redactSecrets(firstError.stack ?? "") } : {}
|
|
1065
1076
|
};
|
|
@@ -1166,7 +1177,7 @@ var init_metrics = __esm({
|
|
|
1166
1177
|
increment(name, value = 1, labels) {
|
|
1167
1178
|
const key = this.getKey(name, labels);
|
|
1168
1179
|
this.counters.set(key, (this.counters.get(key) ?? 0) + value);
|
|
1169
|
-
if (name
|
|
1180
|
+
if (name.startsWith("llm_")) {
|
|
1170
1181
|
logger.debug(`[Metrics] Incremented ${name} by ${value}${labels ? ` (labels: ${JSON.stringify(labels)})` : ""}`);
|
|
1171
1182
|
}
|
|
1172
1183
|
}
|
|
@@ -1270,6 +1281,28 @@ var init_metrics = __esm({
|
|
|
1270
1281
|
async measure(name, action, labels) {
|
|
1271
1282
|
return this.getActive().measure(name, action, labels);
|
|
1272
1283
|
}
|
|
1284
|
+
/**
|
|
1285
|
+
* Emit API that ALWAYS targets the session registry, ignoring any active run
|
|
1286
|
+
* context.
|
|
1287
|
+
*
|
|
1288
|
+
* For process-lifetime callbacks that happen to be *registered* during a run.
|
|
1289
|
+
* AsyncLocalStorage resolves at emit time from the async resource that fired,
|
|
1290
|
+
* and for a listener installed on a long-lived emitter that resource is the
|
|
1291
|
+
* emitter itself — created, for every lazily-initialized subsystem here, inside
|
|
1292
|
+
* whichever run first needed it. So a worker-pool error handler installed during
|
|
1293
|
+
* run 1 keeps writing into run 1's registry for the rest of the process: those
|
|
1294
|
+
* counts disappear from every later run's summary (the registry was discarded
|
|
1295
|
+
* when the run ended) and the dead registry stays reachable through the closure.
|
|
1296
|
+
*
|
|
1297
|
+
* Use this only where the emission is genuinely process-scoped. Anything that
|
|
1298
|
+
* describes work a run asked for belongs in the run registry, which is what the
|
|
1299
|
+
* plain emit methods above give you.
|
|
1300
|
+
*/
|
|
1301
|
+
session = {
|
|
1302
|
+
increment: (name, value = 1, labels) => this._session.increment(name, value, labels),
|
|
1303
|
+
setGauge: (name, value, labels) => this._session.setGauge(name, value, labels),
|
|
1304
|
+
observe: (name, value, labels) => this._session.observe(name, value, labels)
|
|
1305
|
+
};
|
|
1273
1306
|
// ── Session-level read API ─────────────────────────────────────────────
|
|
1274
1307
|
/** Snapshot of the session registry (infrastructure / cross-run metrics). */
|
|
1275
1308
|
getSessionSnapshot() {
|
|
@@ -2170,11 +2203,21 @@ async function extractSearchResults(page) {
|
|
|
2170
2203
|
return found;
|
|
2171
2204
|
});
|
|
2172
2205
|
}
|
|
2173
|
-
|
|
2206
|
+
function bindTaskAbortToPage(page, signal) {
|
|
2207
|
+
if (!signal) return;
|
|
2208
|
+
const onAbort = () => {
|
|
2209
|
+
page.close().catch(() => {
|
|
2210
|
+
});
|
|
2211
|
+
};
|
|
2212
|
+
if (signal.aborted) onAbort();
|
|
2213
|
+
else signal.addEventListener("abort", onAbort, { once: true });
|
|
2214
|
+
}
|
|
2215
|
+
async function executeSearchTask(_context, query, signal) {
|
|
2174
2216
|
const page = await createPageSafe(_context);
|
|
2175
2217
|
const SEARCH_TIMEOUT = parseTimeoutMs(process.env["PI_RESEARCH_SEARCH_TIMEOUT_MS"], 45e3);
|
|
2176
2218
|
page.setDefaultTimeout(SEARCH_TIMEOUT);
|
|
2177
2219
|
page.setDefaultNavigationTimeout(SEARCH_TIMEOUT);
|
|
2220
|
+
bindTaskAbortToPage(page, signal);
|
|
2178
2221
|
try {
|
|
2179
2222
|
logToDebugFile2("DEBUG", `[Worker-${workerId2}] Starting search for: ${query}`);
|
|
2180
2223
|
if (process.env["PI_RESEARCH_MOCK_SEARCH"] === "true") {
|
|
@@ -2387,10 +2430,11 @@ async function executeScrapeTask(_context, url, signal) {
|
|
|
2387
2430
|
}
|
|
2388
2431
|
var HEALTH_PRIMARY_URL = "https://lite.duckduckgo.com/lite/";
|
|
2389
2432
|
var HEALTH_FALLBACK_URL = "https://example.com/";
|
|
2390
|
-
async function executeHealthCheckAttempt(_context, navTimeoutMs, url = HEALTH_PRIMARY_URL) {
|
|
2433
|
+
async function executeHealthCheckAttempt(_context, navTimeoutMs, url = HEALTH_PRIMARY_URL, signal) {
|
|
2391
2434
|
const page = await createPageSafe(_context);
|
|
2392
2435
|
page.setDefaultTimeout(navTimeoutMs);
|
|
2393
2436
|
page.setDefaultNavigationTimeout(navTimeoutMs);
|
|
2437
|
+
bindTaskAbortToPage(page, signal);
|
|
2394
2438
|
try {
|
|
2395
2439
|
const navStart = Date.now();
|
|
2396
2440
|
await page.goto(url, { waitUntil: "domcontentloaded" });
|
|
@@ -2406,25 +2450,25 @@ async function executeHealthCheckAttempt(_context, navTimeoutMs, url = HEALTH_PR
|
|
|
2406
2450
|
throw error;
|
|
2407
2451
|
}
|
|
2408
2452
|
}
|
|
2409
|
-
async function executeHealthCheck(_context, initMs) {
|
|
2453
|
+
async function executeHealthCheck(_context, initMs, signal) {
|
|
2410
2454
|
const configuredMs = parseTimeoutMs(process.env["PI_RESEARCH_HEALTH_CHECK_TIMEOUT_MS"], 0);
|
|
2411
2455
|
const HEALTH_TIMEOUT = configuredMs > 0 ? Math.max(1e4, configuredMs) : 1e4;
|
|
2412
2456
|
if (initMs > 3e3) {
|
|
2413
2457
|
logToDebugFile2("WARN", `[Worker-${workerId2}] Browser init was slow: ${initMs}ms \u2014 leaving less headroom for DDG navigation.`);
|
|
2414
2458
|
}
|
|
2415
2459
|
try {
|
|
2416
|
-
return await executeHealthCheckAttempt(_context, HEALTH_TIMEOUT);
|
|
2460
|
+
return await executeHealthCheckAttempt(_context, HEALTH_TIMEOUT, HEALTH_PRIMARY_URL, signal);
|
|
2417
2461
|
} catch (firstError) {
|
|
2418
2462
|
logToDebugFile2("WARN", `[Worker-${workerId2}] Health check attempt 1 failed: ${firstError instanceof Error ? firstError.message : String(firstError)}. Retrying once...`);
|
|
2419
2463
|
try {
|
|
2420
|
-
return await executeHealthCheckAttempt(_context, HEALTH_TIMEOUT);
|
|
2464
|
+
return await executeHealthCheckAttempt(_context, HEALTH_TIMEOUT, HEALTH_PRIMARY_URL, signal);
|
|
2421
2465
|
} catch (retryError) {
|
|
2422
2466
|
const retryMsg = retryError instanceof Error ? retryError.message : String(retryError);
|
|
2423
2467
|
const isTimeout = /tim(e|ed)\s*out|timeout/i.test(retryMsg);
|
|
2424
2468
|
if (!isTimeout) {
|
|
2425
2469
|
logToDebugFile2("WARN", `[Worker-${workerId2}] Search-provider health probe failed twice (${retryMsg}); verifying open-web reachability via fallback endpoint...`);
|
|
2426
2470
|
try {
|
|
2427
|
-
const fallback = await executeHealthCheckAttempt(_context, HEALTH_TIMEOUT, HEALTH_FALLBACK_URL);
|
|
2471
|
+
const fallback = await executeHealthCheckAttempt(_context, HEALTH_TIMEOUT, HEALTH_FALLBACK_URL, signal);
|
|
2428
2472
|
logToDebugFile2("WARN", `[Worker-${workerId2}] Fallback endpoint reachable \u2014 open web is up, search provider is degraded. Proceeding with research.`);
|
|
2429
2473
|
return fallback;
|
|
2430
2474
|
} catch (fallbackError) {
|
|
@@ -2528,7 +2572,7 @@ var ConfigSchema = Type.Object({
|
|
|
2528
2572
|
* never killed before it has used its full nav budget plus time waiting in the queue.
|
|
2529
2573
|
* (default: 10000) */
|
|
2530
2574
|
BROWSER_TASK_TIMEOUT_MS: Type.Number({ minimum: 2e3, maximum: 12e4, default: 1e4 }),
|
|
2531
|
-
/** Timeout for coordinator/
|
|
2575
|
+
/** Timeout for coordinator/research-lead/repair/knowledge LLM calls in ms (default: 300000 = 5 min, range: 60s-30min).
|
|
2532
2576
|
* Not exposed in TUI — controlled via PI_RESEARCH_LLM_TIMEOUT_MS env var.
|
|
2533
2577
|
* Ceiling raised to 30min (matching RESEARCHER_TIMEOUT_MS): a slow model on a
|
|
2534
2578
|
* large-context synthesis/plan call can legitimately run >10min, and the old
|
|
@@ -2536,7 +2580,7 @@ var ConfigSchema = Type.Object({
|
|
|
2536
2580
|
* (e.g. 900000), causing those calls to time out mid-generation. */
|
|
2537
2581
|
LLM_TIMEOUT_MS: Type.Number({ minimum: 6e4, maximum: 18e5, default: 3e5 }),
|
|
2538
2582
|
/** Chain-of-thought "thinking" level for the engine's own LLM calls (coordinator,
|
|
2539
|
-
*
|
|
2583
|
+
* router, synthesizer, JSON-repair, knowledge extraction) AND the researcher
|
|
2540
2584
|
* sub-agents. Default 'off': these calls emit structured JSON / cited reports, not
|
|
2541
2585
|
* open-ended reasoning, so thinking only burns the output-token budget (often
|
|
2542
2586
|
* truncating before the JSON/report block) and adds latency for at most a marginal
|
|
@@ -2551,22 +2595,26 @@ var ConfigSchema = Type.Object({
|
|
|
2551
2595
|
Type.Literal("medium"),
|
|
2552
2596
|
Type.Literal("high")
|
|
2553
2597
|
], { default: "off" }),
|
|
2554
|
-
/** Max output tokens for the coordinator plan
|
|
2555
|
-
*
|
|
2598
|
+
/** Max output tokens for the coordinator's round-1 plan. Default 16384. The routing
|
|
2599
|
+
* decision has its OWN, much smaller ceiling (it emits a decision, never a report,
|
|
2600
|
+
* and cannot synthesize mid-round under the split protocol); the final report uses
|
|
2601
|
+
* SYNTHESIS_MAX_TOKENS below. The blanket
|
|
2556
2602
|
* 4096 cap previously throttled every call and could be exhausted by a thinking block
|
|
2557
2603
|
* before any text was emitted. Capped at the model's real ceiling at call time.
|
|
2558
2604
|
* Not exposed in TUI — controlled via PI_RESEARCH_PLANNING_MAX_TOKENS env var. */
|
|
2559
2605
|
PLANNING_MAX_TOKENS: Type.Number({ minimum: 1024, maximum: 131072, default: 16384 }),
|
|
2560
|
-
/** Max output tokens for the final synthesized research report (the
|
|
2561
|
-
*
|
|
2606
|
+
/** Max output tokens for the final synthesized research report (the synthesizer call,
|
|
2607
|
+
* and each pass of a reduce when the corpus does not fit in one). Default 32768 so a
|
|
2608
|
+
* full cited report is never truncated. It is also the reserve subtracted from the
|
|
2609
|
+
* model's context window when budgeting that corpus. Capped at
|
|
2562
2610
|
* the model's real maxTokens at call time.
|
|
2563
2611
|
* Not exposed in TUI — controlled via PI_RESEARCH_SYNTHESIS_MAX_TOKENS env var. */
|
|
2564
2612
|
SYNTHESIS_MAX_TOKENS: Type.Number({ minimum: 1024, maximum: 131072, default: 32768 }),
|
|
2565
2613
|
/** LLM Model override for researcher sub-agents and knowledge synthesis.
|
|
2566
2614
|
* Format: provider/model-id (e.g. google/gemini-2.0-flash-001) or just model-id.
|
|
2567
2615
|
* When set, this overrides ctx.model for researcher sub-agents (both deep and quick)
|
|
2568
|
-
* and the knowledge synthesis background LLM. The coordinator and
|
|
2569
|
-
* use the caller's model (ctx.model).
|
|
2616
|
+
* and the knowledge synthesis background LLM. The coordinator and the research lead
|
|
2617
|
+
* always use the caller's model (ctx.model).
|
|
2570
2618
|
*/
|
|
2571
2619
|
RESEARCH_MODEL: Type.Optional(Type.String()),
|
|
2572
2620
|
/** Explicit directory for the knowledge store database (overrides default) */
|
|
@@ -2620,6 +2668,7 @@ function resolveHeadlessMode() {
|
|
|
2620
2668
|
if (process.env["PI_RESEARCH_USE_XVFB"] === "true") return "virtual";
|
|
2621
2669
|
return true;
|
|
2622
2670
|
}
|
|
2671
|
+
var HEALTHCHECK_MIN_BUDGET_MS = 45e3 + 6e4;
|
|
2623
2672
|
|
|
2624
2673
|
// src/infrastructure/browser/thread-worker-browser.ts
|
|
2625
2674
|
var browser = null;
|
|
@@ -2734,11 +2783,24 @@ async function initBrowser() {
|
|
|
2734
2783
|
});
|
|
2735
2784
|
launchPromise.catch((err) => logToDebugFile3("DEBUG", `[ThreadWorker] Background browser launch rejection: ${err.message}`));
|
|
2736
2785
|
let launchTimeoutId;
|
|
2786
|
+
let raceLost = false;
|
|
2787
|
+
launchPromise.then((b) => {
|
|
2788
|
+
if (!raceLost) return;
|
|
2789
|
+
logToDebugFile3("WARN", `[Worker-${workerId3}] Browser launch completed after its deadline; closing the orphaned instance.`);
|
|
2790
|
+
try {
|
|
2791
|
+
b?.close?.();
|
|
2792
|
+
} catch {
|
|
2793
|
+
}
|
|
2794
|
+
}).catch(() => {
|
|
2795
|
+
});
|
|
2737
2796
|
try {
|
|
2738
2797
|
return await Promise.race([
|
|
2739
2798
|
launchPromise,
|
|
2740
2799
|
new Promise((_, reject) => {
|
|
2741
|
-
launchTimeoutId = setTimeout(() =>
|
|
2800
|
+
launchTimeoutId = setTimeout(() => {
|
|
2801
|
+
raceLost = true;
|
|
2802
|
+
reject(new Error(`Browser launch timed out after ${launchTimeoutMs}ms`));
|
|
2803
|
+
}, launchTimeoutMs);
|
|
2742
2804
|
})
|
|
2743
2805
|
]);
|
|
2744
2806
|
} finally {
|
|
@@ -2763,10 +2825,23 @@ async function initBrowser() {
|
|
|
2763
2825
|
const contextPromise = browser.newContext();
|
|
2764
2826
|
contextPromise.catch((err) => logToDebugFile3("DEBUG", `[ThreadWorker] Background browser context rejection: ${err.message}`));
|
|
2765
2827
|
let contextTimeoutId;
|
|
2828
|
+
let contextRaceLost = false;
|
|
2829
|
+
contextPromise.then((c) => {
|
|
2830
|
+
if (!contextRaceLost) return;
|
|
2831
|
+
logToDebugFile3("WARN", `[Worker-${workerId3}] Browser context creation completed after its deadline; closing the orphaned context.`);
|
|
2832
|
+
try {
|
|
2833
|
+
c?.close?.();
|
|
2834
|
+
} catch {
|
|
2835
|
+
}
|
|
2836
|
+
}).catch(() => {
|
|
2837
|
+
});
|
|
2766
2838
|
context = await Promise.race([
|
|
2767
2839
|
contextPromise,
|
|
2768
2840
|
new Promise((_, reject) => {
|
|
2769
|
-
contextTimeoutId = setTimeout(() =>
|
|
2841
|
+
contextTimeoutId = setTimeout(() => {
|
|
2842
|
+
contextRaceLost = true;
|
|
2843
|
+
reject(new Error("Browser context creation timed out after 30000ms"));
|
|
2844
|
+
}, 3e4);
|
|
2770
2845
|
})
|
|
2771
2846
|
]);
|
|
2772
2847
|
if (contextTimeoutId) clearTimeout(contextTimeoutId);
|
|
@@ -2882,14 +2957,14 @@ async function runTask(data) {
|
|
|
2882
2957
|
let result;
|
|
2883
2958
|
if (type === "search") {
|
|
2884
2959
|
if (!query) throw new Error("Search task requires a query");
|
|
2885
|
-
const searchResult = await executeSearchTask(getContext(), query);
|
|
2960
|
+
const searchResult = await executeSearchTask(getContext(), query, abortController.signal);
|
|
2886
2961
|
result = { results: searchResult.results, duration: Date.now() - startTime, jitter: searchResult.jitter };
|
|
2887
2962
|
} else if (type === "scrape") {
|
|
2888
2963
|
if (!url) throw new Error("Scrape task requires a URL");
|
|
2889
2964
|
const scrapeResult = await executeScrapeTask(getContext(), url, abortController.signal);
|
|
2890
2965
|
result = { ...scrapeResult, duration: Date.now() - startTime };
|
|
2891
2966
|
} else if (type === "healthcheck") {
|
|
2892
|
-
const healthResult = await executeHealthCheck(getContext(), initMs);
|
|
2967
|
+
const healthResult = await executeHealthCheck(getContext(), initMs, abortController.signal);
|
|
2893
2968
|
result = { ...healthResult, duration: Date.now() - startTime };
|
|
2894
2969
|
} else {
|
|
2895
2970
|
result = { error: "Unknown task type", duration: Date.now() - startTime };
|
|
@@ -116,14 +116,14 @@ async function runTask(data: TaskData | undefined): Promise<TaskResult> {
|
|
|
116
116
|
let result: any;
|
|
117
117
|
if (type === 'search') {
|
|
118
118
|
if (!query) throw new Error('Search task requires a query');
|
|
119
|
-
const searchResult = await executeSearchTask(getContext(), query);
|
|
119
|
+
const searchResult = await executeSearchTask(getContext(), query, abortController.signal);
|
|
120
120
|
result = { results: searchResult.results, duration: Date.now() - startTime, jitter: searchResult.jitter };
|
|
121
121
|
} else if (type === 'scrape') {
|
|
122
122
|
if (!url) throw new Error('Scrape task requires a URL');
|
|
123
123
|
const scrapeResult = await executeScrapeTask(getContext(), url, abortController.signal);
|
|
124
124
|
result = { ...scrapeResult, duration: Date.now() - startTime };
|
|
125
125
|
} else if (type === 'healthcheck') {
|
|
126
|
-
const healthResult = await executeHealthCheck(getContext(), initMs);
|
|
126
|
+
const healthResult = await executeHealthCheck(getContext(), initMs, abortController.signal);
|
|
127
127
|
result = { ...healthResult, duration: Date.now() - startTime };
|
|
128
128
|
} else {
|
|
129
129
|
result = { error: 'Unknown task type', duration: Date.now() - startTime };
|
|
@@ -28,6 +28,55 @@ const __dirname = dirname(__filename);
|
|
|
28
28
|
/**
|
|
29
29
|
* Worker pool manager for browser operations.
|
|
30
30
|
*/
|
|
31
|
+
/**
|
|
32
|
+
* The pool's dispatch shape: how a worker is chosen, and how much each may hold.
|
|
33
|
+
*
|
|
34
|
+
* Separated out so it is testable. The claim this configuration makes is behavioural
|
|
35
|
+
* — that no task is parked behind a busy worker while another idles — and the first
|
|
36
|
+
* test written for it asserted that this file contained the string
|
|
37
|
+
* "WorkerChoiceStrategies.LEAST_USED", which passes against an implementation that
|
|
38
|
+
* dispatches nothing.
|
|
39
|
+
*
|
|
40
|
+
* LEAST_USED picks the node minimising executing + queued. ROUND_ROBIN is a bare
|
|
41
|
+
* modular cursor whose only filter is a one-time `ready` flag — it cannot see load,
|
|
42
|
+
* and poolifier consults per-node capacity only AFTER the strategy has chosen. A task
|
|
43
|
+
* handed to a busy node is queued ON that node and never re-routed, so one degraded
|
|
44
|
+
* worker takes a share of every burst with it: on 2026-08-15 a worker whose browser
|
|
45
|
+
* launch hung was still handed roughly one turn in six, and the run lost exactly the
|
|
46
|
+
* three queries that landed on it while five workers sat idle.
|
|
47
|
+
*
|
|
48
|
+
* The guarantee is conditional, not absolute, and worth stating precisely: a
|
|
49
|
+
* load-reading strategy never parks a task WHILE A FREE NODE EXISTS. The
|
|
50
|
+
* PriorityTaskQueue above normally makes sure one does, by capping in-flight work at
|
|
51
|
+
* exactly WORKER_THREADS x WORKER_CONCURRENCY — which is why a WORKER_CONCURRENCY
|
|
52
|
+
* change has to rebuild the pool, or the two layers disagree about capacity. But that
|
|
53
|
+
* cap is released early when a caller aborts a task still running on a worker, and a
|
|
54
|
+
* respawning worker is not `ready`, so there are windows where every node is genuinely
|
|
55
|
+
* full. In those, ties resolve to the lowest-index node and a task does park.
|
|
56
|
+
*
|
|
57
|
+
* Which is also why poolifier's stolen-task accounting defect is made rare rather than
|
|
58
|
+
* unreachable: stealing fires only when something is queued on a node, and a stolen
|
|
59
|
+
* task's completion decrements the ORIGINAL node's executing count instead of the one
|
|
60
|
+
* that ran it, freeing a slot on a worker that is still working. That produced 1,425
|
|
61
|
+
* genuinely overlapping task pairs across 37 days at a configured concurrency of 1.
|
|
62
|
+
*
|
|
63
|
+
* LEAST_BUSY was the other candidate and is worse here: it minimises accumulated
|
|
64
|
+
* run+wait time, which turns on per-task performance measurement for every task and
|
|
65
|
+
* still lets a busy fast worker outrank an idle slow one. LEAST_USED overrides no
|
|
66
|
+
* statistics requirements, so it costs nothing extra.
|
|
67
|
+
*/
|
|
68
|
+
export function buildWorkerPoolOptions(config: Config): {
|
|
69
|
+
workerChoiceStrategy: typeof WorkerChoiceStrategies[keyof typeof WorkerChoiceStrategies];
|
|
70
|
+
enableTasksQueue: boolean;
|
|
71
|
+
tasksQueueOptions: { concurrency: number };
|
|
72
|
+
} {
|
|
73
|
+
return {
|
|
74
|
+
workerChoiceStrategy: WorkerChoiceStrategies.LEAST_USED,
|
|
75
|
+
enableTasksQueue: true,
|
|
76
|
+
tasksQueueOptions: { concurrency: config.WORKER_CONCURRENCY },
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
31
80
|
export class WorkerPoolManager implements IService {
|
|
32
81
|
readonly name = ServiceNames.WORKER_POOL_MANAGER;
|
|
33
82
|
lifecycle = ServiceLifecycle.UNINITIALIZED;
|
|
@@ -35,6 +84,12 @@ export class WorkerPoolManager implements IService {
|
|
|
35
84
|
private pool: any | null = null;
|
|
36
85
|
private poolInitializationPromise: Promise<any> | null = null;
|
|
37
86
|
private currentWorkerCount: number | null = null;
|
|
87
|
+
/** Per-worker task concurrency the LIVE pool was built with. Baked into
|
|
88
|
+
* poolifier's tasksQueueOptions at construction and not changeable after, so a
|
|
89
|
+
* change has to recreate the pool — the upper PriorityTaskQueue recomputes its
|
|
90
|
+
* own capacity from the same two values on every task, and the two disagreeing
|
|
91
|
+
* is what lets a task be queued on a worker that the queue believes is free. */
|
|
92
|
+
private currentWorkerConcurrency: number | null = null;
|
|
38
93
|
private consecutiveErrors: number = 0;
|
|
39
94
|
private isShuttingDown: boolean = false;
|
|
40
95
|
// Monotonic shutdown generation. Each shutdown() bumps it, so an in-flight
|
|
@@ -95,7 +150,8 @@ export class WorkerPoolManager implements IService {
|
|
|
95
150
|
|
|
96
151
|
// If pool exists, worker count matches, and we are not shutting down,
|
|
97
152
|
// return the existing pool immediately.
|
|
98
|
-
|
|
153
|
+
const workerConcurrency = (config || getConfig()).WORKER_CONCURRENCY;
|
|
154
|
+
if (this.pool && this.currentWorkerCount === maxWorkers && this.currentWorkerConcurrency === workerConcurrency) {
|
|
99
155
|
return this.pool;
|
|
100
156
|
}
|
|
101
157
|
|
|
@@ -110,14 +166,15 @@ export class WorkerPoolManager implements IService {
|
|
|
110
166
|
const myGeneration = this.generation;
|
|
111
167
|
this.poolInitializationPromise = (async () => {
|
|
112
168
|
try {
|
|
113
|
-
if (this.pool && this.currentWorkerCount !== maxWorkers) {
|
|
114
|
-
logger.log(`[WorkerPoolManager]
|
|
169
|
+
if (this.pool && (this.currentWorkerCount !== maxWorkers || this.currentWorkerConcurrency !== workerConcurrency)) {
|
|
170
|
+
logger.log(`[WorkerPoolManager] Pool shape changed (workers ${this.currentWorkerCount} -> ${maxWorkers}, concurrency ${this.currentWorkerConcurrency} -> ${workerConcurrency}), recreating pool...`);
|
|
115
171
|
this.poolEpoch++; // intentional destroy: silence the dying pool's exit events
|
|
116
172
|
await this.pool.destroy();
|
|
117
173
|
this.pool = null;
|
|
118
174
|
}
|
|
119
175
|
|
|
120
176
|
this.currentWorkerCount = maxWorkers;
|
|
177
|
+
this.currentWorkerConcurrency = workerConcurrency;
|
|
121
178
|
|
|
122
179
|
// Install the master-side IPC guard BEFORE any worker is forked, so a
|
|
123
180
|
// `write EPIPE` on a closing worker channel (poolifier dispatch racing a
|
|
@@ -161,7 +218,6 @@ export class WorkerPoolManager implements IService {
|
|
|
161
218
|
void cleanupOrphanedCamoufoxProcesses()
|
|
162
219
|
.catch((e) => logger.debug('[WorkerPoolManager] Startup orphan sweep failed:', e));
|
|
163
220
|
|
|
164
|
-
const workerConcurrency = (config || getConfig()).WORKER_CONCURRENCY;
|
|
165
221
|
|
|
166
222
|
// thread-worker.mjs is the esbuild-compiled bundle of thread-worker.ts and its
|
|
167
223
|
// local imports. Using a pre-compiled JS file eliminates any TypeScript loading
|
|
@@ -205,10 +261,15 @@ export class WorkerPoolManager implements IService {
|
|
|
205
261
|
return;
|
|
206
262
|
}
|
|
207
263
|
this.consecutiveErrors++;
|
|
208
|
-
|
|
264
|
+
// Session-scoped on purpose. This handler is installed on the pool,
|
|
265
|
+
// and the pool is built lazily inside whichever research run first
|
|
266
|
+
// needed a browser — so the run-registry context every later
|
|
267
|
+
// invocation inherits is that FIRST run's, long after it was
|
|
268
|
+
// discarded. Pool health is process state, not run accounting.
|
|
269
|
+
metrics.session.increment('browser_pool_errors_total', 1);
|
|
209
270
|
logger.error('[WorkerPoolManager] Cluster Error:', e);
|
|
210
271
|
if (this.consecutiveErrors >= 3) {
|
|
211
|
-
metrics.increment('browser_pool_unhealthy_events_total', 1);
|
|
272
|
+
metrics.session.increment('browser_pool_unhealthy_events_total', 1);
|
|
212
273
|
logger.error(`[WorkerPoolManager] Worker pool may be unhealthy: ${this.consecutiveErrors} consecutive errors. Consider restarting.`);
|
|
213
274
|
if (this.onPoolError) {
|
|
214
275
|
this.onPoolError(e, this.consecutiveErrors);
|
|
@@ -235,11 +296,7 @@ export class WorkerPoolManager implements IService {
|
|
|
235
296
|
}
|
|
236
297
|
}
|
|
237
298
|
},
|
|
238
|
-
|
|
239
|
-
enableTasksQueue: true,
|
|
240
|
-
tasksQueueOptions: {
|
|
241
|
-
concurrency: workerConcurrency, // configurable via PI_RESEARCH_WORKER_CONCURRENCY
|
|
242
|
-
}
|
|
299
|
+
...buildWorkerPoolOptions(config || getConfig()),
|
|
243
300
|
});
|
|
244
301
|
|
|
245
302
|
// Secondary race check: if a shutdown() was called concurrent with
|
|
@@ -315,6 +372,60 @@ export class WorkerPoolManager implements IService {
|
|
|
315
372
|
if (this.consecutiveErrors > 0) this.consecutiveErrors--;
|
|
316
373
|
}
|
|
317
374
|
|
|
375
|
+
/**
|
|
376
|
+
* Record how the pool is actually distributing work.
|
|
377
|
+
*
|
|
378
|
+
* The head-of-line failure this pool used to have was invisible to every metric
|
|
379
|
+
* and every log line: tasks parked on one node while other nodes idled, and the
|
|
380
|
+
* only trace was a PriorityTaskQueue reporting free slots and an empty backlog
|
|
381
|
+
* while queries timed out. poolifier computes all of it on `pool.info` and
|
|
382
|
+
* nothing read it.
|
|
383
|
+
*
|
|
384
|
+
* `browser_pool_parked_tasks` is the number that should stay at zero: a task
|
|
385
|
+
* queued on a worker node while some other node is idle. Non-zero means the
|
|
386
|
+
* choice strategy handed work to a busy node when a free one existed — which is
|
|
387
|
+
* also the precondition for poolifier's task stealing, and therefore for its
|
|
388
|
+
* stolen-task accounting defect. `stolenTasks` is recorded for the same reason.
|
|
389
|
+
*
|
|
390
|
+
* Two limits, stated because a gauge that reads zero is otherwise taken as proof:
|
|
391
|
+
* `idleWorkerNodes` counts nodes that are READY and free, so during a respawn
|
|
392
|
+
* window a genuinely parked task is attributed to legitimate saturation; and with
|
|
393
|
+
* every node busy the figure is zero by construction, which is correct but is not
|
|
394
|
+
* evidence the strategy is choosing well. `browser_pool_idle_workers` is exported
|
|
395
|
+
* alongside so the denominator is visible rather than inferred.
|
|
396
|
+
*
|
|
397
|
+
* With no pool the gauges are ZEROED rather than left alone. Returning early held
|
|
398
|
+
* the last live reading forever — so a burst that ended with tasks parked left a
|
|
399
|
+
* standing non-zero gauge for a pool that no longer exists, and every later read
|
|
400
|
+
* of it was a report about a dead process.
|
|
401
|
+
*
|
|
402
|
+
* Session-scoped, for the same reason as the pool's error counters above. The only
|
|
403
|
+
* caller is the leader's 5s leadership tick — a self-rescheduling timer chain
|
|
404
|
+
* started when this process won the election, i.e. inside whichever research run
|
|
405
|
+
* first needed a browser. AsyncLocalStorage resolves at emit time from the async
|
|
406
|
+
* resource that fired, so a plain `metrics.setGauge` here would write into that
|
|
407
|
+
* FIRST run's registry for the life of the process: invisible to every later run's
|
|
408
|
+
* summary, and holding a discarded registry reachable through the timer. The pool's
|
|
409
|
+
* dispatch shape is process state, not run accounting.
|
|
410
|
+
*/
|
|
411
|
+
recordDispatchHealth(): void {
|
|
412
|
+
const info = (this.pool as unknown as { info?: Record<string, number> } | null)?.info;
|
|
413
|
+
if (!info) {
|
|
414
|
+
metrics.session.setGauge('browser_pool_parked_tasks', 0);
|
|
415
|
+
metrics.session.setGauge('browser_pool_queued_tasks', 0);
|
|
416
|
+
metrics.session.setGauge('browser_pool_idle_workers', 0);
|
|
417
|
+
return;
|
|
418
|
+
}
|
|
419
|
+
const queued = info['queuedTasks'] ?? 0;
|
|
420
|
+
const idle = info['idleWorkerNodes'] ?? 0;
|
|
421
|
+
metrics.session.setGauge('browser_pool_parked_tasks', idle > 0 ? queued : 0);
|
|
422
|
+
metrics.session.setGauge('browser_pool_queued_tasks', queued);
|
|
423
|
+
metrics.session.setGauge('browser_pool_idle_workers', idle);
|
|
424
|
+
// Cumulative, not a level: never zeroed on teardown, or a restart would read
|
|
425
|
+
// as the defect having been fixed.
|
|
426
|
+
metrics.session.setGauge('browser_pool_stolen_tasks', info['stolenTasks'] ?? 0);
|
|
427
|
+
}
|
|
428
|
+
|
|
318
429
|
/**
|
|
319
430
|
* Schedule an out-of-band pool reset. Called from poolifier event handlers
|
|
320
431
|
* where destroying the pool synchronously would deadlock. Waits 1 s so the
|
|
@@ -334,12 +445,15 @@ export class WorkerPoolManager implements IService {
|
|
|
334
445
|
// is still being destroyed. Instead, use a flag to signal reset is in progress.
|
|
335
446
|
this._resetInProgress = true;
|
|
336
447
|
this.currentWorkerCount = null;
|
|
448
|
+
this.currentWorkerConcurrency = null;
|
|
337
449
|
this.consecutiveErrors = 0;
|
|
338
450
|
// Pin the generation this reset belongs to. shutdown() bumps generation, so if a
|
|
339
451
|
// shutdown (and a subsequent re-init) happens while this timer is pending, the
|
|
340
452
|
// generation no longer matches and the timer must NOT touch the new pool's state.
|
|
341
453
|
const myGen = this.generation;
|
|
342
|
-
|
|
454
|
+
// Session-scoped: every caller of schedulePoolReset is one of the pool's own
|
|
455
|
+
// lifecycle handlers, which carry the async context of pool construction.
|
|
456
|
+
metrics.session.increment('browser_pool_auto_recoveries_total', 1);
|
|
343
457
|
logger.info('[WorkerPoolManager] Pool scheduled for auto-recovery; next ensurePool() will wait for old pool destruction.');
|
|
344
458
|
// Destroy the old pool asynchronously after the event handler returns.
|
|
345
459
|
const t = setTimeout(async () => {
|
|
@@ -427,6 +541,7 @@ export class WorkerPoolManager implements IService {
|
|
|
427
541
|
|
|
428
542
|
this.poolInitializationPromise = null;
|
|
429
543
|
this.currentWorkerCount = null;
|
|
544
|
+
this.currentWorkerConcurrency = null;
|
|
430
545
|
this.consecutiveErrors = 0;
|
|
431
546
|
// Clear any in-flight auto-recovery flag too: if a schedulePoolReset() timer
|
|
432
547
|
// was pending when shutdown ran, leaving this true makes the next ensurePool()
|
|
@@ -72,8 +72,19 @@ export class EmbeddingClient implements IEmbedder {
|
|
|
72
72
|
|
|
73
73
|
// ---- Health ----
|
|
74
74
|
|
|
75
|
-
|
|
76
|
-
|
|
75
|
+
/**
|
|
76
|
+
* `timeoutMs` defaults to the general-purpose 120s used for real embed requests, but the
|
|
77
|
+
* embedding-leader poll loop (embedding-factory.ts) needs a much shorter deadline: it uses
|
|
78
|
+
* a failed health check as one "unreachable poll" toward declaring a leader's registration
|
|
79
|
+
* stale, on the documented assumption that each poll costs "the poll interval plus up to
|
|
80
|
+
* the [probe] timeout" (~2.5s). At the 120s default that assumption is off by 48x — a
|
|
81
|
+
* leader whose event loop is merely busy (not dead) can take the full 120s to answer, so
|
|
82
|
+
* the poll loop's outer wait times out and retries long before its own unreachable-poll
|
|
83
|
+
* counter would ever fire. Callers that need the fast-fail poll semantics must pass an
|
|
84
|
+
* explicit short timeout.
|
|
85
|
+
*/
|
|
86
|
+
async fetchHealth(timeoutMs?: number): Promise<void> {
|
|
87
|
+
const result = await this.getRequest<{ status: string; device: string; dimension: number | null }>('/health', timeoutMs);
|
|
77
88
|
this._device = result.device;
|
|
78
89
|
if (result.dimension !== null) {
|
|
79
90
|
this.dimension = result.dimension;
|
|
@@ -82,9 +93,8 @@ export class EmbeddingClient implements IEmbedder {
|
|
|
82
93
|
|
|
83
94
|
// ---- Internal HTTP helpers ----
|
|
84
95
|
|
|
85
|
-
private getRequest<T>(path: string): Promise<T> {
|
|
96
|
+
private getRequest<T>(path: string, timeoutMs = 120_000): Promise<T> {
|
|
86
97
|
return new Promise((resolve, reject) => {
|
|
87
|
-
const timeoutMs = 120_000;
|
|
88
98
|
let resolved = false;
|
|
89
99
|
let req: http.ClientRequest | undefined;
|
|
90
100
|
const timer = setTimeout(() => {
|