@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
@@ -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) {
@@ -400,18 +417,28 @@ export async function handleWebGPUWarmupError(
400
417
  gpuLockHeld: boolean,
401
418
  model: string,
402
419
  initializationTimeoutMs: number,
403
- useCache: boolean
420
+ useCache: boolean,
421
+ poolingMode: 'mean' | 'cls' | 'last_token' = 'mean'
404
422
  ): Promise<{ success: boolean; pipeline?: FeatureExtractionPipeline; error?: Error; dummy?: any }> {
405
- const isValidationError = warmupErr.message.toLowerCase().includes('validation');
406
-
407
423
  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
- }
424
+
425
+ // Deliberately does NOT try to name the cause from this error's text. The split it
426
+ // replaced ("validation error" vs "OOM", chosen on whether the message contains
427
+ // "validation") reported the SYMPTOM as the diagnosis, and got it backwards in the
428
+ // case that matters: when the GPU is out of VRAM, Dawn raises
429
+ // VK_ERROR_OUT_OF_DEVICE_MEMORY through its device-error callback — native stderr,
430
+ // not this promise — and CreateBuffer then hands back an invalid buffer. What
431
+ // reaches JS is the first thing to *use* that buffer, i.e.
432
+ // "[Invalid Buffer] ... While calling CreateBindGroup", which contains the word
433
+ // "validation" and so was logged as a validation error. A 2026-08-16 run read
434
+ // exactly that way in the log while eighteen vkAllocateMemory failures sat directly
435
+ // above it. The native lines are captured into this same log, so point at them
436
+ // rather than paraphrasing them wrongly.
437
+ logger.warn(
438
+ '[embedder] WebGPU error during warmup — falling back to CPU. ' +
439
+ 'The underlying cause (commonly VRAM exhaustion) is in the native Dawn/ONNX output logged just above this line.',
440
+ );
441
+ logger.debug('[embedder] Warmup error details:', warmupErr.message);
415
442
 
416
443
  // Clean up
417
444
  if (pipeline) {
@@ -425,7 +452,7 @@ export async function handleWebGPUWarmupError(
425
452
  logger.info('[embedder] CPU pipeline loaded successfully');
426
453
 
427
454
  // Warmup CPU pipeline
428
- const { dummy, success, error: cpuWarmupErr } = await warmupPipeline(cpuPipeline, 'mean', useCache);
455
+ const { dummy, success, error: cpuWarmupErr } = await warmupPipeline(cpuPipeline, poolingMode, useCache);
429
456
  if (!success || cpuWarmupErr) {
430
457
  logger.error('[embedder] CPU fallback pipeline warmup failed:', cpuWarmupErr);
431
458
  if (cpuPipeline) {
@@ -331,7 +331,8 @@ export class Embedder {
331
331
  this.gpuLockHeld,
332
332
  this.model,
333
333
  this.initializationTimeoutMs,
334
- this.useCache
334
+ this.useCache,
335
+ this.poolingMode
335
336
  );
336
337
  if (!result.success) {
337
338
  throw result.error;
@@ -404,9 +405,16 @@ export class Embedder {
404
405
  // the VECTOR — the full text is still stored verbatim and covered by FTS — but the
405
406
  // truncation is otherwise silent, so surface it: a spike here means chunks are
406
407
  // arriving larger than the embed cap and vector recall on their tail is degraded.
408
+ let keep = maxChars;
409
+ // slice() counts UTF-16 code units, so the cut can land between the halves of
410
+ // a surrogate pair (emoji, rare CJK) — same guard as truncateWithMarker
411
+ // (utils/text-utils.ts): a lone high surrogate is invalid text some
412
+ // tokenizers/providers choke on. Back off one unit to restore well-formed text.
413
+ const lastKeptUnit = text.charCodeAt(keep - 1);
414
+ if (lastKeptUnit >= 0xd800 && lastKeptUnit <= 0xdbff) keep -= 1;
407
415
  metrics.increment('embedder_truncations_total', 1, { model: this.model });
408
- logger.debug(`[embedder] Truncated input for embedding: ${text.length} -> ${maxChars} chars (model=${this.model})`);
409
- return text.slice(0, maxChars);
416
+ logger.debug(`[embedder] Truncated input for embedding: ${text.length} -> ${keep} chars (model=${this.model})`);
417
+ return text.slice(0, keep);
410
418
  }
411
419
  return text;
412
420
  }
@@ -1,4 +1,5 @@
1
1
  import * as fs from 'node:fs';
2
+ import * as path from 'node:path';
2
3
  import { KnowledgeStore } from './store.ts';
3
4
  import { WriterQueue } from './writer-queue.ts';
4
5
  import { Chunker } from './chunker.ts';
@@ -108,19 +109,44 @@ export async function createKnowledgeStoreComponents(
108
109
  }
109
110
 
110
111
  /**
111
- * Force delete the entire knowledge store directory.
112
- * Used for system-level clearing or recovery from corruption.
112
+ * Force delete the ACTIVE table's own directory, to recover from a corrupted
113
+ * LanceDB manifest. Scoped to that one table, not the whole dbDir: dbDir is a
114
+ * single unified store shared by every workspace and the global scope (rows
115
+ * are scoped by a `workspace` column, not by separate directories), and it
116
+ * also holds `_backup_*`/`_migration_*` sibling directories — the deliberate,
117
+ * user-recoverable safety net the 'backup' migration strategy creates. A
118
+ * directory-wide wipe here previously destroyed every other project's data
119
+ * and every backup the instant ANY workspace's session hit a corrupted
120
+ * manifest, on the one code path whose entire purpose is recovering from
121
+ * exactly that kind of damage.
113
122
  */
114
123
  export async function forceDeleteKnowledgeStore(config?: Config, workspace?: string): Promise<void> {
115
124
  const dbDir = getDbDir(config, workspace);
116
- if (fs.existsSync(dbDir)) {
117
- try {
118
- fs.rmSync(dbDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 });
119
- fs.mkdirSync(dbDir, { recursive: true });
120
- logger.info(`[knowledge] Knowledge store at ${dbDir} forcefully deleted and recreated.`);
121
- } catch (err) {
122
- logger.error(`[knowledge] Failed to forcefully delete directory ${dbDir}:`, err);
123
- throw err;
125
+ if (!fs.existsSync(dbDir)) return;
126
+
127
+ // Mirrors KnowledgeStore's own manifest read (store.ts): the active table
128
+ // name defaults to 'knowledge' but can point elsewhere after a persisted
129
+ // rename failure. A missing or unreadable manifest is not itself a reason to
130
+ // widen the blast radius, so it falls back to the default rather than
131
+ // escalating to a dbDir-wide delete.
132
+ let tableName = 'knowledge';
133
+ const manifestPath = path.join(dbDir, 'store-manifest.json');
134
+ try {
135
+ if (fs.existsSync(manifestPath)) {
136
+ const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf-8')) as { activeTableName?: string };
137
+ if (manifest.activeTableName) tableName = manifest.activeTableName;
124
138
  }
139
+ } catch (err) {
140
+ logger.warn(`[knowledge] Failed to read manifest before forced delete, defaulting to table '${tableName}':`, err);
141
+ }
142
+
143
+ const tableDir = path.join(dbDir, `${tableName}.lance`);
144
+ if (!fs.existsSync(tableDir)) return;
145
+ try {
146
+ fs.rmSync(tableDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 });
147
+ logger.info(`[knowledge] Corrupted table directory ${tableDir} forcefully deleted; will be recreated on next init.`);
148
+ } catch (err) {
149
+ logger.error(`[knowledge] Failed to forcefully delete table directory ${tableDir}:`, err);
150
+ throw err;
125
151
  }
126
152
  }
@@ -502,12 +502,36 @@ export class KnowledgeStore implements IKnowledgeStore {
502
502
  // If canonical name was already something else and it still exists,
503
503
  // we might need to handle it. But we just dropped it above.
504
504
  await fsPromises.rename(tempDir, canonicalDir);
505
-
505
+
506
506
  this.tableName = canonicalName;
507
- await this.saveManifest();
508
-
509
- // Reopen the canonical table so this.table reflects the renamed path
510
- this.table = await this.db.openTable(this.tableName);
507
+ if (await this.saveManifest()) {
508
+ // Reopen the canonical table so this.table reflects the renamed path
509
+ this.table = await this.db.openTable(this.tableName);
510
+ } else {
511
+ // The directory rename to the canonical name already happened on disk,
512
+ // but the manifest still says tempTableName (persisted successfully
513
+ // above, before the old table was dropped). Left as-is, the next
514
+ // process start would read that stale name, find no such directory
515
+ // (renamed away), and silently CREATE A NEW EMPTY TABLE under it —
516
+ // stranding every re-embedded document in the now-unreferenced
517
+ // canonical directory with no recovery path (pruneOrphanedMigrationDirs
518
+ // doesn't recognize a bare 'knowledge.lance' as orphaned migration
519
+ // material). Revert the rename so the manifest (still correctly
520
+ // pointing at tempTableName, from the pre-drop save above) and the
521
+ // on-disk layout agree again — the data stays fully intact and
522
+ // accessible under its temp name either way. Deliberately NOT thrown:
523
+ // the migration itself fully succeeded and the in-memory handle is
524
+ // fine, so surfacing this as a migration failure would trip the
525
+ // caller's re-embed-failed fallback to the 'backup' strategy, which
526
+ // would needlessly rename this perfectly good, fully re-embedded data
527
+ // out of the way and start a fresh empty table over one manifest
528
+ // write hiccup.
529
+ logger.error('[store] Failed to persist canonical table name after rename — reverting to keep manifest and disk in sync.');
530
+ await fsPromises.rename(canonicalDir, tempDir);
531
+ this.tableName = tempTableName;
532
+ this.table = await this.db.openTable(this.tableName);
533
+ logger.info(`[store] Migrated data remains safe and accessible as ${tempTableName}; canonical rename deferred to a future successful manifest save.`);
534
+ }
511
535
  } catch (renameErr) {
512
536
  // If rename fails (cross-device, permissions, etc.) we keep the temp name
513
537
  // but CRITICALLY we now persist it in the manifest so future sessions
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 ?? '') }