@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.
Files changed (86) hide show
  1. package/README.md +2 -2
  2. package/dist/cli.mjs +5670 -4705
  3. package/dist/prompts/research-tool-usage.md +1 -1
  4. package/dist/prompts/researcher.md +1 -0
  5. package/dist/prompts/system-lead-router.md +122 -0
  6. package/dist/prompts/system-lead-synthesizer.md +46 -0
  7. package/dist/thread-worker.mjs +169 -62
  8. package/docs/ARCHITECTURE.md +18 -10
  9. package/docs/CHANGELOG.md +225 -0
  10. package/docs/CONFIGURATION.md +106 -7
  11. package/package.json +2 -2
  12. package/scripts/build.cjs +8 -1
  13. package/skills/pi-research/SKILL.md +1 -1
  14. package/src/cli.ts +5 -1
  15. package/src/config.ts +22 -12
  16. package/src/constants.ts +1 -1
  17. package/src/core/interfaces/observer-interfaces.ts +3 -3
  18. package/src/core/interfaces/orchestration-interfaces.ts +2 -0
  19. package/src/core/interfaces/planning-interfaces.ts +7 -1
  20. package/src/core/interfaces/scheduler-interfaces.ts +18 -2
  21. package/src/core/llm/agentic-repair.ts +7 -2
  22. package/src/core/llm/inject-date.ts +4 -1
  23. package/src/core/llm/llm-utils.ts +7 -0
  24. package/src/core/planning-service.ts +766 -91
  25. package/src/core/planning-utils.ts +21 -5
  26. package/src/core/service-initialization.ts +19 -3
  27. package/src/healthcheck/index.ts +11 -2
  28. package/src/index.ts +4 -3
  29. package/src/infrastructure/browser/browser-client.ts +68 -34
  30. package/src/infrastructure/browser/browser-task-scheduler.ts +236 -43
  31. package/src/infrastructure/browser/config.ts +79 -1
  32. package/src/infrastructure/browser/priority-task-queue.ts +55 -1
  33. package/src/infrastructure/browser/scheduler-factory.ts +12 -1
  34. package/src/infrastructure/browser/task-execution-service.ts +5 -5
  35. package/src/infrastructure/browser/thread-worker-browser.ts +87 -36
  36. package/src/infrastructure/browser/thread-worker-messaging.ts +99 -10
  37. package/src/infrastructure/browser/thread-worker.mjs +169 -62
  38. package/src/infrastructure/browser/thread-worker.ts +13 -5
  39. package/src/infrastructure/browser/worker-pool-manager.ts +127 -12
  40. package/src/infrastructure/embedding/embedding-client.ts +14 -4
  41. package/src/infrastructure/embedding/embedding-factory.ts +83 -15
  42. package/src/infrastructure/embedding/embedding-server.ts +34 -0
  43. package/src/infrastructure/file-lock-service.ts +214 -19
  44. package/src/infrastructure/knowledge-store-service.ts +14 -0
  45. package/src/infrastructure/research-run-semaphore.ts +1 -1
  46. package/src/infrastructure/service-initialization.ts +7 -0
  47. package/src/knowledge/embedder-init.ts +46 -19
  48. package/src/knowledge/embedder.ts +11 -3
  49. package/src/knowledge/index.ts +36 -10
  50. package/src/knowledge/store.ts +29 -5
  51. package/src/logger.ts +11 -0
  52. package/src/orchestration/deep-research-orchestrator.ts +143 -87
  53. package/src/orchestration/quick-research-orchestrator.ts +11 -2
  54. package/src/orchestration/research-orchestration-service.ts +8 -0
  55. package/src/orchestration/research-synthesis-service.ts +53 -1
  56. package/src/orchestration/researcher-executor.ts +53 -2
  57. package/src/orchestration/researcher.ts +15 -2
  58. package/src/orchestration/session-state.ts +16 -0
  59. package/src/prompts/research-tool-usage.md +1 -1
  60. package/src/prompts/researcher.md +1 -0
  61. package/src/prompts/system-lead-router.md +122 -0
  62. package/src/prompts/system-lead-synthesizer.md +46 -0
  63. package/src/sdk.ts +10 -0
  64. package/src/skill-install/skill-installer.ts +5 -1
  65. package/src/stackexchange/index.ts +3 -0
  66. package/src/stackexchange/rest-client.ts +7 -12
  67. package/src/tools/research-knowledge-search.ts +11 -3
  68. package/src/tools/research-tool-definition.ts +38 -7
  69. package/src/tools/scrape.ts +6 -0
  70. package/src/tools/search.ts +7 -0
  71. package/src/tools/security.ts +31 -10
  72. package/src/tools/stackexchange.ts +13 -7
  73. package/src/tools/youtube-transcript.ts +26 -12
  74. package/src/utils/cancellation.ts +36 -0
  75. package/src/utils/coverage-digest.ts +204 -0
  76. package/src/utils/host-config.ts +12 -1
  77. package/src/utils/llm-usage.ts +33 -6
  78. package/src/utils/log-utils.ts +10 -0
  79. package/src/utils/metrics-summary.ts +20 -4
  80. package/src/utils/metrics.ts +27 -1
  81. package/src/web-research/browser-search.ts +102 -22
  82. package/src/web-research/web-scraper.ts +27 -16
  83. package/src/youtube/potoken.ts +8 -1
  84. package/src/youtube/transcript-client.ts +56 -5
  85. package/dist/prompts/system-lead-evaluator.md +0 -118
  86. 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
- if (this.pool && this.currentWorkerCount === maxWorkers) {
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] Worker count changed from ${this.currentWorkerCount} to ${maxWorkers}, recreating pool...`);
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
- metrics.increment('browser_pool_errors_total', 1);
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
- workerChoiceStrategy: WorkerChoiceStrategies.ROUND_ROBIN,
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
- metrics.increment('browser_pool_auto_recoveries_total', 1);
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
- async fetchHealth(): Promise<void> {
76
- const result = await this.getRequest<{ status: string; device: string; dimension: number | null }>('/health');
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
- const portOk = await isPortListening(info.port);
257
- if (portOk) {
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
- await client.fetchHealth();
261
- _embeddingInstance = client;
262
- _cachedModel = cfg.EMBEDDING_MODEL;
263
- _cachedDevice = cfg.EMBEDDING_DEVICE;
264
- return client;
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
- // Port registered but not responding — wait one more interval and re-check
267
- await new Promise<void>((r) => setTimeout(r, POLL_INTERVAL_MS));
268
- const secondCheck = await isPortListening(info.port);
269
- if (!secondCheck) {
270
- logger.warn(`[EmbeddingFactory] Registered port ${info.port} is unreachable after two checks — clearing stale state`);
271
- // serverId-scoped: this snapshot is up to ~2.5s old (two probes + sleep);
272
- // the entry may already belong to a new claimant. See the dead-leader
273
- // clear above for the failure this prevents.
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 {
@@ -80,6 +80,11 @@ class SerialQueue {
80
80
  private readonly hardDeadlineMs: number;
81
81
  private readonly onPoison?: (err: Error) => void;
82
82
  private poisoned: Error | null = null;
83
+ // The currently-executing task's real work promise (not the soft-timeout race) —
84
+ // set only while a native call is actually in flight. Lets a caller that needs
85
+ // to dispose the underlying pipeline (EmbeddingServer.shutdown) wait for true
86
+ // idleness instead of racing disposal against a still-running inference.
87
+ private currentWork: Promise<unknown> | null = null;
83
88
 
84
89
  constructor(
85
90
  maxDepth = 200,
@@ -132,6 +137,7 @@ class SerialQueue {
132
137
  let hardTimer: ReturnType<typeof setTimeout> | undefined;
133
138
  try {
134
139
  work = fn();
140
+ this.currentWork = work;
135
141
  // Arm the hard-deadline watchdog from the moment the real GPU work starts, so
136
142
  // the cap is an honest wall-clock deadline measured from work-start rather than
137
143
  // (soft-timeout + hardDeadlineMs). A single inference still unsettled after
@@ -162,6 +168,7 @@ class SerialQueue {
162
168
  } finally {
163
169
  settled = true;
164
170
  if (hardTimer !== undefined) clearTimeout(hardTimer);
171
+ if (this.currentWork === work) this.currentWork = null;
165
172
  }
166
173
  }
167
174
  };
@@ -170,6 +177,23 @@ class SerialQueue {
170
177
  });
171
178
  }
172
179
 
180
+ /**
181
+ * Resolve once no native call is in flight. Used by EmbeddingServer.shutdown()
182
+ * to wait out a still-running inference before disposing the pipeline —
183
+ * without this, disposal could run concurrently with a legitimately slow (not
184
+ * hung) embed/embedMany call on the same re-entrant-locked GPU context, the
185
+ * exact native-segfault class this queue exists to prevent. No timeout: a
186
+ * promise that truly never settles is already an accepted, pre-existing
187
+ * leak-to-process-exit scenario elsewhere in this class (see the poison path's
188
+ * disposeEmbedder=false handling) — better to leak than risk concurrent
189
+ * native access.
190
+ */
191
+ async waitForIdle(): Promise<void> {
192
+ while (this.currentWork) {
193
+ await this.currentWork.catch(() => {});
194
+ }
195
+ }
196
+
173
197
  private poison(err: Error): void {
174
198
  if (this.poisoned) return;
175
199
  this.poisoned = err;
@@ -414,6 +438,16 @@ export class EmbeddingServer implements IEmbedder {
414
438
  // is enough for another process to re-elect; the leaked native resources die with
415
439
  // eventual process exit.
416
440
  if (disposeEmbedder) {
441
+ // Wait for the SerialQueue to go idle BEFORE disposing. This shutdown() path
442
+ // also runs on ordinary leadership loss — not just the poison path above — and
443
+ // a legitimately slow (not hung) embed/embedMany can still be mid-flight on
444
+ // this same GPU context. Embedder.dispose()'s own drainActiveEmbeddings only
445
+ // waits 5s, an order of magnitude under the queue's own 120s soft timeout, so
446
+ // without this a normal large batch could have its pipeline disposed out from
447
+ // under it — the same concurrent-native-call hazard the queue exists to
448
+ // prevent, just triggered by disposal racing a live call instead of two calls
449
+ // racing each other.
450
+ await this.queue.waitForIdle();
417
451
  await this.embedder.dispose().catch((err) => {
418
452
  if (this.isShuttingDown) {
419
453
  logger.debug('[EmbeddingServer] Error disposing embedder during shutdown (expected):', err);