@lincoln504/pi-research 1.1.2 → 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 (119) hide show
  1. package/README.md +3 -3
  2. package/dist/cli.mjs +2599 -1268
  3. package/dist/thread-worker.mjs +224 -147
  4. package/docs/AGENT-SKILL.md +11 -4
  5. package/docs/ARCHITECTURE.md +20 -10
  6. package/docs/CHANGELOG.md +195 -17
  7. package/docs/CONFIGURATION.md +9 -7
  8. package/docs/KNOWLEDGE-STORE.md +4 -3
  9. package/docs/PI-EXTENSION.md +1 -1
  10. package/docs/SDK.md +55 -0
  11. package/docs/THIRD-PARTY-NOTICES.md +1 -1
  12. package/package.json +5 -4
  13. package/scripts/cleanup.cjs +114 -12
  14. package/scripts/prepare.cjs +15 -5
  15. package/scripts/setup.cjs +24 -4
  16. package/skills/pi-research/README.md +9 -2
  17. package/skills/pi-research/SKILL.md +6 -3
  18. package/skills/pi-research/references/configuration.md +6 -2
  19. package/skills/pi-research/scripts/run.mjs +24 -2
  20. package/src/cleanup/research-cleanup.ts +12 -4
  21. package/src/cli.ts +372 -86
  22. package/src/config.ts +134 -17
  23. package/src/constants.ts +31 -0
  24. package/src/core/interfaces/health-check-interfaces.ts +3 -39
  25. package/src/core/interfaces/knowledge-interfaces.ts +6 -1
  26. package/src/core/interfaces/planning-interfaces.ts +8 -0
  27. package/src/core/interfaces/service-names.ts +0 -1
  28. package/src/core/interfaces/state-manager-interfaces.ts +11 -3
  29. package/src/core/llm/agentic-repair.ts +14 -0
  30. package/src/core/pi-version.ts +136 -0
  31. package/src/core/planning-service.ts +43 -1
  32. package/src/core/service-initialization.ts +6 -14
  33. package/src/core/service-interfaces.ts +1 -1
  34. package/src/core/service-registry.ts +117 -8
  35. package/src/healthcheck/index.ts +7 -2
  36. package/src/index.ts +11 -18
  37. package/src/infrastructure/browser/browser-client.ts +80 -13
  38. package/src/infrastructure/browser/browser-error-utils.ts +6 -1
  39. package/src/infrastructure/browser/browser-server.ts +41 -12
  40. package/src/infrastructure/browser/browser-task-scheduler.ts +159 -35
  41. package/src/infrastructure/browser/cleanup-utils.ts +95 -9
  42. package/src/infrastructure/browser/client-agent.ts +6 -5
  43. package/src/infrastructure/browser/config.ts +14 -3
  44. package/src/infrastructure/browser/priority-task-queue.ts +10 -3
  45. package/src/infrastructure/browser/scheduler-factory.ts +44 -4
  46. package/src/infrastructure/browser/task-execution-service.ts +92 -28
  47. package/src/infrastructure/browser/thread-worker-lifecycle.ts +50 -13
  48. package/src/infrastructure/browser/thread-worker-messaging.ts +54 -2
  49. package/src/infrastructure/browser/thread-worker-types.ts +3 -1
  50. package/src/infrastructure/browser/thread-worker.mjs +224 -147
  51. package/src/infrastructure/browser/worker-pool-manager.ts +18 -5
  52. package/src/infrastructure/embedding/embedding-client.ts +22 -8
  53. package/src/infrastructure/embedding/embedding-factory.ts +61 -15
  54. package/src/infrastructure/embedding/embedding-server.ts +105 -11
  55. package/src/infrastructure/file-lock-service.ts +295 -28
  56. package/src/infrastructure/knowledge-store-service.ts +48 -3
  57. package/src/infrastructure/process-lifecycle-service.ts +134 -34
  58. package/src/infrastructure/research-run-semaphore.ts +37 -3
  59. package/src/infrastructure/state/state-backup-manager.ts +5 -7
  60. package/src/infrastructure/state/state-browser-api.ts +5 -2
  61. package/src/infrastructure/state/state-browser-manager.ts +15 -1
  62. package/src/infrastructure/state/state-manager-service.ts +5 -5
  63. package/src/infrastructure/state/state-manager.ts +34 -23
  64. package/src/infrastructure/types/state-types.ts +9 -0
  65. package/src/knowledge/chunker.ts +88 -1
  66. package/src/knowledge/embedder-init.ts +1 -1
  67. package/src/knowledge/embedder.ts +188 -46
  68. package/src/knowledge/index.ts +1 -1
  69. package/src/knowledge/store.ts +101 -21
  70. package/src/knowledge/webgpu-viability.ts +9 -4
  71. package/src/knowledge/writer-queue.ts +88 -46
  72. package/src/logger.ts +5 -1
  73. package/src/observers/research-observer-impl.ts +31 -0
  74. package/src/orchestration/abort-utils.ts +60 -0
  75. package/src/orchestration/deep-research-orchestrator.ts +25 -4
  76. package/src/orchestration/headless-observer.ts +8 -0
  77. package/src/orchestration/quick-research-orchestrator.ts +2 -2
  78. package/src/orchestration/research-orchestration-service.ts +65 -15
  79. package/src/orchestration/research-session-service.ts +37 -11
  80. package/src/orchestration/research-synthesis-service.ts +33 -11
  81. package/src/orchestration/researcher-executor.ts +100 -20
  82. package/src/orchestration/researcher.ts +39 -20
  83. package/src/orchestration/session-state.ts +11 -0
  84. package/src/sdk.ts +125 -19
  85. package/src/security/cisa-kev.ts +10 -3
  86. package/src/security/github-advisories.ts +18 -7
  87. package/src/security/index.ts +51 -30
  88. package/src/security/nvd-client.ts +24 -8
  89. package/src/security/osv-client.ts +14 -4
  90. package/src/skill-install/skill-installer.ts +153 -11
  91. package/src/stackexchange/index.ts +20 -5
  92. package/src/stackexchange/output/table.ts +7 -2
  93. package/src/stackexchange/rest-client.ts +41 -3
  94. package/src/tools/research-knowledge-search.ts +15 -0
  95. package/src/tools/research-tool-definition.ts +8 -2
  96. package/src/tools/scrape.ts +48 -25
  97. package/src/tools/search.ts +16 -2
  98. package/src/tools/security.ts +68 -7
  99. package/src/tui/research-health.ts +18 -2
  100. package/src/utils/atomic-replace.ts +101 -0
  101. package/src/utils/circuit-breaker.ts +9 -2
  102. package/src/utils/citation-utils.ts +3 -1
  103. package/src/utils/disk-space-checker.ts +11 -6
  104. package/src/utils/http-body.ts +176 -0
  105. package/src/utils/json-utils.ts +116 -76
  106. package/src/utils/llm-usage.ts +17 -1
  107. package/src/utils/log-rotation.ts +42 -10
  108. package/src/utils/log-utils.ts +40 -0
  109. package/src/utils/metrics-summary.ts +27 -9
  110. package/src/utils/stdio-capture.ts +63 -22
  111. package/src/utils/text-utils.ts +8 -1
  112. package/src/utils/tool-usage-tracker.ts +9 -2
  113. package/src/web-research/browser-search.ts +70 -5
  114. package/src/web-research/scraper-utils.ts +13 -0
  115. package/src/web-research/search.ts +25 -6
  116. package/src/web-research/types.ts +19 -4
  117. package/src/web-research/web-scraper.ts +72 -54
  118. package/src/youtube/transcript-client.ts +5 -1
  119. package/src/core/health-check-service.ts +0 -265
@@ -23,6 +23,31 @@ import type { IProcessLifecycle } from '../core/interfaces/process-interfaces.ts
23
23
  */
24
24
  const lockContext = new AsyncLocalStorage<Set<string>>();
25
25
 
26
+ /**
27
+ * How long cleanup() waits for an in-flight critical section to release normally
28
+ * before forcing the teardown. Long enough for an ordinary state write (temp file
29
+ * + fsync + rename) to finish, short enough that shutdown still makes progress
30
+ * when the holder is wedged.
31
+ */
32
+ const CLEANUP_DRAIN_TIMEOUT_MS = 5_000;
33
+
34
+ /**
35
+ * Age past which an orphaned `<lock>.trash.<hex>` copy is swept at startup. An
36
+ * in-flight trash-and-verify holds its copy for milliseconds; anything this old
37
+ * was leaked by a crash mid-dance.
38
+ */
39
+ const TRASH_SWEEP_AGE_MS = 10 * 60_000;
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
+
26
51
  /**
27
52
  * Lock configuration options
28
53
  */
@@ -83,9 +108,20 @@ export class FileLockService implements IService {
83
108
  // PID reuse. Null when unavailable (no processLifecycle, or lookup failed) —
84
109
  // then lock content omits startTime and readers treat us as a legacy owner.
85
110
  private ownStartTime: number | null = null;
111
+ // Set by cleanup(). A retired instance no longer manages the lock file, so
112
+ // acquiring through it would hand out exclusivity it cannot honour.
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;
86
121
 
87
122
  constructor(options: FileLockOptions) {
88
123
  this.lockFilePath = options.lockFilePath;
124
+ this.lockName = pathmod.basename(options.lockFilePath);
89
125
  this.lockTimeout = options.lockTimeout ?? 20000;
90
126
  this.lockRetries = options.lockRetries ?? 200;
91
127
  this.lockRetryDelay = options.lockRetryDelay ?? 100;
@@ -175,11 +211,153 @@ export class FileLockService implements IService {
175
211
  this.lifecycle = ServiceLifecycle.DISPOSED;
176
212
  }
177
213
 
214
+ /**
215
+ * Put a lock file back after a trash-and-verify decided not to reclaim it.
216
+ *
217
+ * The old form linked it back inside a swallowing catch and then unlinked the
218
+ * trash copy unconditionally — so any link failure other than "a peer already
219
+ * re-created it" DESTROYED the lock this branch exists to preserve. Distinguish
220
+ * the two: if the path is occupied again the peer's lock wins and ours is dropped;
221
+ * otherwise move ours back rather than deleting it.
222
+ */
223
+ private async _restoreLockFile(trashPath: string): Promise<void> {
224
+ try {
225
+ await fs.link(trashPath, this.lockFilePath);
226
+ await fs.unlink(trashPath).catch(() => { /* link succeeded; copy is redundant */ });
227
+ return;
228
+ } catch { /* fall through to the two cases below */ }
229
+
230
+ const peerHoldsIt = await fs.access(this.lockFilePath).then(() => true).catch(() => false);
231
+ if (peerHoldsIt) {
232
+ // Someone re-created the lock while we were checking. Theirs is authoritative.
233
+ await fs.unlink(trashPath).catch(() => { /* best effort */ });
234
+ return;
235
+ }
236
+ // Nothing there and link failed (EPERM, filesystems without hardlinks,
237
+ // Windows link limits): re-create the path EXCLUSIVELY rather than rename it
238
+ // back — rename silently REPLACES a lock a peer creates between the probe
239
+ // above and now, clobbering a live owner. 'wx' loses that race loudly, and
240
+ // then the peer's lock is authoritative.
241
+ try {
242
+ const content = await fs.readFile(trashPath, 'utf-8');
243
+ const handle = await fs.open(this.lockFilePath, 'wx', 0o600);
244
+ try {
245
+ await handle.write(content);
246
+ await handle.sync();
247
+ } finally {
248
+ await handle.close().catch(() => { /* content is on disk */ });
249
+ }
250
+ await fs.unlink(trashPath).catch(() => { /* restored; copy is redundant */ });
251
+ } catch {
252
+ // EEXIST (a peer took the path) — ours is redundant; anything else, leave
253
+ // the trash copy for the startup sweep. Never delete what we could not
254
+ // put back.
255
+ const occupied = await fs.access(this.lockFilePath).then(() => true).catch(() => false);
256
+ if (occupied) await fs.unlink(trashPath).catch(() => { /* best effort */ });
257
+ }
258
+ }
259
+
260
+ /**
261
+ * Hand our turn back to the next caller in the FIFO queue.
262
+ *
263
+ * Every path that stops holding the lock must run this, including the ones that
264
+ * find the handle already gone. `acquireLock` parks on `await previous` with no
265
+ * timeout of its own — the lockTimeout only starts counting once the turn has
266
+ * been granted — so a turn that is never resolved is not a slow acquire, it is a
267
+ * permanent one.
268
+ */
269
+ private _releaseTurn(): void {
270
+ const resolve = this.resolveTurn;
271
+ this.resolveTurn = null;
272
+ if (resolve) resolve();
273
+ }
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
+
317
+ /**
318
+ * Wait, bounded, for an in-flight critical section to finish.
319
+ *
320
+ * Yanking a held lock is not a clean teardown: unlinking the file while the
321
+ * holder is still mid read-modify-write drops cross-process exclusion, and a peer
322
+ * that acquires in that window loses the holder's update — precisely the hazard
323
+ * the lock exists to prevent. Since every caller of {@link cleanup} is a shutdown
324
+ * path, waiting briefly for a normal release is both safe and correct.
325
+ *
326
+ * Bounded because shutdown must make progress regardless: if the holder is wedged
327
+ * (or is itself the caller, which would otherwise deadlock), we give up and force
328
+ * the teardown, which is the pre-existing behaviour.
329
+ */
330
+ private async _drainHeldLock(timeoutMs: number): Promise<boolean> {
331
+ const deadline = Date.now() + timeoutMs;
332
+ while (this.lockHandle !== null && Date.now() < deadline) {
333
+ await this.sleep(Math.min(25, this.lockRetryDelay));
334
+ }
335
+ return this.lockHandle === null;
336
+ }
337
+
178
338
  /**
179
339
  * Clean up any stale lock files on initialization.
180
340
  * This handles cases where locks weren't released due to crashes.
181
341
  */
182
342
  private async cleanupStaleLocksOnStartup(): Promise<void> {
343
+ // Sweep trash copies leaked by an interrupted trash-and-verify (a crash or
344
+ // I/O failure between the rename and the unlink/restore). Nothing else ever
345
+ // collects them: the state-dir orphan sweep matches only *.tmp files. The age
346
+ // gate is generous — an in-flight reclaim's trash copy lives for milliseconds,
347
+ // and a restore-worthy one is put back on the same code path that made it.
348
+ try {
349
+ const dir = pathmod.dirname(this.lockFilePath);
350
+ const prefix = `${pathmod.basename(this.lockFilePath)}.trash.`;
351
+ for (const name of await fs.readdir(dir)) {
352
+ if (!name.startsWith(prefix)) continue;
353
+ const p = pathmod.join(dir, name);
354
+ const st = await fs.stat(p).catch(() => null);
355
+ if (st && Date.now() - st.mtimeMs > TRASH_SWEEP_AGE_MS) {
356
+ await fs.unlink(p).catch(() => { /* best effort */ });
357
+ }
358
+ }
359
+ } catch { /* best-effort hygiene; never blocks startup */ }
360
+
183
361
  try {
184
362
  const rawContent = await fs.readFile(this.lockFilePath, 'utf-8').catch(() => null);
185
363
  if (rawContent === null) return; // No lock file — nothing to do
@@ -201,19 +379,25 @@ export class FileLockService implements IService {
201
379
  // the UUID matches what we decided on — same guard as the contended path.
202
380
  const expectedUuid = parsed?.uuid ?? '';
203
381
  const trashPath = `${this.lockFilePath}.trash.${crypto.randomBytes(8).toString('hex')}`;
382
+ let renamed = false;
204
383
  try {
205
384
  await fs.rename(this.lockFilePath, trashPath);
385
+ renamed = true;
206
386
  const trashContent = await fs.readFile(trashPath, 'utf-8');
207
387
  const trashParsed = this._parseLockContent(trashContent);
208
388
  if ((trashParsed?.uuid ?? '') !== expectedUuid) {
209
389
  // Lock changed hands mid-check — restore it and back off.
210
- try { await fs.link(trashPath, this.lockFilePath); } catch { /* ignore */ }
211
- await fs.unlink(trashPath);
390
+ await this._restoreLockFile(trashPath);
212
391
  return;
213
392
  }
214
- await fs.unlink(trashPath);
393
+ await fs.unlink(trashPath).catch(() => { /* leaked copy; the sweep above collects it */ });
215
394
  logger.log('[FileLockService] Stale lock removed');
216
- } catch { /* best-effort startup cleanup; acquire() handles contention */ }
395
+ } catch {
396
+ // A failure AFTER the rename means the verify never ran — restore
397
+ // rather than leave the (possibly live) lock destroyed in the trash.
398
+ if (renamed) await this._restoreLockFile(trashPath);
399
+ /* otherwise best-effort startup cleanup; acquire() handles contention */
400
+ }
217
401
  }
218
402
  } catch (error: unknown) {
219
403
  // ENOENT is expected (no lock file exists)
@@ -232,6 +416,9 @@ export class FileLockService implements IService {
232
416
  * @throws Error if unable to acquire lock within timeout
233
417
  */
234
418
  async acquireLock(): Promise<void> {
419
+ if (this._retired) {
420
+ throw new Error(`Cannot acquire lock at ${this.lockFilePath}: service has been disposed`);
421
+ }
235
422
  const heldLocks = lockContext.getStore();
236
423
  const lockKey = `${this.lockFilePath}:${this.lockUuid}`;
237
424
 
@@ -270,6 +457,14 @@ export class FileLockService implements IService {
270
457
  return;
271
458
  }
272
459
 
460
+ // Re-check after the wait: cleanup() may have retired the instance while we
461
+ // were queued. Hand the turn straight on rather than acquiring a lock file this
462
+ // instance no longer owns.
463
+ if (this._retired) {
464
+ myResolve();
465
+ throw new Error(`Cannot acquire lock at ${this.lockFilePath}: service was disposed while queued`);
466
+ }
467
+
273
468
  // Capture resolve function so we can trigger it in releaseLock() or on failure
274
469
  this.resolveTurn = myResolve;
275
470
 
@@ -282,6 +477,15 @@ export class FileLockService implements IService {
282
477
 
283
478
  try {
284
479
  for (let _attempt = 0; _attempt < this.lockRetries; _attempt++) {
480
+ // cleanup() can retire the instance while we are contending here — its
481
+ // drain polls only the HELD handle, so a caller mid-retry (holding
482
+ // nothing yet) is invisible to it and would otherwise win the lock and
483
+ // run its critical section against torn-down services after dispose
484
+ // resolved. The queued-waiter check above cannot catch this: our turn was
485
+ // granted before retirement. Abort; the catch below hands the turn on.
486
+ if (this._retired) {
487
+ throw new Error(`Cannot acquire lock at ${this.lockFilePath}: service was disposed during acquisition`);
488
+ }
285
489
  // Use a LOCAL handle variable so that concurrent acquireLock() calls on
286
490
  // the same instance cannot close each other's file handle through the
287
491
  // shared this.lockHandle field. We only promote to this.lockHandle after
@@ -293,9 +497,20 @@ export class FileLockService implements IService {
293
497
  await handle.write(this._makeLockContent());
294
498
  await handle.sync(); // Ensure content is written to disk
295
499
 
500
+ // Retirement may have landed between the open above and this commit.
501
+ // cleanup()'s drain saw no held handle and already returned, so nothing
502
+ // would ever tear this acquisition down. Throwing here lets the catch
503
+ // below close the handle and remove the just-created lock file.
504
+ if (this._retired) {
505
+ throw new Error(`Cannot acquire lock at ${this.lockFilePath}: service was disposed during acquisition`);
506
+ }
507
+
296
508
  // Fully acquired — commit to instance state
297
509
  this.lockHandle = handle;
298
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();
299
514
 
300
515
  // Track in ALS for nested calls
301
516
  if (heldLocks) {
@@ -305,7 +520,7 @@ export class FileLockService implements IService {
305
520
  const duration = Date.now() - startTime;
306
521
  metrics.observe('state_lock_acquire_duration_ms', duration);
307
522
  metrics.increment('state_lock_acquire_total', 1, { status: 'success' });
308
- metrics.setGauge('state_lock_held', 1);
523
+ metrics.setGauge('state_lock_held', 1, { lock: this.lockName });
309
524
  if (contentionCount > 0) {
310
525
  metrics.increment('state_lock_contention_total', 1);
311
526
  metrics.observe('state_lock_contention_retries', contentionCount);
@@ -355,26 +570,47 @@ export class FileLockService implements IService {
355
570
  // surrounding retry loop / timeout, not by stealing.
356
571
  if (await this._shouldReclaim(parsed?.pid ?? null, parsed?.startTime ?? null, lockAge, contendedOwnerAliveMemo)) {
357
572
  const trashPath = `${this.lockFilePath}.trash.${crypto.randomBytes(8).toString('hex')}`;
573
+ let renamed = false;
358
574
  try {
359
575
  await fs.rename(this.lockFilePath, trashPath);
576
+ renamed = true;
360
577
  const trashContent = await fs.readFile(trashPath, 'utf-8');
361
578
  const trashParsed = this._parseLockContent(trashContent);
362
579
  if ((trashParsed?.uuid ?? '') !== lockUuid) {
363
- try { await fs.link(trashPath, this.lockFilePath); } catch { /* ignore */ }
364
- await fs.unlink(trashPath);
580
+ await this._restoreLockFile(trashPath);
365
581
  continue;
366
582
  }
367
- await fs.unlink(trashPath);
368
- } catch { /* ignore */ }
583
+ await fs.unlink(trashPath).catch(() => { /* leaked copy; startup sweep collects it */ });
584
+ } catch {
585
+ // Rename failed (nothing moved) — retry handles it. But a read
586
+ // failure AFTER the rename means the verify never ran: the lock
587
+ // may have changed hands to a live peer mid-check, and leaving
588
+ // it in the trash destroys it unverified — the same transient
589
+ // classes (EMFILE, EACCES, EBUSY) the inspect branch below
590
+ // backs off for. Put it back instead.
591
+ if (renamed) await this._restoreLockFile(trashPath);
592
+ }
369
593
  continue;
370
594
  }
371
- } catch (_statError) {
372
- const trashPath = `${this.lockFilePath}.trash.${crypto.randomBytes(8).toString('hex')}`;
373
- try {
374
- await fs.rename(this.lockFilePath, trashPath);
375
- await fs.unlink(trashPath);
376
- continue;
377
- } catch { /* ignore */ }
595
+ } catch (inspectError) {
596
+ // We could not READ the lock, which says nothing about whether its
597
+ // owner is alive. Destroying it here — as this branch used to,
598
+ // with neither a UUID check nor a liveness check, unlike the
599
+ // verified path above — turns any transient fs failure (EMFILE
600
+ // under fd pressure, EACCES, EBUSY on Windows) into a stolen lock
601
+ // and two concurrent writers on the same file.
602
+ //
603
+ // ENOENT is the exception and the common case: the lock was
604
+ // released between our open() and our read, so there is nothing to
605
+ // destroy and retrying immediately is correct.
606
+ const code = (inspectError as NodeJS.ErrnoException | undefined)?.code;
607
+ if (code === 'ENOENT') continue;
608
+ logger.warn(
609
+ `[FileLockService] Could not inspect contended lock at ${this.lockFilePath} ` +
610
+ `(${code ?? 'unknown'}); backing off rather than reclaiming it.`,
611
+ );
612
+ metrics.increment('state_lock_inspect_failed_total', 1, { code: code ?? 'unknown' });
613
+ // Fall through to the timeout check + backoff below.
378
614
  }
379
615
 
380
616
  if (Date.now() - startTime >= this.lockTimeout) {
@@ -400,10 +636,7 @@ export class FileLockService implements IService {
400
636
  } catch (err) {
401
637
  // If acquisition failed (timeout/error), we MUST release our turn in the
402
638
  // queue so the next caller can try.
403
- if (this.resolveTurn) {
404
- this.resolveTurn();
405
- this.resolveTurn = null;
406
- }
639
+ this._releaseTurn();
407
640
  throw err;
408
641
  }
409
642
  }
@@ -413,6 +646,11 @@ export class FileLockService implements IService {
413
646
  */
414
647
  async releaseLock(): Promise<void> {
415
648
  if (this.lockHandle === null) {
649
+ // cleanup() can tear the handle down underneath an in-flight critical
650
+ // section, so withLock()'s finally lands here with the handle already gone.
651
+ // The queue turn is still ours to hand back — returning without doing so
652
+ // leaves every queued waiter blocked on `await previous` for good.
653
+ this._releaseTurn();
416
654
  return;
417
655
  }
418
656
 
@@ -421,6 +659,10 @@ export class FileLockService implements IService {
421
659
  return;
422
660
  }
423
661
 
662
+ // Fully releasing (not a re-entrant pop) — the mtime heartbeat must stop
663
+ // with the hold it maintains.
664
+ this._stopHeartbeat();
665
+
424
666
  try {
425
667
  // 1. Double check ownership before unlinking
426
668
  const content = await fs.readFile(this.lockFilePath, 'utf8').catch(() => '');
@@ -440,14 +682,10 @@ export class FileLockService implements IService {
440
682
  heldLocks.delete(`${this.lockFilePath}:${this.lockUuid}`);
441
683
  }
442
684
 
443
- metrics.setGauge('state_lock_held', 0);
685
+ metrics.setGauge('state_lock_held', 0, { lock: this.lockName });
444
686
  } finally {
445
687
  // 4. Signal the next caller in the queue that it's their turn.
446
- const resolve = this.resolveTurn;
447
- this.resolveTurn = null;
448
- if (resolve) {
449
- resolve();
450
- }
688
+ this._releaseTurn();
451
689
  }
452
690
  }
453
691
 
@@ -455,8 +693,13 @@ export class FileLockService implements IService {
455
693
  * Execute a function within the scope of a filesystem lock.
456
694
  * This is the preferred way to use the lock service.
457
695
  * Safe re-entrancy is supported within the same async flow.
696
+ *
697
+ * The acquire timeout is the one given to the constructor (`lockTimeout`). This
698
+ * used to take a second `_timeout` argument that was accepted and then ignored
699
+ * entirely — a caller passing 5000 silently got the constructed 20000 — so it is
700
+ * gone rather than left to mislead. No caller was passing it.
458
701
  */
459
- async withLock<T>(callback: () => Promise<T> | T, _timeout: number = 30000): Promise<T> {
702
+ async withLock<T>(callback: () => Promise<T> | T): Promise<T> {
460
703
  const heldLocks = lockContext.getStore();
461
704
  const lockKey = `${this.lockFilePath}:${this.lockUuid}`;
462
705
 
@@ -477,9 +720,31 @@ export class FileLockService implements IService {
477
720
  }
478
721
 
479
722
  /**
480
- * Force clean up any lock files owned by this instance.
723
+ * Clean up any lock file owned by this instance, and retire the instance.
724
+ *
725
+ * Called only from shutdown paths (`dispose`, `StateManager.cleanup`). It first
726
+ * gives an in-flight critical section a bounded chance to release normally, then
727
+ * forces the teardown if it has not. After this the instance is retired:
728
+ * `acquireLock` fails fast rather than blocking on a queue nothing will ever
729
+ * drain, which is what makes forcing the teardown safe to do at all.
481
730
  */
482
731
  async cleanup(): Promise<void> {
732
+ const drained = await this._drainHeldLock(CLEANUP_DRAIN_TIMEOUT_MS);
733
+ if (!drained) {
734
+ logger.warn(
735
+ `[FileLockService] Lock at ${this.lockFilePath} still held after ${CLEANUP_DRAIN_TIMEOUT_MS}ms; forcing teardown. ` +
736
+ 'A concurrent writer in another process may observe the lock as free.',
737
+ );
738
+ metrics.increment('state_lock_forced_teardown_total', 1);
739
+ }
740
+
741
+ // Retire BEFORE releasing the turn, so a waiter woken below re-checks and
742
+ // fails fast instead of acquiring a lock this instance no longer manages.
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();
747
+
483
748
  if (this.lockHandle) {
484
749
  try {
485
750
  const content = await fs.readFile(this.lockFilePath, 'utf8').catch(() => '');
@@ -494,6 +759,8 @@ export class FileLockService implements IService {
494
759
  this.lockHandle = null;
495
760
  }
496
761
  this.lockCount = 0;
762
+ // Unblock anyone parked on `await previous`; they will now see _retired and throw.
763
+ this._releaseTurn();
497
764
  }
498
765
 
499
766
  /**
@@ -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) {