@lincoln504/pi-research 1.2.0 → 1.3.0

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 (81) hide show
  1. package/dist/cli.mjs +1334 -750
  2. package/dist/thread-worker.mjs +194 -136
  3. package/docs/ARCHITECTURE.md +1 -1
  4. package/docs/CHANGELOG.md +114 -16
  5. package/package.json +1 -1
  6. package/scripts/cleanup.cjs +64 -2
  7. package/skills/pi-research/SKILL.md +1 -1
  8. package/src/cleanup/research-cleanup.ts +12 -4
  9. package/src/cli.ts +228 -70
  10. package/src/config.ts +78 -2
  11. package/src/core/interfaces/health-check-interfaces.ts +3 -39
  12. package/src/core/interfaces/planning-interfaces.ts +8 -0
  13. package/src/core/interfaces/service-names.ts +0 -1
  14. package/src/core/interfaces/state-manager-interfaces.ts +10 -2
  15. package/src/core/llm/agentic-repair.ts +14 -0
  16. package/src/core/planning-service.ts +43 -1
  17. package/src/core/service-initialization.ts +6 -14
  18. package/src/core/service-interfaces.ts +1 -1
  19. package/src/healthcheck/index.ts +7 -2
  20. package/src/infrastructure/browser/browser-client.ts +64 -9
  21. package/src/infrastructure/browser/browser-server.ts +24 -7
  22. package/src/infrastructure/browser/browser-task-scheduler.ts +58 -12
  23. package/src/infrastructure/browser/cleanup-utils.ts +50 -2
  24. package/src/infrastructure/browser/client-agent.ts +6 -6
  25. package/src/infrastructure/browser/config.ts +14 -3
  26. package/src/infrastructure/browser/scheduler-factory.ts +18 -3
  27. package/src/infrastructure/browser/thread-worker-messaging.ts +54 -2
  28. package/src/infrastructure/browser/thread-worker-types.ts +3 -1
  29. package/src/infrastructure/browser/thread-worker.mjs +194 -136
  30. package/src/infrastructure/browser/worker-pool-manager.ts +7 -2
  31. package/src/infrastructure/embedding/embedding-factory.ts +35 -6
  32. package/src/infrastructure/embedding/embedding-server.ts +40 -7
  33. package/src/infrastructure/file-lock-service.ts +72 -2
  34. package/src/infrastructure/knowledge-store-service.ts +48 -3
  35. package/src/infrastructure/process-lifecycle-service.ts +28 -5
  36. package/src/infrastructure/research-run-semaphore.ts +37 -3
  37. package/src/infrastructure/state/state-browser-api.ts +5 -2
  38. package/src/infrastructure/state/state-browser-manager.ts +15 -1
  39. package/src/infrastructure/state/state-manager-service.ts +4 -4
  40. package/src/infrastructure/state/state-manager.ts +15 -3
  41. package/src/knowledge/chunker.ts +88 -1
  42. package/src/knowledge/embedder.ts +136 -43
  43. package/src/knowledge/store.ts +66 -12
  44. package/src/logger.ts +5 -1
  45. package/src/observers/research-observer-impl.ts +31 -0
  46. package/src/orchestration/abort-utils.ts +60 -0
  47. package/src/orchestration/deep-research-orchestrator.ts +12 -2
  48. package/src/orchestration/headless-observer.ts +8 -0
  49. package/src/orchestration/research-orchestration-service.ts +63 -11
  50. package/src/orchestration/research-session-service.ts +37 -11
  51. package/src/orchestration/researcher-executor.ts +100 -20
  52. package/src/orchestration/researcher.ts +39 -20
  53. package/src/orchestration/session-state.ts +11 -0
  54. package/src/sdk.ts +125 -19
  55. package/src/security/index.ts +51 -30
  56. package/src/security/nvd-client.ts +7 -2
  57. package/src/skill-install/skill-installer.ts +18 -2
  58. package/src/stackexchange/index.ts +20 -5
  59. package/src/stackexchange/output/table.ts +7 -2
  60. package/src/stackexchange/rest-client.ts +35 -1
  61. package/src/tools/research-tool-definition.ts +8 -2
  62. package/src/tools/scrape.ts +48 -25
  63. package/src/tools/search.ts +10 -1
  64. package/src/tools/security.ts +68 -7
  65. package/src/tui/research-health.ts +18 -2
  66. package/src/utils/circuit-breaker.ts +9 -2
  67. package/src/utils/disk-space-checker.ts +11 -6
  68. package/src/utils/json-utils.ts +116 -76
  69. package/src/utils/llm-usage.ts +17 -1
  70. package/src/utils/log-rotation.ts +42 -10
  71. package/src/utils/log-utils.ts +40 -0
  72. package/src/utils/metrics-summary.ts +27 -9
  73. package/src/utils/stdio-capture.ts +63 -22
  74. package/src/utils/text-utils.ts +8 -1
  75. package/src/utils/tool-usage-tracker.ts +9 -2
  76. package/src/web-research/browser-search.ts +26 -7
  77. package/src/web-research/scraper-utils.ts +13 -0
  78. package/src/web-research/search.ts +6 -2
  79. package/src/web-research/types.ts +4 -2
  80. package/src/web-research/web-scraper.ts +65 -4
  81. package/src/core/health-check-service.ts +0 -265
@@ -16,6 +16,7 @@ import { Utf8Body } from '../../utils/http-body.ts';
16
16
  import type { IEmbedder } from '../../core/interfaces/knowledge-interfaces.ts';
17
17
  import type { IStateManager } from '../../core/interfaces/state-manager-interfaces.ts';
18
18
  import type { Embedder } from '../../knowledge/embedder.ts';
19
+ import { markWebGpuFallback } from '../../knowledge/embedder-utils.ts';
19
20
 
20
21
  /**
21
22
  * Per-process shared secret every embed request must present.
@@ -49,6 +50,24 @@ function isAuthorized(presented: string | string[] | undefined): boolean {
49
50
  return actual.length === expected.length && crypto.timingSafeEqual(actual, expected);
50
51
  }
51
52
 
53
+ /**
54
+ * Refuse to serialize a vector containing NaN/±Infinity. JSON.stringify emits
55
+ * `null` for non-finite numbers, and the client's `new Float32Array(resp.data)`
56
+ * coerces null back to 0 — a silently part-zeroed vector that passes every
57
+ * downstream width/finite gate (0 IS finite) and lands in the ANN index. Failing
58
+ * the request instead surfaces it as an ordinary embed failure on the client.
59
+ */
60
+ function assertFiniteVector(vec: ArrayLike<number>, vectorIndex?: number): void {
61
+ for (let i = 0; i < vec.length; i++) {
62
+ if (!Number.isFinite(vec[i]!)) {
63
+ const where = vectorIndex === undefined ? `position ${i}` : `vector ${vectorIndex}, position ${i}`;
64
+ throw new Error(
65
+ `[EmbeddingServer] embedder produced a non-finite value (${vec[i]}) at ${where} — refusing to serialize (JSON would launder it to null/0)`,
66
+ );
67
+ }
68
+ }
69
+ }
70
+
52
71
  // ---------------------------------------------------------------------------
53
72
  // SerialQueue — ensures embed/embedMany never run concurrently on the GPU
54
73
  // ---------------------------------------------------------------------------
@@ -274,6 +293,15 @@ export class EmbeddingServer implements IEmbedder {
274
293
  'Stepping down as leader so a fresh process can re-elect with a clean GPU context. ' +
275
294
  err.message,
276
295
  );
296
+ // INVARIANT: after a poisoned (permanently hung) inference, THIS process must
297
+ // never touch WebGPU again. The wedged native call still holds the same-PID
298
+ // re-entrant GPU lock, so if this process wins the next election and
299
+ // re-initializes WebGPU it would run a second inference beside the stuck one —
300
+ // the concurrent-GPU-inference segfault class the SerialQueue exists to
301
+ // prevent. Latch the process-wide fallback so any embedder built in this
302
+ // process from here on comes up on CPU; only a FRESH process may re-elect
303
+ // with a clean GPU context.
304
+ markWebGpuFallback();
277
305
  // Step down WITHOUT disposing the embedder: pipeline.dispose() would join the
278
306
  // stuck native thread (itself hanging) or touch a device-lost context. Clearing
279
307
  // the state registration + closing the HTTP server is enough for re-election;
@@ -339,14 +367,14 @@ export class EmbeddingServer implements IEmbedder {
339
367
  this.leadershipTimer = null;
340
368
  }
341
369
 
342
- // Clear our registration if we still own it
370
+ // Clear our registration if we still own it. The serverId CAS runs inside the
371
+ // state lock, so a successor that claimed the slot between our last check and
372
+ // this shutdown can never be deregistered by us (the old read-then-clear had
373
+ // exactly that window).
343
374
  try {
344
- const serverInfo = await this.stateManager.getEmbeddingServer();
345
- if (serverInfo?.serverId === this.serverId) {
346
- await this.stateManager.clearEmbeddingServer().catch((err) => {
347
- logger.warn('[EmbeddingServer] Failed to clear embedding server state:', err);
348
- });
349
- }
375
+ await this.stateManager.clearEmbeddingServer({ serverId: this.serverId }).catch((err) => {
376
+ logger.warn('[EmbeddingServer] Failed to clear embedding server state:', err);
377
+ });
350
378
  } catch (err) {
351
379
  const msg = err instanceof Error ? err.message : String(err);
352
380
  // State manager is torn down before the embedding server during process exit —
@@ -552,6 +580,9 @@ export class EmbeddingServer implements IEmbedder {
552
580
  // message drives the client to reconnect to the new leader.
553
581
  this.assertServing();
554
582
  const result = await this.queue.enqueue(() => this.embedder.embed(data.text as string));
583
+ // Scan BEFORE serialization: a device-lost mid-inference yields NaN
584
+ // entries that JSON would launder into null → 0 on the client.
585
+ assertFiniteVector(result);
555
586
  res.writeHead(200, { 'Content-Type': 'application/json' });
556
587
  res.end(JSON.stringify({ data: Array.from(result) }));
557
588
  break;
@@ -559,6 +590,8 @@ export class EmbeddingServer implements IEmbedder {
559
590
  case '/embedMany': {
560
591
  this.assertServing();
561
592
  const results = await this.queue.enqueue(() => this.embedder.embedMany(data.texts as string[]));
593
+ // Same non-finite scan as /embed, per vector (see assertFiniteVector).
594
+ for (let i = 0; i < results.length; i++) assertFiniteVector(results[i]!, i);
562
595
  res.writeHead(200, { 'Content-Type': 'application/json' });
563
596
  res.end(JSON.stringify({ results: results.map(r => Array.from(r)) }));
564
597
  break;
@@ -38,6 +38,16 @@ const CLEANUP_DRAIN_TIMEOUT_MS = 5_000;
38
38
  */
39
39
  const TRASH_SWEEP_AGE_MS = 10 * 60_000;
40
40
 
41
+ /**
42
+ * Floor for the held-lock mtime heartbeat interval (see _startHeartbeat), so a
43
+ * small liveOwnerStaleThreshold cannot turn maintenance into a utimes storm.
44
+ */
45
+ const HEARTBEAT_MIN_INTERVAL_MS = 2_000;
46
+
47
+ /** setTimeout/setInterval clamp delays above 2^31-1 to 1ms — treat anything
48
+ * bigger as "no heartbeat" rather than a busy-loop. */
49
+ const MAX_TIMER_DELAY_MS = 0x7fffffff;
50
+
41
51
  /**
42
52
  * Lock configuration options
43
53
  */
@@ -101,9 +111,17 @@ export class FileLockService implements IService {
101
111
  // Set by cleanup(). A retired instance no longer manages the lock file, so
102
112
  // acquiring through it would hand out exclusivity it cannot honour.
103
113
  private _retired = false;
114
+ // Refreshes the held lock file's mtime while a critical section runs (see
115
+ // _startHeartbeat). Null whenever no lock is held.
116
+ private heartbeatTimer: NodeJS.Timeout | null = null;
117
+ // Stable short lock identity for metrics labels — without it every instance
118
+ // (state lock, knowledge init, browser init, run slots) shares one gauge and
119
+ // any release zeroes it globally.
120
+ private readonly lockName: string;
104
121
 
105
122
  constructor(options: FileLockOptions) {
106
123
  this.lockFilePath = options.lockFilePath;
124
+ this.lockName = pathmod.basename(options.lockFilePath);
107
125
  this.lockTimeout = options.lockTimeout ?? 20000;
108
126
  this.lockRetries = options.lockRetries ?? 200;
109
127
  this.lockRetryDelay = options.lockRetryDelay ?? 100;
@@ -254,6 +272,48 @@ export class FileLockService implements IService {
254
272
  if (resolve) resolve();
255
273
  }
256
274
 
275
+ /**
276
+ * While the lock is HELD, refresh its mtime on an interval well inside
277
+ * liveOwnerStaleThreshold. Peers judge a live owner purely by lock-file age
278
+ * (_shouldReclaim), and nothing else touches the file during a critical
279
+ * section — so without a heartbeat a legitimately long one (e.g. a multi-minute
280
+ * knowledge-store model-change migration under the 240s init-lock threshold)
281
+ * ages past the limit and is stolen from a LIVE owner, admitting a concurrent
282
+ * writer. With the heartbeat, a live owner is only stolen after genuinely
283
+ * wedging: no refresh for the full threshold.
284
+ *
285
+ * unref() is CORRECT here, unlike a timer that IS the operation: the heartbeat
286
+ * is maintenance on behalf of a critical section that keeps the process alive
287
+ * by itself, and the process must never be kept alive just to freshen a lock.
288
+ *
289
+ * The refresh goes through the held FileHandle (futimes), not the path, so if
290
+ * the lock changed hands (renamed/unlinked by a reclaiming peer) we can only
291
+ * ever touch OUR old inode — never freshen a peer's lock by mistake.
292
+ */
293
+ private _startHeartbeat(): void {
294
+ this._stopHeartbeat();
295
+ const interval = Math.max(HEARTBEAT_MIN_INTERVAL_MS, Math.ceil(this.liveOwnerStaleThreshold / 4));
296
+ // An effectively-infinite threshold (the run-semaphore's never-steal-from-a-
297
+ // live-owner opt-out) needs no heartbeat — and would overflow the 32-bit
298
+ // timer delay, which Node clamps to 1ms (a utimes busy-loop). Skip entirely;
299
+ // the opt-out's semantics are unchanged.
300
+ if (interval > MAX_TIMER_DELAY_MS) return;
301
+ this.heartbeatTimer = setInterval(() => {
302
+ const handle = this.lockHandle;
303
+ if (handle === null) return;
304
+ const now = new Date();
305
+ handle.utimes(now, now).catch(() => { /* best-effort maintenance */ });
306
+ }, interval);
307
+ this.heartbeatTimer.unref?.();
308
+ }
309
+
310
+ private _stopHeartbeat(): void {
311
+ if (this.heartbeatTimer !== null) {
312
+ clearInterval(this.heartbeatTimer);
313
+ this.heartbeatTimer = null;
314
+ }
315
+ }
316
+
257
317
  /**
258
318
  * Wait, bounded, for an in-flight critical section to finish.
259
319
  *
@@ -448,6 +508,9 @@ export class FileLockService implements IService {
448
508
  // Fully acquired — commit to instance state
449
509
  this.lockHandle = handle;
450
510
  this.lockCount = 1;
511
+ // Keep the held lock's mtime fresh so a long critical section is not
512
+ // mistaken for a wedged owner and stolen (see _startHeartbeat).
513
+ this._startHeartbeat();
451
514
 
452
515
  // Track in ALS for nested calls
453
516
  if (heldLocks) {
@@ -457,7 +520,7 @@ export class FileLockService implements IService {
457
520
  const duration = Date.now() - startTime;
458
521
  metrics.observe('state_lock_acquire_duration_ms', duration);
459
522
  metrics.increment('state_lock_acquire_total', 1, { status: 'success' });
460
- metrics.setGauge('state_lock_held', 1);
523
+ metrics.setGauge('state_lock_held', 1, { lock: this.lockName });
461
524
  if (contentionCount > 0) {
462
525
  metrics.increment('state_lock_contention_total', 1);
463
526
  metrics.observe('state_lock_contention_retries', contentionCount);
@@ -596,6 +659,10 @@ export class FileLockService implements IService {
596
659
  return;
597
660
  }
598
661
 
662
+ // Fully releasing (not a re-entrant pop) — the mtime heartbeat must stop
663
+ // with the hold it maintains.
664
+ this._stopHeartbeat();
665
+
599
666
  try {
600
667
  // 1. Double check ownership before unlinking
601
668
  const content = await fs.readFile(this.lockFilePath, 'utf8').catch(() => '');
@@ -615,7 +682,7 @@ export class FileLockService implements IService {
615
682
  heldLocks.delete(`${this.lockFilePath}:${this.lockUuid}`);
616
683
  }
617
684
 
618
- metrics.setGauge('state_lock_held', 0);
685
+ metrics.setGauge('state_lock_held', 0, { lock: this.lockName });
619
686
  } finally {
620
687
  // 4. Signal the next caller in the queue that it's their turn.
621
688
  this._releaseTurn();
@@ -674,6 +741,9 @@ export class FileLockService implements IService {
674
741
  // Retire BEFORE releasing the turn, so a waiter woken below re-checks and
675
742
  // fails fast instead of acquiring a lock this instance no longer manages.
676
743
  this._retired = true;
744
+ // The forced-teardown path bypasses releaseLock, so stop the heartbeat here
745
+ // too — a retired instance must not keep freshening a lock it forfeited.
746
+ this._stopHeartbeat();
677
747
 
678
748
  if (this.lockHandle) {
679
749
  try {
@@ -108,10 +108,40 @@ export class KnowledgeStoreService implements IKnowledgeStoreService {
108
108
  return this._initializationPromise;
109
109
  }
110
110
 
111
+ // A different-cwd (or post-dispose-gate) call while an init is still in flight
112
+ // must NOT start a second closure: the two would race assignments to
113
+ // _embedder/_store/_writerQueue (the loser's components leak un-disposed), and
114
+ // the first to settle would null the second's promise marker — letting a
115
+ // concurrent dispose() skip its wait-for-init guard and be resurrected when the
116
+ // straggler flips lifecycle back to INITIALIZED. Settle the in-flight init
117
+ // first, then fall through to the normal re-init path.
118
+ while (this._initializationPromise) {
119
+ await this._initializationPromise.catch(() => { /* its own catch reset state */ });
120
+ // A newer init may have started while we awaited (or while dispose() below
121
+ // yielded); if it covers our cwd, join it — the loop re-checks either way.
122
+ if (this._initializationPromise && this._cwd === newCwd) {
123
+ return this._initializationPromise;
124
+ }
125
+ if (this.lifecycle === ServiceLifecycle.INITIALIZED || this.lifecycle === ServiceLifecycle.DISABLED) {
126
+ if (this._cwd !== newCwd) {
127
+ logger.log(`[KnowledgeStoreService] CWD changed from ${this._cwd} to ${newCwd}. Re-initializing store...`);
128
+ await this.dispose();
129
+ } else {
130
+ // The settled init already covers this cwd.
131
+ return;
132
+ }
133
+ }
134
+ }
135
+
111
136
  this.lifecycle = ServiceLifecycle.INITIALIZING;
112
137
  logger.debug('[KnowledgeStoreService] Initializing...');
113
138
 
114
- this._initializationPromise = (async () => {
139
+ // `let ... = null` (not const): if the closure throws BEFORE its first await,
140
+ // the finally runs synchronously during the IIFE call — a const self-reference
141
+ // would be a TDZ crash. The null guard in the finally covers that window (the
142
+ // marker was never published, so there is nothing to clear).
143
+ let initPromise: Promise<void> | null = null;
144
+ initPromise = (async () => {
115
145
  try {
116
146
  // Create the knowledge store components.
117
147
  // Prefer an explicit ctx.cwd; otherwise keep the cwd already resolved on this
@@ -228,11 +258,18 @@ export class KnowledgeStoreService implements IKnowledgeStoreService {
228
258
  this.lifecycle = ServiceLifecycle.UNINITIALIZED;
229
259
  throw err;
230
260
  } finally {
231
- this._initializationPromise = null;
261
+ // Identity-guarded (same pattern as embedding-factory's clearIfCurrent): only
262
+ // the promise that owns the marker may null it, so a settling straggler can
263
+ // never disarm a newer init's marker — which dispose() relies on to wait out
264
+ // in-flight work.
265
+ if (this._initializationPromise === initPromise) {
266
+ this._initializationPromise = null;
267
+ }
232
268
  }
233
269
  })();
270
+ this._initializationPromise = initPromise;
234
271
 
235
- return this._initializationPromise;
272
+ return initPromise;
236
273
  }
237
274
 
238
275
  async dispose(): Promise<void> {
@@ -267,6 +304,14 @@ export class KnowledgeStoreService implements IKnowledgeStoreService {
267
304
 
268
305
  if (this._embedder) {
269
306
  await this._embedder.dispose?.();
307
+ // The embedding factory's module-level cache still points at the instance
308
+ // just disposed — its fast path has no liveness check, so a later re-init
309
+ // (cwd/mode re-scope) would be handed the dead instance and burn every
310
+ // warm-up/init retry on it. clearEmbeddingInstance() re-disposes
311
+ // (idempotent) and nulls the cache; when this process was the leader, its
312
+ // shutdown deregisters via the serverId CAS, and a client instance's
313
+ // dispose is a no-op — a FOREIGN leader's registration is never touched.
314
+ await clearEmbeddingInstance();
270
315
  }
271
316
 
272
317
  if (this._initLock) {
@@ -55,6 +55,17 @@ export class ProcessLifecycleService implements IProcessLifecycle {
55
55
 
56
56
  private cachedStartTime: number | null = null;
57
57
 
58
+ // Short-TTL memo for SUBPROCESS-backed start-time lookups (macOS `ps`, Windows
59
+ // powershell — Linux reads /proc and stays uncached). A queued run-slot waiter
60
+ // sweeps every 250ms and builds a fresh FileLockService per attempt, so without
61
+ // a cross-attempt memo each live owner costs up to two `ps` spawns per sweep —
62
+ // ~48 subprocess spawns/second sustained on macOS while queued. A live PID's
63
+ // start time never changes; the only staleness is a PID recycled within the
64
+ // TTL reading as its dead predecessor, which merely delays a stale-owner steal
65
+ // by up to the TTL (well under every staleness threshold in use).
66
+ private static readonly START_TIME_TTL_MS = 5000;
67
+ private startTimeMemo = new Map<number, { value: number | null; at: number }>();
68
+
58
69
  async initialize(): Promise<void> {
59
70
  this.lifecycle = ServiceLifecycle.INITIALIZED;
60
71
  }
@@ -87,6 +98,18 @@ export class ProcessLifecycleService implements IProcessLifecycle {
87
98
  }
88
99
  }
89
100
 
101
+ const memoized = this.startTimeMemo.get(pid);
102
+ if (memoized && Date.now() - memoized.at < ProcessLifecycleService.START_TIME_TTL_MS) {
103
+ return memoized.value;
104
+ }
105
+ const memoize = (value: number | null): number | null => {
106
+ // Bound the map: sweeps interrogate a handful of owners; anything beyond a
107
+ // few hundred distinct PIDs inside one TTL window means leaked entries.
108
+ if (this.startTimeMemo.size > 256) this.startTimeMemo.clear();
109
+ this.startTimeMemo.set(pid, { value, at: Date.now() });
110
+ return value;
111
+ };
112
+
90
113
  // Windows: use powershell to get process creation time
91
114
  if (process.platform === 'win32') {
92
115
  try {
@@ -99,11 +122,11 @@ export class ProcessLifecycleService implements IProcessLifecycle {
99
122
  );
100
123
  child.unref?.();
101
124
  });
102
- if (!output || !output.trim()) return null;
125
+ if (!output || !output.trim()) return memoize(null);
103
126
  const startTime = parseInt(output.trim(), 10);
104
- return isNaN(startTime) ? null : startTime;
127
+ return memoize(isNaN(startTime) ? null : startTime);
105
128
  } catch {
106
- return null;
129
+ return memoize(null);
107
130
  }
108
131
  }
109
132
 
@@ -121,8 +144,8 @@ export class ProcessLifecycleService implements IProcessLifecycle {
121
144
  // So: try `etimes`, and fall back to parsing POSIX `etime` when it yields
122
145
  // nothing. `etime` is mandated by POSIX, so the fallback works everywhere.
123
146
  const elapsedSec = await this.readElapsedSeconds(pid);
124
- if (elapsedSec === null) return null;
125
- return Math.floor(Date.now() / 1000) - elapsedSec;
147
+ if (elapsedSec === null) return memoize(null);
148
+ return memoize(Math.floor(Date.now() / 1000) - elapsedSec);
126
149
  }
127
150
 
128
151
  /** Elapsed seconds for `pid` via ps, or null. Tries `etimes`, then POSIX `etime`. */
@@ -195,7 +195,18 @@ export class ResearchRunSemaphore implements IService {
195
195
  try {
196
196
  await slot.initialize(); // resolves own startTime (cached) + startup stale cleanup
197
197
  await slot.acquireLock(); // try-take: wins, reclaims a dead owner, or throws fast
198
- } catch {
198
+ } catch (err) {
199
+ // Contention and breakage are different outcomes and must not be conflated.
200
+ // FileLockService synthesizes its own Errors (no errno code) for the two
201
+ // contention shapes a try-take produces — a live-contended slot ("timeout
202
+ // after 0ms", cause EEXIST) and a lost reclaim race ("after N retries") —
203
+ // and those genuinely mean "slot busy". A real I/O failure (ENOENT/EACCES/
204
+ // ENOSPC: the slot file is uncreatable; both mkdirs are warn-only) instead
205
+ // propagates the raw errno error. Swallowing that as "busy" would poll out
206
+ // the whole acquire window and reject with a FALSE ResearchRunCapacityError,
207
+ // making the documented fail-open contract (research proceeds when the
208
+ // semaphore is broken) unreachable. Rethrow so acquire() can tell them apart.
209
+ if (typeof (err as NodeJS.ErrnoException | null)?.code === 'string') throw err;
199
210
  return null; // busy (live owner) or lost a reclaim race — try the next slot
200
211
  }
201
212
  return {
@@ -246,16 +257,39 @@ export class ResearchRunSemaphore implements IService {
246
257
  // Round-robin from a rotating start so contending processes don't synchronize on slot 0.
247
258
  const start = this.nextStartIndex;
248
259
  this.nextStartIndex = (this.nextStartIndex + 1) % this.maxSlots;
260
+ // Slot BREAKAGE (I/O error) is tracked separately from contention within
261
+ // this sweep — a broken slot must never be reported as a busy one.
262
+ let brokenSlots = 0;
263
+ let firstBreakage: unknown = null;
249
264
  for (let offset = 0; offset < this.maxSlots; offset++) {
250
265
  const i = (start + offset) % this.maxSlots;
251
- const got = await this.trySlot(i);
266
+ let got: RunSlotAcquisition | null;
267
+ try {
268
+ got = await this.trySlot(i);
269
+ } catch (err) {
270
+ // This slot is broken, not busy. Keep sweeping — another slot may still
271
+ // be acquirable (mixed case: slot 0 uncreatable, slot 1 free).
272
+ brokenSlots++;
273
+ firstBreakage ??= err;
274
+ logger.debug(`[ResearchRunSemaphore] Slot ${i} errored (broken, not busy): ${err instanceof Error ? err.message : String(err)}`);
275
+ continue;
276
+ }
252
277
  if (got) {
253
278
  metrics.setGauge('research_run_slots_held', 1, { slot: String(i) });
254
279
  metrics.increment('research_run_acquire_total', 1, { status: 'acquired' });
255
280
  return got;
256
281
  }
257
282
  }
258
- // Every slot is held by a live process.
283
+ // EVERY slot errored: the semaphore itself is broken, not at capacity.
284
+ // Waiting cannot help — nothing is held that could free up — and a
285
+ // ResearchRunCapacityError here would misclassify breakage as retryable
286
+ // contention while the caller's fail-open path (research proceeds without
287
+ // the cap) stays unreachable. Rethrow the I/O error instead.
288
+ if (brokenSlots === this.maxSlots) {
289
+ metrics.increment('research_run_acquire_total', 1, { status: 'error' });
290
+ throw firstBreakage;
291
+ }
292
+ // Every remaining slot is held by a live process.
259
293
  if (Date.now() >= deadline) {
260
294
  metrics.increment('research_run_acquire_total', 1, { status: 'exhausted' });
261
295
  throw new ResearchRunCapacityError(this.maxSlots, maxWaitMs);
@@ -50,12 +50,15 @@ export class StateBrowserApi {
50
50
  /**
51
51
  * Clear the browser server information
52
52
  * @param updateState Function to update state atomically
53
+ * @param expected Optional expected-owner identity forwarded to the CAS check
54
+ * in StateBrowserManager.clearBrowserServer.
53
55
  */
54
56
  async clearBrowserServer(
55
- updateState: (updater: (state: SingletonState) => SingletonState | Promise<SingletonState>) => Promise<void>
57
+ updateState: (updater: (state: SingletonState) => SingletonState | Promise<SingletonState>) => Promise<void>,
58
+ expected?: { pid?: number; schedulerId?: string }
56
59
  ): Promise<void> {
57
60
  await updateState((state) => {
58
- return this.browserManager.clearBrowserServer(state);
61
+ return this.browserManager.clearBrowserServer(state, expected);
59
62
  });
60
63
  }
61
64
 
@@ -47,9 +47,23 @@ export class StateBrowserManager implements IService {
47
47
  /**
48
48
  * Clear the browser server information
49
49
  * @param state The current state
50
+ * @param expected Optional identity the caller believes is currently registered.
51
+ * When supplied, this is a compare-and-delete: the entry is only removed if it
52
+ * still matches (mirrors StateManager.clearEmbeddingServer). browserServer is
53
+ * shared cross-process state, and an unconditional delete lets a caller who
54
+ * decided the *previous* leader was dead deregister a DIFFERENT process's
55
+ * fresh, live registration that claimed the slot in the meantime — that new
56
+ * leader then runs undiscoverable (state shows no browserServer), so the next
57
+ * getScheduler() caller elects a second, redundant leader. A mismatch means
58
+ * someone else already claimed the slot, and the clear must no-op.
50
59
  * @returns Updated state without browser server info
51
60
  */
52
- clearBrowserServer(state: SingletonState): SingletonState {
61
+ clearBrowserServer(state: SingletonState, expected?: { pid?: number; schedulerId?: string }): SingletonState {
62
+ const entry = state.browserServer;
63
+ if (entry && expected !== undefined) {
64
+ if (expected.schedulerId !== undefined && entry.schedulerId !== expected.schedulerId) return state;
65
+ if (expected.pid !== undefined && entry.pid !== expected.pid) return state;
66
+ }
53
67
  delete state.browserServer;
54
68
  return state;
55
69
  }
@@ -188,8 +188,8 @@ export class StateManagerService implements IStateManager {
188
188
  /**
189
189
  * Clear the browser server information
190
190
  */
191
- async clearBrowserServer(): Promise<void> {
192
- return this.getStateManager().clearBrowserServer();
191
+ async clearBrowserServer(expected?: { pid?: number; schedulerId?: string }): Promise<void> {
192
+ return this.getStateManager().clearBrowserServer(expected);
193
193
  }
194
194
 
195
195
  /**
@@ -202,8 +202,8 @@ export class StateManagerService implements IStateManager {
202
202
  /**
203
203
  * Clear the embedding server information
204
204
  */
205
- async clearEmbeddingServer(): Promise<void> {
206
- return this.getStateManager().clearEmbeddingServer();
205
+ async clearEmbeddingServer(expected?: { pid?: number; serverId?: string }): Promise<void> {
206
+ return this.getStateManager().clearEmbeddingServer(expected);
207
207
  }
208
208
 
209
209
  /**
@@ -446,8 +446,8 @@ export class StateManager {
446
446
  );
447
447
  }
448
448
 
449
- public async clearBrowserServer(): Promise<void> {
450
- await this.browserApi.clearBrowserServer(this.updateState.bind(this));
449
+ public async clearBrowserServer(expected?: { pid?: number; schedulerId?: string }): Promise<void> {
450
+ await this.browserApi.clearBrowserServer(this.updateState.bind(this), expected);
451
451
  }
452
452
 
453
453
  // ==================== Embedding Server API ====================
@@ -457,8 +457,20 @@ export class StateManager {
457
457
  return state.embeddingServer ?? null;
458
458
  }
459
459
 
460
- public async clearEmbeddingServer(): Promise<void> {
460
+ public async clearEmbeddingServer(expected?: { pid?: number; serverId?: string }): Promise<void> {
461
461
  await this.updateState((state) => {
462
+ // Compare-and-delete: the registration is shared cross-process state, and an
463
+ // unconditional delete lets a follower's shutdown (or a waiter acting on a
464
+ // stale snapshot) deregister a DIFFERENT process's live leader — which then
465
+ // never re-registers (its leadership check treats an absent entry as benign),
466
+ // so a fresh caller elects a second GPU model instance alongside the invisible
467
+ // one. Callers pass the identity they believe they are clearing; a mismatch
468
+ // means someone else already claimed the slot, and the clear must no-op.
469
+ const entry = state.embeddingServer;
470
+ if (entry && expected !== undefined) {
471
+ if (expected.serverId !== undefined && entry.serverId !== expected.serverId) return state;
472
+ if (expected.pid !== undefined && entry.pid !== expected.pid) return state;
473
+ }
462
474
  delete state.embeddingServer;
463
475
  return state;
464
476
  });
@@ -42,6 +42,7 @@ export class Chunker {
42
42
  let spaceExtensions = 0;
43
43
 
44
44
  while (start < text.length) {
45
+ const iterationStart = start;
45
46
  let end = start + this.targetSize;
46
47
 
47
48
  if (end < text.length) {
@@ -118,6 +119,55 @@ export class Chunker {
118
119
 
119
120
  if (end <= start) end = Math.min(start + this.targetSize, text.length);
120
121
 
122
+ // Forward-progress invariant: every chunk after the first must end past the
123
+ // previous chunk's end. When the overlap step plants `start` inside a code
124
+ // block whose closing fence terminated the PREVIOUS chunk, the in-block
125
+ // re-extension above clamps `end` back onto that same fence — a chunk that
126
+ // re-covers only overlap content, and (via the 1-char anti-regression step
127
+ // below) one such sliver per character until `start` clears the fence.
128
+ // Covering fresh text is worth splitting the fence: the previous chunk
129
+ // already carries the block's tail intact.
130
+ if (chunks.length > 0 && end <= prevEnd) {
131
+ end = Math.min(start + this.targetSize, text.length);
132
+ }
133
+
134
+ // UTF-16 surrogate-pair safety: `end` is a raw code-unit index, and every
135
+ // adjustment above (code-block/heading/sentence/newline/space, plus the
136
+ // forward-progress guard) operates purely in code-unit space — none of
137
+ // them are aware of astral characters (emoji, rare CJK Extension B+),
138
+ // which are a HIGH surrogate (0xD800-0xDBFF) followed by a LOW surrogate
139
+ // (0xDC00-0xDFFF). If `end` lands between the two halves, slicing here
140
+ // emits a lone high surrogate. Node's UTF-16->UTF-8 conversion (JSON
141
+ // serialization, HTTP bodies, N-API marshalling into the LanceDB/Arrow
142
+ // layer) silently substitutes U+FFFD for that rather than throwing —
143
+ // silent corruption of text that gets embedded and stored. Mirrors the
144
+ // backoff in truncateWithMarker (src/utils/text-utils.ts). Applied last,
145
+ // right before the slice, and only shrinks `end` when doing so can't
146
+ // undo the forward-progress guard just above or produce an empty chunk.
147
+ if (end < text.length) {
148
+ const code = text.charCodeAt(end - 1);
149
+ if (code >= 0xd800 && code <= 0xdbff) {
150
+ const candidate = end - 1;
151
+ const staysPastPrev = chunks.length === 0 || candidate > prevEnd;
152
+ if (candidate > start && staysPastPrev) {
153
+ end = candidate;
154
+ } else {
155
+ // Backing off would violate the forward-progress guard above (or empty
156
+ // the chunk) — this happens when targetSize and overlap sit within one
157
+ // code unit of each other. Grow past the low surrogate instead: taking
158
+ // the full pair into THIS chunk only ever increases `end`, so it can
159
+ // never re-collide with the guard it just failed, and it still avoids
160
+ // splitting the pair (the alternative — leaving `end` right after the
161
+ // lone high surrogate — is the silent corruption this block exists to
162
+ // prevent).
163
+ const low = text.charCodeAt(end);
164
+ if (low >= 0xdc00 && low <= 0xdfff) {
165
+ end = Math.min(end + 1, text.length);
166
+ }
167
+ }
168
+ }
169
+ }
170
+
121
171
  const chunkText = text.slice(start, end);
122
172
  const actual_overlap = chunks.length === 0 ? 0 : Math.max(0, Math.min(chunkText.length, prevEnd - start));
123
173
 
@@ -131,7 +181,44 @@ export class Chunker {
131
181
  start = end - Math.min(this.overlap, chunkText.length - 1);
132
182
  }
133
183
  if (start < 0) start = 0;
134
- if (start >= end) start = end - 1;
184
+ if (start >= end) start = end - 1;
185
+
186
+ // Forward-progress invariant, start side: the constructor rejects
187
+ // overlap >= targetSize specifically "to prevent infinite loops," but that
188
+ // guarantee only holds for the raw arithmetic above — it says nothing about
189
+ // the end-side forward-progress guard earlier in this loop reusing the same
190
+ // `end` two iterations in a row, which can leave `start` no greater than the
191
+ // value this same iteration started with, reproducing an identical chunk
192
+ // forever (observed: OOM from an unbounded chunks array on a small
193
+ // targetSize/overlap gap plus a boundary emoji). Guarantee real progress
194
+ // independent of every heuristic above, mirroring the `end`-side guard.
195
+ // Applied BEFORE the surrogate-pair check below so that check always
196
+ // adjusts an already-safe value instead of being able to undo this guard.
197
+ if (start <= iterationStart) start = iterationStart + 1;
198
+
199
+ // Surrogate-pair safety, read side: `start` (end - overlap, further
200
+ // clamped above) is an independent raw code-unit index — it is not
201
+ // derived from any of the textual heuristics that picked `end` above,
202
+ // so the fix to `end` does not cover it. It can separately bisect an
203
+ // unrelated pair and produce a next chunk that OPENS with a lone low
204
+ // surrogate. The pair's high half is already inside the chunk just
205
+ // pushed (it sits before `end`), so pulling `start` back one unit only
206
+ // widens that chunk's overlap by one code unit — it never drops text.
207
+ // Only pull back when doing so keeps the forward-progress guard above
208
+ // satisfied; otherwise push forward past the low surrogate instead — the
209
+ // pair's high half is guaranteed already present in the chunk just
210
+ // pushed, so skipping the low half here loses nothing, it just narrows
211
+ // this chunk's overlap by one code unit instead of widening it.
212
+ if (start > 0) {
213
+ const code = text.charCodeAt(start);
214
+ if (code >= 0xdc00 && code <= 0xdfff) {
215
+ if (start - 1 > iterationStart) {
216
+ start -= 1;
217
+ } else if (start + 1 < end) {
218
+ start += 1;
219
+ }
220
+ }
221
+ }
135
222
  }
136
223
 
137
224
  const duration = Date.now() - startTime;