@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
|
@@ -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(() => {
|
|
@@ -227,6 +227,33 @@ export async function getEmbedder(config?: Config, _attempt = 0): Promise<IEmbed
|
|
|
227
227
|
const POLL_TIMEOUT_MS = 120_000;
|
|
228
228
|
const deadline = Date.now() + POLL_TIMEOUT_MS;
|
|
229
229
|
|
|
230
|
+
/**
|
|
231
|
+
* How many CONSECUTIVE polls must find a confirmed-live leader's endpoint
|
|
232
|
+
* unusable before its registration is judged stale.
|
|
233
|
+
*
|
|
234
|
+
* Deleting a live leader's entry is the expensive mistake in this loop: the
|
|
235
|
+
* leader is not told, treats a missing entry as benign, and never steps down,
|
|
236
|
+
* so the follower's subsequent election produces a second model init and a
|
|
237
|
+
* second GPU context while the first server keeps running, invisible. The old
|
|
238
|
+
* code reached that from two failed TCP probes ~2.5s apart — well inside a
|
|
239
|
+
* blip from a saturated accept backlog, a paused event loop during a large
|
|
240
|
+
* batch embed, or CPU starvation on a shared box.
|
|
241
|
+
*
|
|
242
|
+
* Not clearing at all is not the answer either: a leader whose HTTP server
|
|
243
|
+
* died while the process lives on leaves a registration nothing else collects,
|
|
244
|
+
* and every later process then falls back to its own in-process embedder. So
|
|
245
|
+
* the threshold is raised rather than removed. Each iteration costs the poll
|
|
246
|
+
* interval plus up to the 2s socket probe (isPortListening) plus up to a
|
|
247
|
+
* further 2s health-check probe (fetchHealth, called below with an explicit
|
|
248
|
+
* short timeout — NOT the client's 120s default, which would let a merely-busy
|
|
249
|
+
* leader blow the outer POLL_TIMEOUT_MS long before this counter ever fired),
|
|
250
|
+
* making this roughly 45s of uninterrupted unreachability — far past any blip,
|
|
251
|
+
* far short of the 120s wait.
|
|
252
|
+
*/
|
|
253
|
+
const UNREACHABLE_POLLS_BEFORE_STALE = 10;
|
|
254
|
+
let unreachablePolls = 0;
|
|
255
|
+
let unreachableEndpoint: string | null = null;
|
|
256
|
+
|
|
230
257
|
logger.info(`[EmbeddingFactory] Another process is initializing (port=${fastPathPort}), waiting...`);
|
|
231
258
|
|
|
232
259
|
while (Date.now() < deadline) {
|
|
@@ -253,24 +280,50 @@ export async function getEmbedder(config?: Config, _attempt = 0): Promise<IEmbed
|
|
|
253
280
|
return localFallbackForMismatch(info.model);
|
|
254
281
|
}
|
|
255
282
|
if (info.port > 0) {
|
|
256
|
-
|
|
257
|
-
|
|
283
|
+
// A new claimant resets the streak — the count must describe ONE endpoint's
|
|
284
|
+
// continuous unreachability, not a tally across successive leaders.
|
|
285
|
+
const endpoint = `${info.serverId}:${info.port}`;
|
|
286
|
+
if (endpoint !== unreachableEndpoint) {
|
|
287
|
+
unreachableEndpoint = endpoint;
|
|
288
|
+
unreachablePolls = 0;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// Reaching the counter below means "not usable this poll" by construction:
|
|
292
|
+
// the only usable outcome returns.
|
|
293
|
+
if (await isPortListening(info.port)) {
|
|
258
294
|
logger.info(`[EmbeddingFactory] Connecting to embedding server on port ${info.port}`);
|
|
259
295
|
const client = new EmbeddingClient(info.port, cfg.EMBEDDING_DEVICE, info.authSecret);
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
296
|
+
try {
|
|
297
|
+
// A short, poll-scale timeout — NOT EmbeddingClient's 120s default. The
|
|
298
|
+
// unreachable-poll count above assumes each iteration costs roughly the poll
|
|
299
|
+
// interval plus this probe's timeout (~2.5s total); at the 120s default, a
|
|
300
|
+
// leader that is merely busy (not dead) can take the full 120s to answer,
|
|
301
|
+
// blowing the outer POLL_TIMEOUT_MS well before the stale-registration
|
|
302
|
+
// threshold below would ever fire. Matches isPortListening's own 2s probe.
|
|
303
|
+
await client.fetchHealth(2_000);
|
|
304
|
+
_embeddingInstance = client;
|
|
305
|
+
_cachedModel = cfg.EMBEDDING_MODEL;
|
|
306
|
+
_cachedDevice = cfg.EMBEDDING_DEVICE;
|
|
307
|
+
return client;
|
|
308
|
+
} catch (err) {
|
|
309
|
+
// The socket accepted but the server did not answer. Previously this
|
|
310
|
+
// rejected out of getEmbedder entirely, so one failed health request
|
|
311
|
+
// against an otherwise-recoverable leader (mid model swap, momentarily
|
|
312
|
+
// wedged) took embedding down for the whole session instead of costing
|
|
313
|
+
// one poll. Count it as an unreachable poll and keep waiting.
|
|
314
|
+
logger.debug(`[EmbeddingFactory] Health check against port ${info.port} failed:`, err);
|
|
315
|
+
}
|
|
265
316
|
}
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
//
|
|
317
|
+
|
|
318
|
+
unreachablePolls++;
|
|
319
|
+
if (unreachablePolls >= UNREACHABLE_POLLS_BEFORE_STALE) {
|
|
320
|
+
logger.warn(
|
|
321
|
+
`[EmbeddingFactory] Registered port ${info.port} unreachable for ${unreachablePolls} consecutive polls ` +
|
|
322
|
+
`(PID ${info.pid} is alive) — treating the registration as stale and electing a replacement.`,
|
|
323
|
+
);
|
|
324
|
+
// serverId-scoped: this snapshot is many seconds old by now and the entry
|
|
325
|
+
// may already belong to a new claimant. See the dead-leader clear above
|
|
326
|
+
// for the failure an unconditional delete causes.
|
|
274
327
|
await stateManager.clearEmbeddingServer({ serverId: info.serverId }).catch((err) => logger.debug('Swallowed clear embedding server error:', err));
|
|
275
328
|
break;
|
|
276
329
|
}
|
|
@@ -355,6 +408,21 @@ export async function getEmbedder(config?: Config, _attempt = 0): Promise<IEmbed
|
|
|
355
408
|
// clearEmbeddingInstance — for testing / restart scenarios
|
|
356
409
|
// ---------------------------------------------------------------------------
|
|
357
410
|
|
|
411
|
+
/**
|
|
412
|
+
* Does THIS process currently hold a live embedding server (as opposed to a client
|
|
413
|
+
* pointing at someone else's, or nothing at all)?
|
|
414
|
+
*
|
|
415
|
+
* Asked by the teardown-time state clear, which is pid-scoped and therefore cannot
|
|
416
|
+
* tell "my leftover registration" from "my server that is still listening". That
|
|
417
|
+
* clear runs before disposeAllServices, so at that moment our own server usually
|
|
418
|
+
* IS still up — and deleting its entry makes it an invisible leader, since its
|
|
419
|
+
* leadership check reads a missing entry as benign and never steps down. Its own
|
|
420
|
+
* dispose clears the entry properly, keyed on serverId.
|
|
421
|
+
*/
|
|
422
|
+
export function ownsLiveEmbeddingServer(): boolean {
|
|
423
|
+
return _embeddingInstance !== null && _embeddingInstance instanceof EmbeddingServer;
|
|
424
|
+
}
|
|
425
|
+
|
|
358
426
|
export async function clearEmbeddingInstance(): Promise<void> {
|
|
359
427
|
if (_embeddingInstance) {
|
|
360
428
|
try {
|