@lincoln504/pi-research 1.3.12 → 1.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. package/README.md +2 -2
  2. package/dist/cli.mjs +5495 -4645
  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 +94 -19
  8. package/docs/ARCHITECTURE.md +18 -10
  9. package/docs/CHANGELOG.md +149 -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 +12 -8
  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 +719 -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 +220 -34
  31. package/src/infrastructure/browser/config.ts +41 -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 +32 -2
  36. package/src/infrastructure/browser/thread-worker-messaging.ts +38 -6
  37. package/src/infrastructure/browser/thread-worker.mjs +94 -19
  38. package/src/infrastructure/browser/thread-worker.ts +2 -2
  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/file-lock-service.ts +214 -19
  43. package/src/infrastructure/knowledge-store-service.ts +14 -0
  44. package/src/infrastructure/research-run-semaphore.ts +1 -1
  45. package/src/infrastructure/service-initialization.ts +7 -0
  46. package/src/knowledge/embedder-init.ts +43 -17
  47. package/src/logger.ts +11 -0
  48. package/src/orchestration/deep-research-orchestrator.ts +141 -87
  49. package/src/orchestration/quick-research-orchestrator.ts +11 -2
  50. package/src/orchestration/research-orchestration-service.ts +8 -0
  51. package/src/orchestration/research-synthesis-service.ts +53 -1
  52. package/src/orchestration/researcher-executor.ts +53 -2
  53. package/src/orchestration/researcher.ts +15 -2
  54. package/src/prompts/research-tool-usage.md +1 -1
  55. package/src/prompts/researcher.md +1 -0
  56. package/src/prompts/system-lead-router.md +122 -0
  57. package/src/prompts/system-lead-synthesizer.md +46 -0
  58. package/src/tools/research-knowledge-search.ts +11 -3
  59. package/src/tools/scrape.ts +6 -0
  60. package/src/tools/search.ts +7 -0
  61. package/src/tools/security.ts +27 -9
  62. package/src/tools/stackexchange.ts +13 -7
  63. package/src/tools/youtube-transcript.ts +26 -12
  64. package/src/utils/cancellation.ts +36 -0
  65. package/src/utils/coverage-digest.ts +204 -0
  66. package/src/utils/llm-usage.ts +33 -6
  67. package/src/utils/metrics-summary.ts +20 -4
  68. package/src/utils/metrics.ts +27 -1
  69. package/src/web-research/browser-search.ts +93 -20
  70. package/dist/prompts/system-lead-evaluator.md +0 -118
  71. package/src/prompts/system-lead-evaluator.md +0 -118
@@ -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 {
@@ -44,6 +44,49 @@ const TRASH_SWEEP_AGE_MS = 10 * 60_000;
44
44
  */
45
45
  const HEARTBEAT_MIN_INTERVAL_MS = 2_000;
46
46
 
47
+ /**
48
+ * Ceiling on the held-lock mtime heartbeat interval.
49
+ *
50
+ * The interval derives from liveOwnerStaleThreshold/4, which is sized so a live
51
+ * holder is never judged stale between two refreshes. That is the right shape for
52
+ * the live-owner rule and the wrong size for everything downstream of it: the
53
+ * knowledge-store init lock derived a 60s interval, and _unidentifiedStaleThreshold
54
+ * doubles it, so a lock torn by SIGKILL — unidentifiable by construction — was left
55
+ * alone for two minutes while the acquirer's stall bound gave up after twenty
56
+ * seconds. The recovery the unidentified rule exists to allow could not happen.
57
+ *
58
+ * A utimes every few seconds costs nothing, so cap it. The cap does not change who
59
+ * may be reclaimed: liveOwnerStaleThreshold is untouched, and refreshing more often
60
+ * than required only makes a live holder harder to mistake for a dead one.
61
+ */
62
+ const HEARTBEAT_MAX_INTERVAL_MS = 5_000;
63
+
64
+ /**
65
+ * Ceiling on how long a lock whose owner cannot be identified is left alone.
66
+ *
67
+ * Such a lock is treated as live (we could not prove otherwise), but "treated as
68
+ * live" must not mean "immortal" — see _shouldReclaim for why the never-steal
69
+ * opt-out would otherwise strand a run slot permanently.
70
+ */
71
+ const UNIDENTIFIED_MAX_STALE_MS = 10 * 60_000;
72
+
73
+ /**
74
+ * Absolute ceiling on one acquireLock() wait, however healthy the holder looks.
75
+ *
76
+ * `lockTimeout` bounds a STALL — time in which the lock did not change hands and
77
+ * its holder did not refresh it. It deliberately does not bound a wait behind a
78
+ * holder that is visibly working, because that turned ordinary contention into
79
+ * failed runs: the knowledge-store init lock allowed 20s while the same lock
80
+ * covers a post-run FTS rebuild plus optimize, so a second run starting during
81
+ * another's housekeeping failed outright. Twice, on 2026-08-14. Widening the
82
+ * constant is the fix that does not work — housekeeping has no fixed duration.
83
+ *
84
+ * This ceiling is what keeps "wait while progress is happening" from meaning
85
+ * "wait forever" under sustained contention, where the lock keeps changing hands
86
+ * and every observation looks like progress while this caller never wins.
87
+ */
88
+ export const LOCK_ACQUIRE_CEILING_MS = 5 * 60_000;
89
+
47
90
  /** setTimeout/setInterval clamp delays above 2^31-1 to 1ms — treat anything
48
91
  * bigger as "no heartbeat" rather than a busy-loop. */
49
92
  const MAX_TIMER_DELAY_MS = 0x7fffffff;
@@ -62,6 +105,13 @@ export interface FileLockOptions {
62
105
  lockRetryDelay?: number;
63
106
  /** Stale lock threshold in milliseconds (default: 15 seconds) */
64
107
  lockStaleThreshold?: number;
108
+ /**
109
+ * Absolute ceiling on one acquireLock() wait, however healthy the holder looks
110
+ * (default: LOCK_ACQUIRE_CEILING_MS). `lockTimeout` bounds a stall; this bounds
111
+ * the total. Exposed so the bound is reachable in a test — a five-minute
112
+ * backstop that no test can drive is a five-minute backstop nobody has checked.
113
+ */
114
+ acquireCeilingMs?: number;
65
115
  /**
66
116
  * Stale threshold (ms) applied ONLY when the lock's owner process is provably
67
117
  * alive. Much larger than lockStaleThreshold so a live process holding the lock
@@ -95,6 +145,7 @@ export class FileLockService implements IService {
95
145
  private readonly lockRetryDelay: number;
96
146
  private readonly lockStaleThreshold: number;
97
147
  private readonly liveOwnerStaleThreshold: number;
148
+ private readonly acquireCeilingMs: number;
98
149
  private readonly processLifecycle: IProcessLifecycle | null;
99
150
 
100
151
  // Lock tracking
@@ -128,18 +179,41 @@ export class FileLockService implements IService {
128
179
  this.lockStaleThreshold = options.lockStaleThreshold ?? 15000;
129
180
  this.liveOwnerStaleThreshold =
130
181
  options.liveOwnerStaleThreshold ?? Math.max(120000, this.lockStaleThreshold * 4);
182
+ this.acquireCeilingMs = options.acquireCeilingMs ?? LOCK_ACQUIRE_CEILING_MS;
131
183
  this.processLifecycle = options.processLifecycle ?? null;
132
184
  }
133
185
 
134
186
  /**
135
187
  * Decide whether a contended lock may be reclaimed.
136
- * - Dead owner → reclaim immediately (crash cleanup).
137
- * - Legacy lock (no pid) → reclaim once aged past the normal stale threshold.
138
- * - Provably-alive owner → reclaim ONLY after liveOwnerStaleThreshold, so we
139
- * never steal a lock a live process is actively
140
- * holding during a (possibly slow) critical section.
141
- * Stealing it would let two writers run the same
142
- * read-modify-write and lose an update.
188
+ * - Dead owner → reclaim immediately (crash cleanup).
189
+ * - Provably-alive owner → reclaim ONLY after liveOwnerStaleThreshold, so we
190
+ * never steal a lock a live process is actively
191
+ * holding during a (possibly slow) critical section.
192
+ * Stealing it would let two writers run the same
193
+ * read-modify-write and lose an update.
194
+ * - Unidentifiable (no pid) → judged by whether this lock has a HEARTBEAT to
195
+ * judge it by; see below.
196
+ *
197
+ * The unidentifiable case used to take the short threshold outright, which put it
198
+ * on the crash-cleanup schedule on the strength of no evidence at all — while
199
+ * `_isOwnerAlive(null)` returns true, so the two halves openly disagreed. The
200
+ * forensic cost was the worse half: the reclaim log read
201
+ * "Cleaning up stale lock file (90s old, owner alive: true)", a sentence
202
+ * describing the one thing this class exists not to do.
203
+ *
204
+ * Treating it as fully alive instead was an overcorrection, and it broke a real
205
+ * recovery: a lock file torn by a SIGKILL between `open(...,'wx')` and `write()`
206
+ * is unidentifiable by construction, and holding it for the live-owner threshold
207
+ * made knowledge-store init FAIL where it used to reclaim and proceed.
208
+ *
209
+ * The resolution is that a pid is not the only liveness evidence available. A
210
+ * lock class with a heartbeat has its mtime refreshed by its holder, so an
211
+ * unreadable lock that has ALSO gone motionless past the stale threshold is very
212
+ * likely abandoned — the heartbeat is exactly the proof that does not need a pid.
213
+ * Where there is no heartbeat (the run semaphore opts out of live-owner reclaim,
214
+ * so its slot files never move) age proves nothing, and only the ten-minute
215
+ * ceiling keeps an unreadable slot file from holding a run slot for the life of
216
+ * the machine.
143
217
  */
144
218
  private async _shouldReclaim(
145
219
  pid: number | null,
@@ -148,10 +222,49 @@ export class FileLockService implements IService {
148
222
  memo?: Map<string, boolean>,
149
223
  ): Promise<boolean> {
150
224
  if (!(await this._resolveOwnerAlive(pid, startTime, memo))) return true;
151
- if (pid === null) return lockAge > this.lockStaleThreshold;
225
+ if (pid === null) return lockAge > this._unidentifiedStaleThreshold();
152
226
  return lockAge > this.liveOwnerStaleThreshold;
153
227
  }
154
228
 
229
+ /**
230
+ * How long an unidentifiable lock is left alone before it may be reclaimed.
231
+ *
232
+ * With a heartbeat running for this lock class, a motionless file is evidence of
233
+ * abandonment even though its owner cannot be named, so the ordinary stale
234
+ * threshold applies. Without one — `_startHeartbeat` skips the effectively
235
+ * infinite opt-out — age carries no information and only the absolute ceiling
236
+ * prevents an unreadable file from holding the lock forever.
237
+ */
238
+ private _unidentifiedStaleThreshold(): number {
239
+ const heartbeatInterval = this._heartbeatIntervalMs();
240
+ // Never shorter than a heartbeat interval plus a tick, or a live holder could be
241
+ // reclaimed in the gap between two refreshes.
242
+ return heartbeatInterval === null
243
+ ? UNIDENTIFIED_MAX_STALE_MS
244
+ : Math.max(this.lockStaleThreshold, heartbeatInterval * 2);
245
+ }
246
+
247
+ /**
248
+ * The interval at which a held lock's mtime is refreshed, or null when this lock
249
+ * class opts out of live-owner reclaim entirely and therefore runs no heartbeat.
250
+ *
251
+ * One helper because two call sites depend on the SAME number: the timer that does
252
+ * the refreshing, and the window that judges an unrefreshed lock abandoned. They
253
+ * were computed independently from the same formula, which is a drift waiting to
254
+ * happen in exactly the direction that reclaims a live holder.
255
+ *
256
+ * The opt-out is detected from the DERIVED interval, before the cap: an effectively
257
+ * infinite liveOwnerStaleThreshold is how the run semaphore says "never steal from a
258
+ * live owner", and clamping it into a runnable timer would quietly start a heartbeat
259
+ * there — which in turn would shorten its unidentified window from the ten-minute
260
+ * ceiling to seconds, reclaiming run slots the opt-out exists to protect.
261
+ */
262
+ private _heartbeatIntervalMs(): number | null {
263
+ const derived = Math.ceil(this.liveOwnerStaleThreshold / 4);
264
+ if (derived > MAX_TIMER_DELAY_MS) return null;
265
+ return Math.min(HEARTBEAT_MAX_INTERVAL_MS, Math.max(HEARTBEAT_MIN_INTERVAL_MS, derived));
266
+ }
267
+
155
268
  /**
156
269
  * Liveness of a contended owner, memoized per acquire() episode. The memo (keyed
157
270
  * by pid:startTime) ensures the PID+start-time check — which may spawn a ps/powershell
@@ -292,12 +405,12 @@ export class FileLockService implements IService {
292
405
  */
293
406
  private _startHeartbeat(): void {
294
407
  this._stopHeartbeat();
295
- const interval = Math.max(HEARTBEAT_MIN_INTERVAL_MS, Math.ceil(this.liveOwnerStaleThreshold / 4));
296
408
  // An effectively-infinite threshold (the run-semaphore's never-steal-from-a-
297
409
  // 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;
410
+ // timer delay, which Node clamps to 1ms (a utimes busy-loop). _heartbeatIntervalMs
411
+ // returns null there; the opt-out's semantics are unchanged.
412
+ const interval = this._heartbeatIntervalMs();
413
+ if (interval === null) return;
301
414
  this.heartbeatTimer = setInterval(() => {
302
415
  const handle = this.lockHandle;
303
416
  if (handle === null) return;
@@ -370,8 +483,16 @@ export class FileLockService implements IService {
370
483
  const ownerAlive = await this._resolveOwnerAlive(parsed?.pid ?? null, parsed?.startTime ?? null, memo);
371
484
 
372
485
  if (await this._shouldReclaim(parsed?.pid ?? null, parsed?.startTime ?? null, lockAge, memo)) {
486
+ // Name the lock and say what was actually known about its owner. The old
487
+ // line printed "owner alive: true" for a lock with no readable pid —
488
+ // because unknown owners report as alive — so the reclaim read as a steal
489
+ // from a living process, and it named no path, in a log every concurrent
490
+ // process shares.
491
+ const owner = parsed?.pid == null
492
+ ? 'owner unidentifiable (unreadable lock content)'
493
+ : `owner pid ${parsed.pid} ${ownerAlive ? 'alive but silent' : 'gone'}`;
373
494
  logger.log(
374
- `[FileLockService] Cleaning up stale lock file (${Math.round(lockAge / 1000)}s old, owner alive: ${ownerAlive})`
495
+ `[FileLockService] Reclaiming ${this.lockName} — untouched for ${Math.round(lockAge / 1000)}s, ${owner}`
375
496
  );
376
497
  // The liveness lookups above can take long enough for another process to
377
498
  // reclaim the stale lock and write its own fresh one at this path. A bare
@@ -391,7 +512,7 @@ export class FileLockService implements IService {
391
512
  return;
392
513
  }
393
514
  await fs.unlink(trashPath).catch(() => { /* leaked copy; the sweep above collects it */ });
394
- logger.log('[FileLockService] Stale lock removed');
515
+ logger.log(`[FileLockService] Reclaimed ${this.lockName}`);
395
516
  } catch {
396
517
  // A failure AFTER the rename means the verify never ran — restore
397
518
  // rather than leave the (possibly live) lock destroyed in the trash.
@@ -469,14 +590,39 @@ export class FileLockService implements IService {
469
590
  this.resolveTurn = myResolve;
470
591
 
471
592
  const startTime = Date.now();
593
+ // Last moment the contended lock demonstrably moved: it changed hands, or its
594
+ // holder refreshed the mtime its heartbeat maintains. `lockTimeout` is measured
595
+ // from here rather than from startTime, so waiting behind work in progress is
596
+ // free and only a genuinely motionless lock times out.
597
+ let lastProgressAt = startTime;
598
+ let lastSeenUuid: string | null = null;
599
+ let lastSeenMtime = 0;
472
600
  let contentionCount = 0;
473
601
  // One entry per distinct (pid,startTime) owner seen during THIS acquireLock()
474
602
  // call, so a live contended owner waited out across many ~100ms retry ticks is
475
603
  // liveness-checked once, not once per tick (no ps/powershell storm on non-Linux).
476
604
  const contendedOwnerAliveMemo = new Map<string, boolean>();
477
605
 
606
+ // `lockRetries` was sized to expire at roughly the same moment as the old
607
+ // elapsed-time timeout (200 x 100ms = 20s), so leaving it alone would cap the
608
+ // progress-aware wait right back at the bound it replaces. A try-take
609
+ // (lockTimeout 0) keeps its configured count exactly: the run semaphore sizes
610
+ // it to complete a dead-owner reclaim and reacquire, and must still fail fast.
611
+ const maxAttempts = this.lockTimeout === 0
612
+ ? this.lockRetries
613
+ : this.lockRetries + Math.ceil(this.acquireCeilingMs / Math.max(1, this.lockRetryDelay));
614
+
478
615
  try {
479
- for (let _attempt = 0; _attempt < this.lockRetries; _attempt++) {
616
+ for (let _attempt = 0; _attempt < maxAttempts; _attempt++) {
617
+ // Bound EVERY iteration, not just the ones that reach the timeout check
618
+ // below: the reclaim branches `continue` without sleeping, so a lock that
619
+ // kept changing hands could otherwise spin here indefinitely.
620
+ if (this.lockTimeout > 0 && Date.now() - startTime >= this.acquireCeilingMs) {
621
+ metrics.increment('state_lock_acquire_total', 1, { status: 'timeout' });
622
+ throw new Error(
623
+ `Failed to acquire lock at ${this.lockFilePath}: gave up after ${Date.now() - startTime}ms of contention (ceiling ${this.acquireCeilingMs}ms)`,
624
+ );
625
+ }
480
626
  // cleanup() can retire the instance while we are contending here — its
481
627
  // drain polls only the HELD handle, so a caller mid-retry (holding
482
628
  // nothing yet) is invisible to it and would otherwise win the lock and
@@ -548,6 +694,12 @@ export class FileLockService implements IService {
548
694
  const errnoError = error as NodeJS.ErrnoException;
549
695
  if (errnoError.code === 'EEXIST') {
550
696
  contentionCount++;
697
+ // When the contended lock becomes eligible for reclaim, if this tick can
698
+ // know it. Scoped per observation: a tick that could not read the lock
699
+ // learned nothing, and an eligibility carried over from an earlier one is
700
+ // no longer evidence of anything. See the stall check below for why the
701
+ // stall bound must not fire before this moment.
702
+ let reclaimEligibleAt: number | null = null;
551
703
  // Read lock content to verify ownership and liveness before considering stale
552
704
  try {
553
705
  const rawContent = await fs.readFile(this.lockFilePath, 'utf-8');
@@ -556,6 +708,30 @@ export class FileLockService implements IService {
556
708
  const stats = await fs.stat(this.lockFilePath);
557
709
  const lockAge = Date.now() - stats.mtimeMs;
558
710
 
711
+ // Progress: a different owner than last time, or the same owner's
712
+ // heartbeat freshening the file. Either way the system is moving and
713
+ // this wait is queueing, not hanging.
714
+ if (lastSeenUuid !== null && (lockUuid !== lastSeenUuid || stats.mtimeMs > lastSeenMtime)) {
715
+ lastProgressAt = Date.now();
716
+ }
717
+ lastSeenUuid = lockUuid;
718
+ lastSeenMtime = stats.mtimeMs;
719
+
720
+ // A lock with no readable pid is judged purely on age (see
721
+ // _shouldReclaim), and its mtime never moves again — so it can never
722
+ // show progress and the stall bound is guaranteed to expire first.
723
+ // Record when it becomes reclaimable so the wait below can outlast the
724
+ // stall bound rather than abandoning the recovery at 20s.
725
+ //
726
+ // Deliberately NOT extended to a live owner: there the heartbeat is the
727
+ // designed progress signal, its absence past lockTimeout is real evidence
728
+ // of a wedge, and reporting that to the caller promptly beats waiting out
729
+ // a two-to-four-minute live-owner threshold on a lock we may well never
730
+ // be handed.
731
+ reclaimEligibleAt = (parsed?.pid ?? null) === null
732
+ ? stats.mtimeMs + this._unidentifiedStaleThreshold()
733
+ : null;
734
+
559
735
  if (lockUuid === this.lockUuid) {
560
736
  // This is our own lock — a previous attempt was interrupted. Reclaim.
561
737
  try { await fs.unlink(this.lockFilePath); } catch { /* ignore */ }
@@ -613,12 +789,31 @@ export class FileLockService implements IService {
613
789
  // Fall through to the timeout check + backoff below.
614
790
  }
615
791
 
616
- if (Date.now() - startTime >= this.lockTimeout) {
617
- const duration = Date.now() - startTime;
618
- metrics.observe('state_lock_acquire_duration_ms', duration, { status: 'timeout' });
792
+ const stalledFor = Date.now() - lastProgressAt;
793
+ const waitedFor = Date.now() - startTime;
794
+ // Never give up on a lock we are about to be ALLOWED to take. The stall
795
+ // bound and the reclaim window are two different clocks, and on every
796
+ // lock class configured here the stall bound is the shorter one — so a
797
+ // torn, ownerless lock timed the acquirer out before its reclaim window
798
+ // opened, and the pid-less recovery path could not run. The knowledge-store
799
+ // init lock is the measured case: reclaimable at 60s, abandoned at 20s.
800
+ // The ceiling below is still absolute.
801
+ // A try-take (lockTimeout 0) is excluded outright: it is configured to fail
802
+ // on the spot rather than wait for anything, and the run semaphore's slot
803
+ // files carry a TEN-MINUTE unidentified window, so honouring eligibility
804
+ // there would turn "is this slot free right now" into a ten-minute block.
805
+ const awaitingReclaim =
806
+ this.lockTimeout > 0 && reclaimEligibleAt !== null && Date.now() < reclaimEligibleAt;
807
+ if ((stalledFor >= this.lockTimeout && !awaitingReclaim) || waitedFor >= this.acquireCeilingMs) {
808
+ metrics.observe('state_lock_acquire_duration_ms', waitedFor, { status: 'timeout' });
619
809
  metrics.increment('state_lock_acquire_total', 1, { status: 'timeout' });
810
+ // Say which bound was hit. "Timeout after 20000ms" on a lock whose
811
+ // holder was working the whole time sent readers looking for a
812
+ // deadlock that was never there.
620
813
  throw new Error(
621
- `Failed to acquire lock at ${this.lockFilePath}: timeout after ${this.lockTimeout}ms`,
814
+ waitedFor >= this.acquireCeilingMs
815
+ ? `Failed to acquire lock at ${this.lockFilePath}: gave up after ${waitedFor}ms of contention (ceiling ${this.acquireCeilingMs}ms)`
816
+ : `Failed to acquire lock at ${this.lockFilePath}: timeout after ${this.lockTimeout}ms with no sign of progress from its holder`,
622
817
  { cause: error },
623
818
  );
624
819
  }
@@ -193,6 +193,20 @@ export class KnowledgeStoreService implements IKnowledgeStoreService {
193
193
  this._initLock = new FileLockService({
194
194
  lockFilePath: lockPath,
195
195
  lockStaleThreshold: 60000,
196
+ // The default lockTimeout (20s, class default) is the WAITING side's own
197
+ // "give up and throw" bound — separate from, and much shorter than, the
198
+ // liveOwnerStaleThreshold (120s default) that actually governs when a
199
+ // provably-alive holder's lock may be physically reclaimed. This lock
200
+ // protects a post-run FTS rebuild plus optimize with no fixed duration; if
201
+ // that holder has a stretch of non-yielding synchronous work (a single
202
+ // large better-sqlite3 call blocks the event loop for its full duration,
203
+ // during which the heartbeat literally cannot fire) longer than 20s, a
204
+ // waiting caller would abandon and throw "no sign of progress from its
205
+ // holder" against a holder that was never actually wedged — up to 100s
206
+ // before the lock would even become reclaim-eligible. Raised to match
207
+ // liveOwnerStaleThreshold's own default so a live holder is given the same
208
+ // grace period here that the reclaim logic itself already grants it.
209
+ lockTimeout: 120000,
196
210
  processLifecycle,
197
211
  });
198
212
  await this._initLock.initialize();
@@ -184,7 +184,7 @@ export class ResearchRunSemaphore implements IService {
184
184
  lockTimeout: 0, // fail immediately on a live-contended slot (try-take)
185
185
  lockRetries: 24, // enough iterations to complete a dead-owner reclaim + reacquire
186
186
  lockRetryDelay: 50,
187
- lockStaleThreshold: 15000, // legacy (no-pid) locks: reclaim once 15s old
187
+ lockStaleThreshold: 15000, // derivation base only; no-pid locks now follow the live-owner rule under its 10-minute ceiling
188
188
  // A live process holding a run slot for the entire (minutes-long) run is the
189
189
  // NORMAL case, not a stall. Never steal from a live owner — doing so would
190
190
  // admit an (N+1)th concurrent run, the exact condition this guard prevents.
@@ -65,6 +65,13 @@ export function registerInfrastructureServices(container: ServiceContainer = get
65
65
  return new FileLockService({
66
66
  lockFilePath: pathConfig.getLockFilePath(),
67
67
  processLifecycle,
68
+ // The shared state lock guards a temp-file write plus fsync plus rename —
69
+ // sub-second work, unlike the knowledge-store init lock that the default
70
+ // five-minute ceiling was sized for (an FTS rebuild has no fixed duration).
71
+ // Inheriting that default here would let SDK teardown, which awaits a state
72
+ // write, stall for five minutes under sustained contention where it used to
73
+ // fail in twenty seconds. A ceiling should match what its lock protects.
74
+ acquireCeilingMs: 30_000,
68
75
  });
69
76
  },
70
77
  {
@@ -73,14 +73,31 @@ export async function warmupPipeline(
73
73
  useCache: boolean
74
74
  ): Promise<{ dummy: any; success: boolean; error?: Error }> {
75
75
  try {
76
- const dummy = await withTimeout(
77
- pipeline('warmup', {
78
- pooling: poolingMode as any,
79
- normalize: false,
80
- ...(useCache === false ? { use_cache: false } : {}),
81
- }),
82
- 20_000,
83
- 'Model warmup timed out after 20000ms.'
76
+ // Inside runCapturingStderr for the same reason the load above is, and for one
77
+ // more: warmup is the FIRST inference, so it is where a GPU that cannot serve the
78
+ // model actually fails. transformers.js reports that failure by printing
79
+ // "An error occurred during model execution: ..." plus a dump of the input tensors
80
+ // to console.error BEFORE rejecting — and this call was the only pipeline
81
+ // invocation in the module left unwrapped, so that dump went straight to the
82
+ // user's terminal. It is pure noise there: the rejection is caught two frames up,
83
+ // the embedder falls back to CPU, and the run continues to completion. What the
84
+ // user saw was a multi-line raw error for something that had already been handled.
85
+ //
86
+ // The load's capture ends before this runs, so wrapping the caller is not enough;
87
+ // and the server's captureStdio around startServer() covers only the FIRST
88
+ // initialization, not the re-initialization that follows every idle-timeout
89
+ // teardown — which is exactly when this was observed (2026-08-16, the fourth
90
+ // init of the session).
91
+ const dummy = await logger.runCapturingStderr(async () =>
92
+ withTimeout(
93
+ pipeline('warmup', {
94
+ pooling: poolingMode as any,
95
+ normalize: false,
96
+ ...(useCache === false ? { use_cache: false } : {}),
97
+ }),
98
+ 20_000,
99
+ 'Model warmup timed out after 20000ms.'
100
+ ),
84
101
  );
85
102
  return { dummy, success: true };
86
103
  } catch (warmupErr) {
@@ -402,16 +419,25 @@ export async function handleWebGPUWarmupError(
402
419
  initializationTimeoutMs: number,
403
420
  useCache: boolean
404
421
  ): Promise<{ success: boolean; pipeline?: FeatureExtractionPipeline; error?: Error; dummy?: any }> {
405
- const isValidationError = warmupErr.message.toLowerCase().includes('validation');
406
-
407
422
  markWebGpuFallback();
408
-
409
- if (isValidationError) {
410
- logger.warn('[embedder] WebGPU validation error during warmup — falling back to CPU');
411
- logger.debug('[embedder] Validation error details:', warmupErr.message);
412
- } else {
413
- logger.warn('[embedder] WebGPU OOM during warmup — falling back to CPU');
414
- }
423
+
424
+ // Deliberately does NOT try to name the cause from this error's text. The split it
425
+ // replaced ("validation error" vs "OOM", chosen on whether the message contains
426
+ // "validation") reported the SYMPTOM as the diagnosis, and got it backwards in the
427
+ // case that matters: when the GPU is out of VRAM, Dawn raises
428
+ // VK_ERROR_OUT_OF_DEVICE_MEMORY through its device-error callback — native stderr,
429
+ // not this promise — and CreateBuffer then hands back an invalid buffer. What
430
+ // reaches JS is the first thing to *use* that buffer, i.e.
431
+ // "[Invalid Buffer] ... While calling CreateBindGroup", which contains the word
432
+ // "validation" and so was logged as a validation error. A 2026-08-16 run read
433
+ // exactly that way in the log while eighteen vkAllocateMemory failures sat directly
434
+ // above it. The native lines are captured into this same log, so point at them
435
+ // rather than paraphrasing them wrongly.
436
+ logger.warn(
437
+ '[embedder] WebGPU error during warmup — falling back to CPU. ' +
438
+ 'The underlying cause (commonly VRAM exhaustion) is in the native Dawn/ONNX output logged just above this line.',
439
+ );
440
+ logger.debug('[embedder] Warmup error details:', warmupErr.message);
415
441
 
416
442
  // Clean up
417
443
  if (pipeline) {
package/src/logger.ts CHANGED
@@ -259,6 +259,17 @@ export class Logger implements ILogger {
259
259
  timestamp,
260
260
  level,
261
261
  ...getLogContext(),
262
+ // Every process writes to the SAME log file — up to three concurrent research
263
+ // runs plus the elected browser-pool leader — and until this field existed
264
+ // there was nothing to tell their lines apart. Reconstructing a lock
265
+ // contention from the log then meant inferring process boundaries from
266
+ // timing, which is guesswork: two runs a second apart interleave into what
267
+ // reads like one process contradicting itself. Twelve bytes a line buys
268
+ // attributable forensics.
269
+ //
270
+ // After the context spread, not before: which process wrote a line is not
271
+ // something a scoped context gets to claim otherwise.
272
+ pid: process.pid,
262
273
  message,
263
274
  ...(firstError
264
275
  ? { errorMessage: redactSecrets(firstError.message), errorStack: redactSecrets(firstError.stack ?? '') }