@lincoln504/pi-research 1.1.2 → 1.2.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 (74) hide show
  1. package/README.md +3 -3
  2. package/dist/cli.mjs +1287 -540
  3. package/dist/thread-worker.mjs +30 -11
  4. package/docs/AGENT-SKILL.md +11 -4
  5. package/docs/ARCHITECTURE.md +19 -9
  6. package/docs/CHANGELOG.md +81 -1
  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 +50 -10
  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/cli.ts +149 -21
  21. package/src/config.ts +56 -15
  22. package/src/constants.ts +31 -0
  23. package/src/core/interfaces/knowledge-interfaces.ts +6 -1
  24. package/src/core/interfaces/state-manager-interfaces.ts +1 -1
  25. package/src/core/pi-version.ts +136 -0
  26. package/src/core/service-registry.ts +117 -8
  27. package/src/index.ts +11 -18
  28. package/src/infrastructure/browser/browser-client.ts +16 -4
  29. package/src/infrastructure/browser/browser-error-utils.ts +6 -1
  30. package/src/infrastructure/browser/browser-server.ts +17 -5
  31. package/src/infrastructure/browser/browser-task-scheduler.ts +101 -23
  32. package/src/infrastructure/browser/cleanup-utils.ts +45 -7
  33. package/src/infrastructure/browser/client-agent.ts +4 -3
  34. package/src/infrastructure/browser/priority-task-queue.ts +10 -3
  35. package/src/infrastructure/browser/scheduler-factory.ts +26 -1
  36. package/src/infrastructure/browser/task-execution-service.ts +92 -28
  37. package/src/infrastructure/browser/thread-worker-lifecycle.ts +50 -13
  38. package/src/infrastructure/browser/thread-worker.mjs +30 -11
  39. package/src/infrastructure/browser/worker-pool-manager.ts +11 -3
  40. package/src/infrastructure/embedding/embedding-client.ts +22 -8
  41. package/src/infrastructure/embedding/embedding-factory.ts +28 -11
  42. package/src/infrastructure/embedding/embedding-server.ts +65 -4
  43. package/src/infrastructure/file-lock-service.ts +223 -26
  44. package/src/infrastructure/process-lifecycle-service.ts +108 -31
  45. package/src/infrastructure/state/state-backup-manager.ts +5 -7
  46. package/src/infrastructure/state/state-manager-service.ts +1 -1
  47. package/src/infrastructure/state/state-manager.ts +19 -20
  48. package/src/infrastructure/types/state-types.ts +9 -0
  49. package/src/knowledge/embedder-init.ts +1 -1
  50. package/src/knowledge/embedder.ts +52 -3
  51. package/src/knowledge/index.ts +1 -1
  52. package/src/knowledge/store.ts +35 -9
  53. package/src/knowledge/webgpu-viability.ts +9 -4
  54. package/src/knowledge/writer-queue.ts +88 -46
  55. package/src/orchestration/deep-research-orchestrator.ts +13 -2
  56. package/src/orchestration/quick-research-orchestrator.ts +2 -2
  57. package/src/orchestration/research-orchestration-service.ts +2 -4
  58. package/src/orchestration/research-synthesis-service.ts +33 -11
  59. package/src/security/cisa-kev.ts +10 -3
  60. package/src/security/github-advisories.ts +18 -7
  61. package/src/security/nvd-client.ts +17 -6
  62. package/src/security/osv-client.ts +14 -4
  63. package/src/skill-install/skill-installer.ts +135 -9
  64. package/src/stackexchange/rest-client.ts +6 -2
  65. package/src/tools/research-knowledge-search.ts +15 -0
  66. package/src/tools/search.ts +6 -1
  67. package/src/utils/atomic-replace.ts +101 -0
  68. package/src/utils/citation-utils.ts +3 -1
  69. package/src/utils/http-body.ts +176 -0
  70. package/src/web-research/browser-search.ts +50 -4
  71. package/src/web-research/search.ts +20 -5
  72. package/src/web-research/types.ts +17 -4
  73. package/src/web-research/web-scraper.ts +7 -50
  74. package/src/youtube/transcript-client.ts +5 -1
@@ -23,6 +23,21 @@ 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
+
26
41
  /**
27
42
  * Lock configuration options
28
43
  */
@@ -83,6 +98,9 @@ export class FileLockService implements IService {
83
98
  // PID reuse. Null when unavailable (no processLifecycle, or lookup failed) —
84
99
  // then lock content omits startTime and readers treat us as a legacy owner.
85
100
  private ownStartTime: number | null = null;
101
+ // Set by cleanup(). A retired instance no longer manages the lock file, so
102
+ // acquiring through it would hand out exclusivity it cannot honour.
103
+ private _retired = false;
86
104
 
87
105
  constructor(options: FileLockOptions) {
88
106
  this.lockFilePath = options.lockFilePath;
@@ -175,11 +193,111 @@ export class FileLockService implements IService {
175
193
  this.lifecycle = ServiceLifecycle.DISPOSED;
176
194
  }
177
195
 
196
+ /**
197
+ * Put a lock file back after a trash-and-verify decided not to reclaim it.
198
+ *
199
+ * The old form linked it back inside a swallowing catch and then unlinked the
200
+ * trash copy unconditionally — so any link failure other than "a peer already
201
+ * re-created it" DESTROYED the lock this branch exists to preserve. Distinguish
202
+ * the two: if the path is occupied again the peer's lock wins and ours is dropped;
203
+ * otherwise move ours back rather than deleting it.
204
+ */
205
+ private async _restoreLockFile(trashPath: string): Promise<void> {
206
+ try {
207
+ await fs.link(trashPath, this.lockFilePath);
208
+ await fs.unlink(trashPath).catch(() => { /* link succeeded; copy is redundant */ });
209
+ return;
210
+ } catch { /* fall through to the two cases below */ }
211
+
212
+ const peerHoldsIt = await fs.access(this.lockFilePath).then(() => true).catch(() => false);
213
+ if (peerHoldsIt) {
214
+ // Someone re-created the lock while we were checking. Theirs is authoritative.
215
+ await fs.unlink(trashPath).catch(() => { /* best effort */ });
216
+ return;
217
+ }
218
+ // Nothing there and link failed (EPERM, filesystems without hardlinks,
219
+ // Windows link limits): re-create the path EXCLUSIVELY rather than rename it
220
+ // back — rename silently REPLACES a lock a peer creates between the probe
221
+ // above and now, clobbering a live owner. 'wx' loses that race loudly, and
222
+ // then the peer's lock is authoritative.
223
+ try {
224
+ const content = await fs.readFile(trashPath, 'utf-8');
225
+ const handle = await fs.open(this.lockFilePath, 'wx', 0o600);
226
+ try {
227
+ await handle.write(content);
228
+ await handle.sync();
229
+ } finally {
230
+ await handle.close().catch(() => { /* content is on disk */ });
231
+ }
232
+ await fs.unlink(trashPath).catch(() => { /* restored; copy is redundant */ });
233
+ } catch {
234
+ // EEXIST (a peer took the path) — ours is redundant; anything else, leave
235
+ // the trash copy for the startup sweep. Never delete what we could not
236
+ // put back.
237
+ const occupied = await fs.access(this.lockFilePath).then(() => true).catch(() => false);
238
+ if (occupied) await fs.unlink(trashPath).catch(() => { /* best effort */ });
239
+ }
240
+ }
241
+
242
+ /**
243
+ * Hand our turn back to the next caller in the FIFO queue.
244
+ *
245
+ * Every path that stops holding the lock must run this, including the ones that
246
+ * find the handle already gone. `acquireLock` parks on `await previous` with no
247
+ * timeout of its own — the lockTimeout only starts counting once the turn has
248
+ * been granted — so a turn that is never resolved is not a slow acquire, it is a
249
+ * permanent one.
250
+ */
251
+ private _releaseTurn(): void {
252
+ const resolve = this.resolveTurn;
253
+ this.resolveTurn = null;
254
+ if (resolve) resolve();
255
+ }
256
+
257
+ /**
258
+ * Wait, bounded, for an in-flight critical section to finish.
259
+ *
260
+ * Yanking a held lock is not a clean teardown: unlinking the file while the
261
+ * holder is still mid read-modify-write drops cross-process exclusion, and a peer
262
+ * that acquires in that window loses the holder's update — precisely the hazard
263
+ * the lock exists to prevent. Since every caller of {@link cleanup} is a shutdown
264
+ * path, waiting briefly for a normal release is both safe and correct.
265
+ *
266
+ * Bounded because shutdown must make progress regardless: if the holder is wedged
267
+ * (or is itself the caller, which would otherwise deadlock), we give up and force
268
+ * the teardown, which is the pre-existing behaviour.
269
+ */
270
+ private async _drainHeldLock(timeoutMs: number): Promise<boolean> {
271
+ const deadline = Date.now() + timeoutMs;
272
+ while (this.lockHandle !== null && Date.now() < deadline) {
273
+ await this.sleep(Math.min(25, this.lockRetryDelay));
274
+ }
275
+ return this.lockHandle === null;
276
+ }
277
+
178
278
  /**
179
279
  * Clean up any stale lock files on initialization.
180
280
  * This handles cases where locks weren't released due to crashes.
181
281
  */
182
282
  private async cleanupStaleLocksOnStartup(): Promise<void> {
283
+ // Sweep trash copies leaked by an interrupted trash-and-verify (a crash or
284
+ // I/O failure between the rename and the unlink/restore). Nothing else ever
285
+ // collects them: the state-dir orphan sweep matches only *.tmp files. The age
286
+ // gate is generous — an in-flight reclaim's trash copy lives for milliseconds,
287
+ // and a restore-worthy one is put back on the same code path that made it.
288
+ try {
289
+ const dir = pathmod.dirname(this.lockFilePath);
290
+ const prefix = `${pathmod.basename(this.lockFilePath)}.trash.`;
291
+ for (const name of await fs.readdir(dir)) {
292
+ if (!name.startsWith(prefix)) continue;
293
+ const p = pathmod.join(dir, name);
294
+ const st = await fs.stat(p).catch(() => null);
295
+ if (st && Date.now() - st.mtimeMs > TRASH_SWEEP_AGE_MS) {
296
+ await fs.unlink(p).catch(() => { /* best effort */ });
297
+ }
298
+ }
299
+ } catch { /* best-effort hygiene; never blocks startup */ }
300
+
183
301
  try {
184
302
  const rawContent = await fs.readFile(this.lockFilePath, 'utf-8').catch(() => null);
185
303
  if (rawContent === null) return; // No lock file — nothing to do
@@ -201,19 +319,25 @@ export class FileLockService implements IService {
201
319
  // the UUID matches what we decided on — same guard as the contended path.
202
320
  const expectedUuid = parsed?.uuid ?? '';
203
321
  const trashPath = `${this.lockFilePath}.trash.${crypto.randomBytes(8).toString('hex')}`;
322
+ let renamed = false;
204
323
  try {
205
324
  await fs.rename(this.lockFilePath, trashPath);
325
+ renamed = true;
206
326
  const trashContent = await fs.readFile(trashPath, 'utf-8');
207
327
  const trashParsed = this._parseLockContent(trashContent);
208
328
  if ((trashParsed?.uuid ?? '') !== expectedUuid) {
209
329
  // 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);
330
+ await this._restoreLockFile(trashPath);
212
331
  return;
213
332
  }
214
- await fs.unlink(trashPath);
333
+ await fs.unlink(trashPath).catch(() => { /* leaked copy; the sweep above collects it */ });
215
334
  logger.log('[FileLockService] Stale lock removed');
216
- } catch { /* best-effort startup cleanup; acquire() handles contention */ }
335
+ } catch {
336
+ // A failure AFTER the rename means the verify never ran — restore
337
+ // rather than leave the (possibly live) lock destroyed in the trash.
338
+ if (renamed) await this._restoreLockFile(trashPath);
339
+ /* otherwise best-effort startup cleanup; acquire() handles contention */
340
+ }
217
341
  }
218
342
  } catch (error: unknown) {
219
343
  // ENOENT is expected (no lock file exists)
@@ -232,6 +356,9 @@ export class FileLockService implements IService {
232
356
  * @throws Error if unable to acquire lock within timeout
233
357
  */
234
358
  async acquireLock(): Promise<void> {
359
+ if (this._retired) {
360
+ throw new Error(`Cannot acquire lock at ${this.lockFilePath}: service has been disposed`);
361
+ }
235
362
  const heldLocks = lockContext.getStore();
236
363
  const lockKey = `${this.lockFilePath}:${this.lockUuid}`;
237
364
 
@@ -270,6 +397,14 @@ export class FileLockService implements IService {
270
397
  return;
271
398
  }
272
399
 
400
+ // Re-check after the wait: cleanup() may have retired the instance while we
401
+ // were queued. Hand the turn straight on rather than acquiring a lock file this
402
+ // instance no longer owns.
403
+ if (this._retired) {
404
+ myResolve();
405
+ throw new Error(`Cannot acquire lock at ${this.lockFilePath}: service was disposed while queued`);
406
+ }
407
+
273
408
  // Capture resolve function so we can trigger it in releaseLock() or on failure
274
409
  this.resolveTurn = myResolve;
275
410
 
@@ -282,6 +417,15 @@ export class FileLockService implements IService {
282
417
 
283
418
  try {
284
419
  for (let _attempt = 0; _attempt < this.lockRetries; _attempt++) {
420
+ // cleanup() can retire the instance while we are contending here — its
421
+ // drain polls only the HELD handle, so a caller mid-retry (holding
422
+ // nothing yet) is invisible to it and would otherwise win the lock and
423
+ // run its critical section against torn-down services after dispose
424
+ // resolved. The queued-waiter check above cannot catch this: our turn was
425
+ // granted before retirement. Abort; the catch below hands the turn on.
426
+ if (this._retired) {
427
+ throw new Error(`Cannot acquire lock at ${this.lockFilePath}: service was disposed during acquisition`);
428
+ }
285
429
  // Use a LOCAL handle variable so that concurrent acquireLock() calls on
286
430
  // the same instance cannot close each other's file handle through the
287
431
  // shared this.lockHandle field. We only promote to this.lockHandle after
@@ -293,6 +437,14 @@ export class FileLockService implements IService {
293
437
  await handle.write(this._makeLockContent());
294
438
  await handle.sync(); // Ensure content is written to disk
295
439
 
440
+ // Retirement may have landed between the open above and this commit.
441
+ // cleanup()'s drain saw no held handle and already returned, so nothing
442
+ // would ever tear this acquisition down. Throwing here lets the catch
443
+ // below close the handle and remove the just-created lock file.
444
+ if (this._retired) {
445
+ throw new Error(`Cannot acquire lock at ${this.lockFilePath}: service was disposed during acquisition`);
446
+ }
447
+
296
448
  // Fully acquired — commit to instance state
297
449
  this.lockHandle = handle;
298
450
  this.lockCount = 1;
@@ -355,26 +507,47 @@ export class FileLockService implements IService {
355
507
  // surrounding retry loop / timeout, not by stealing.
356
508
  if (await this._shouldReclaim(parsed?.pid ?? null, parsed?.startTime ?? null, lockAge, contendedOwnerAliveMemo)) {
357
509
  const trashPath = `${this.lockFilePath}.trash.${crypto.randomBytes(8).toString('hex')}`;
510
+ let renamed = false;
358
511
  try {
359
512
  await fs.rename(this.lockFilePath, trashPath);
513
+ renamed = true;
360
514
  const trashContent = await fs.readFile(trashPath, 'utf-8');
361
515
  const trashParsed = this._parseLockContent(trashContent);
362
516
  if ((trashParsed?.uuid ?? '') !== lockUuid) {
363
- try { await fs.link(trashPath, this.lockFilePath); } catch { /* ignore */ }
364
- await fs.unlink(trashPath);
517
+ await this._restoreLockFile(trashPath);
365
518
  continue;
366
519
  }
367
- await fs.unlink(trashPath);
368
- } catch { /* ignore */ }
520
+ await fs.unlink(trashPath).catch(() => { /* leaked copy; startup sweep collects it */ });
521
+ } catch {
522
+ // Rename failed (nothing moved) — retry handles it. But a read
523
+ // failure AFTER the rename means the verify never ran: the lock
524
+ // may have changed hands to a live peer mid-check, and leaving
525
+ // it in the trash destroys it unverified — the same transient
526
+ // classes (EMFILE, EACCES, EBUSY) the inspect branch below
527
+ // backs off for. Put it back instead.
528
+ if (renamed) await this._restoreLockFile(trashPath);
529
+ }
369
530
  continue;
370
531
  }
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 */ }
532
+ } catch (inspectError) {
533
+ // We could not READ the lock, which says nothing about whether its
534
+ // owner is alive. Destroying it here — as this branch used to,
535
+ // with neither a UUID check nor a liveness check, unlike the
536
+ // verified path above — turns any transient fs failure (EMFILE
537
+ // under fd pressure, EACCES, EBUSY on Windows) into a stolen lock
538
+ // and two concurrent writers on the same file.
539
+ //
540
+ // ENOENT is the exception and the common case: the lock was
541
+ // released between our open() and our read, so there is nothing to
542
+ // destroy and retrying immediately is correct.
543
+ const code = (inspectError as NodeJS.ErrnoException | undefined)?.code;
544
+ if (code === 'ENOENT') continue;
545
+ logger.warn(
546
+ `[FileLockService] Could not inspect contended lock at ${this.lockFilePath} ` +
547
+ `(${code ?? 'unknown'}); backing off rather than reclaiming it.`,
548
+ );
549
+ metrics.increment('state_lock_inspect_failed_total', 1, { code: code ?? 'unknown' });
550
+ // Fall through to the timeout check + backoff below.
378
551
  }
379
552
 
380
553
  if (Date.now() - startTime >= this.lockTimeout) {
@@ -400,10 +573,7 @@ export class FileLockService implements IService {
400
573
  } catch (err) {
401
574
  // If acquisition failed (timeout/error), we MUST release our turn in the
402
575
  // queue so the next caller can try.
403
- if (this.resolveTurn) {
404
- this.resolveTurn();
405
- this.resolveTurn = null;
406
- }
576
+ this._releaseTurn();
407
577
  throw err;
408
578
  }
409
579
  }
@@ -413,6 +583,11 @@ export class FileLockService implements IService {
413
583
  */
414
584
  async releaseLock(): Promise<void> {
415
585
  if (this.lockHandle === null) {
586
+ // cleanup() can tear the handle down underneath an in-flight critical
587
+ // section, so withLock()'s finally lands here with the handle already gone.
588
+ // The queue turn is still ours to hand back — returning without doing so
589
+ // leaves every queued waiter blocked on `await previous` for good.
590
+ this._releaseTurn();
416
591
  return;
417
592
  }
418
593
 
@@ -443,11 +618,7 @@ export class FileLockService implements IService {
443
618
  metrics.setGauge('state_lock_held', 0);
444
619
  } finally {
445
620
  // 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
- }
621
+ this._releaseTurn();
451
622
  }
452
623
  }
453
624
 
@@ -455,8 +626,13 @@ export class FileLockService implements IService {
455
626
  * Execute a function within the scope of a filesystem lock.
456
627
  * This is the preferred way to use the lock service.
457
628
  * Safe re-entrancy is supported within the same async flow.
629
+ *
630
+ * The acquire timeout is the one given to the constructor (`lockTimeout`). This
631
+ * used to take a second `_timeout` argument that was accepted and then ignored
632
+ * entirely — a caller passing 5000 silently got the constructed 20000 — so it is
633
+ * gone rather than left to mislead. No caller was passing it.
458
634
  */
459
- async withLock<T>(callback: () => Promise<T> | T, _timeout: number = 30000): Promise<T> {
635
+ async withLock<T>(callback: () => Promise<T> | T): Promise<T> {
460
636
  const heldLocks = lockContext.getStore();
461
637
  const lockKey = `${this.lockFilePath}:${this.lockUuid}`;
462
638
 
@@ -477,9 +653,28 @@ export class FileLockService implements IService {
477
653
  }
478
654
 
479
655
  /**
480
- * Force clean up any lock files owned by this instance.
656
+ * Clean up any lock file owned by this instance, and retire the instance.
657
+ *
658
+ * Called only from shutdown paths (`dispose`, `StateManager.cleanup`). It first
659
+ * gives an in-flight critical section a bounded chance to release normally, then
660
+ * forces the teardown if it has not. After this the instance is retired:
661
+ * `acquireLock` fails fast rather than blocking on a queue nothing will ever
662
+ * drain, which is what makes forcing the teardown safe to do at all.
481
663
  */
482
664
  async cleanup(): Promise<void> {
665
+ const drained = await this._drainHeldLock(CLEANUP_DRAIN_TIMEOUT_MS);
666
+ if (!drained) {
667
+ logger.warn(
668
+ `[FileLockService] Lock at ${this.lockFilePath} still held after ${CLEANUP_DRAIN_TIMEOUT_MS}ms; forcing teardown. ` +
669
+ 'A concurrent writer in another process may observe the lock as free.',
670
+ );
671
+ metrics.increment('state_lock_forced_teardown_total', 1);
672
+ }
673
+
674
+ // Retire BEFORE releasing the turn, so a waiter woken below re-checks and
675
+ // fails fast instead of acquiring a lock this instance no longer manages.
676
+ this._retired = true;
677
+
483
678
  if (this.lockHandle) {
484
679
  try {
485
680
  const content = await fs.readFile(this.lockFilePath, 'utf8').catch(() => '');
@@ -494,6 +689,8 @@ export class FileLockService implements IService {
494
689
  this.lockHandle = null;
495
690
  }
496
691
  this.lockCount = 0;
692
+ // Unblock anyone parked on `await previous`; they will now see _retired and throw.
693
+ this._releaseTurn();
497
694
  }
498
695
 
499
696
  /**
@@ -11,6 +11,39 @@ import type { IProcessLifecycle } from '../core/interfaces/process-interfaces.ts
11
11
  import { ServiceLifecycle } from '../core/service-registry.ts';
12
12
  import { ServiceNames } from '../core/interfaces/service-names.ts';
13
13
 
14
+ /**
15
+ * Parse the POSIX `ps -o etime=` elapsed-time format into whole seconds.
16
+ * Accepted shapes: `MM:SS`, `HH:MM:SS`, `DD-HH:MM:SS`. Returns null if it does
17
+ * not parse, so a caller degrades to "unknown" rather than to a bogus number.
18
+ */
19
+ export function parseEtime(value: string): number | null {
20
+ const m = /^(?:(\d+)-)?(?:(\d+):)?(\d+):(\d+)$/.exec(value.trim());
21
+ if (!m) return null;
22
+ const days = m[1] ? parseInt(m[1], 10) : 0;
23
+ const hours = m[2] ? parseInt(m[2], 10) : 0;
24
+ const minutes = parseInt(m[3]!, 10);
25
+ const seconds = parseInt(m[4]!, 10);
26
+ if ([days, hours, minutes, seconds].some((n) => isNaN(n))) return null;
27
+ return days * 86400 + hours * 3600 + minutes * 60 + seconds;
28
+ }
29
+
30
+ /**
31
+ * Does this `process.kill(pid, 0)` failure mean the process is GONE?
32
+ *
33
+ * Only `ESRCH` does. `EPERM` (POSIX) and `EACCES` — which is what libuv surfaces
34
+ * on Windows when `OpenProcess` returns ERROR_ACCESS_DENIED — both mean the
35
+ * process EXISTS but is not ours to signal: another user on a shared host, a CI
36
+ * side-car, a different Windows session. Reporting those as dead lets a caller
37
+ * reclaim a live lock, run slot or leader out from under its owner, which is how
38
+ * two writers end up racing the state file. The orphan guard and the browser
39
+ * sweep already get this right; this is the same rule.
40
+ */
41
+ function isDefinitelyGone(err: unknown): boolean {
42
+ const code = (err as NodeJS.ErrnoException | undefined)?.code;
43
+ if (code === 'EPERM' || code === 'EACCES') return false;
44
+ return true;
45
+ }
46
+
14
47
  /**
15
48
  * Process Lifecycle Service
16
49
  *
@@ -74,23 +107,51 @@ export class ProcessLifecycleService implements IProcessLifecycle {
74
107
  }
75
108
  }
76
109
 
77
- // Cross-platform fallback (macOS/Unix): `ps -o etimes=` returns elapsed seconds.
78
- try {
79
- const output = await new Promise<string>((resolve, reject) => {
80
- const child = execFile(
81
- 'ps', ['-o', 'etimes=', '-p', String(pid)],
82
- { encoding: 'utf8', timeout: 3000 },
83
- (err, stdout) => err ? reject(err) : resolve(stdout),
84
- );
85
- child.unref?.();
86
- });
87
- if (!output || !output.trim()) return null;
88
- const elapsedSec = parseInt(output.trim(), 10);
89
- if (isNaN(elapsedSec)) return null;
90
- return Math.floor(Date.now() / 1000) - elapsedSec;
91
- } catch {
92
- return null;
110
+ // Cross-platform fallback (macOS/BSD): derive start time from elapsed time.
111
+ //
112
+ // `etimes` (elapsed WHOLE SECONDS) is a procps-ng/FreeBSD extension. Apple's
113
+ // ps (adv_cmds) does not implement it — it has only the POSIX `etime`
114
+ // ([[dd-]hh:]mm:ss). Asking for an unsupported keyword does not fail loudly:
115
+ // procps-ng writes an error to stderr and still exits 0 with EMPTY stdout,
116
+ // BSD ps exits non-zero. Both collapse to `null` here, which meant every
117
+ // PID+start-time guard on macOS silently degraded to a bare signal-0 check —
118
+ // the file lock, the run-slot semaphore, the GPU lock and both leader
119
+ // elections all advertise PID-reuse safety they were not actually getting.
120
+ //
121
+ // So: try `etimes`, and fall back to parsing POSIX `etime` when it yields
122
+ // nothing. `etime` is mandated by POSIX, so the fallback works everywhere.
123
+ const elapsedSec = await this.readElapsedSeconds(pid);
124
+ if (elapsedSec === null) return null;
125
+ return Math.floor(Date.now() / 1000) - elapsedSec;
126
+ }
127
+
128
+ /** Elapsed seconds for `pid` via ps, or null. Tries `etimes`, then POSIX `etime`. */
129
+ private async readElapsedSeconds(pid: number): Promise<number | null> {
130
+ const run = async (fmt: string): Promise<string | null> => {
131
+ try {
132
+ return await new Promise<string>((resolve, reject) => {
133
+ const child = execFile(
134
+ 'ps', ['-o', fmt, '-p', String(pid)],
135
+ { encoding: 'utf8', timeout: 3000 },
136
+ (err, stdout) => err ? reject(err) : resolve(stdout),
137
+ );
138
+ child.unref?.();
139
+ });
140
+ } catch {
141
+ return null;
142
+ }
143
+ };
144
+
145
+ const etimes = await run('etimes=');
146
+ if (etimes && etimes.trim()) {
147
+ const n = parseInt(etimes.trim(), 10);
148
+ if (!isNaN(n)) return n;
93
149
  }
150
+
151
+ const etime = await run('etime=');
152
+ if (etime && etime.trim()) return parseEtime(etime.trim());
153
+
154
+ return null;
94
155
  }
95
156
 
96
157
  /**
@@ -111,11 +172,20 @@ export class ProcessLifecycleService implements IProcessLifecycle {
111
172
  * @returns true if process is alive (and matches start time if provided)
112
173
  */
113
174
  async isProcessAlive(pid: number, expectedStartTime?: number | null): Promise<boolean> {
175
+ // 1. EXISTENCE. Separate from identity (step 2) on purpose: a permission error
176
+ // means the process exists but is not ours to signal, which must NOT
177
+ // short-circuit the start-time comparison — a PID-reuse impostor owned by
178
+ // another user would otherwise read as a live owner and deadlock the lock,
179
+ // run slot or leadership it forged.
114
180
  try {
115
- // 1. Basic liveness check via signal 0
116
181
  process.kill(pid, 0);
117
-
118
- // 2. If start time verification requested, check it
182
+ } catch (err) {
183
+ if (isDefinitelyGone(err)) return false;
184
+ // EPERM/EACCES: exists, not signalable by us — continue to the identity check.
185
+ }
186
+
187
+ try {
188
+ // 2. IDENTITY. If start time verification requested, check it.
119
189
  if (expectedStartTime !== undefined && expectedStartTime !== null) {
120
190
  const actualStartTime = await this.getProcessStartTime(pid);
121
191
  if (actualStartTime === null) {
@@ -132,14 +202,18 @@ export class ProcessLifecycleService implements IProcessLifecycle {
132
202
  // caller — a live lock/leader/run-cap owner is reclaimed from under it
133
203
  // → two writers collide (lost update) or the run-cap admits extra
134
204
  // concurrent runs (the flaky duplicate-slot/cap-breach we saw on Windows
135
- // CI). So re-confirm liveness instead of guessing: if signal(0) STILL
136
- // succeeds the process is alive → return true; if it now throws it died
137
- // between the two probes and the outer catch returns false. A genuine
138
- // death is therefore still detected within one poll tick (~100ms); the
139
- // only behavioural change is that a confirmed-alive PID is never again
140
- // misreported as dead on a transient start-time lookup failure.
141
- process.kill(pid, 0); // re-confirm; a throw (process died) → outer catch → false
142
- return true;
205
+ // CI). So re-confirm existence instead of guessing: if the PID is STILL
206
+ // there the process is alive → true; if it has gone it died between the
207
+ // two probes → false. A genuine death is therefore still detected within
208
+ // one poll tick (~100ms); the only behavioural change is that a
209
+ // confirmed-alive PID is never again misreported as dead on a transient
210
+ // start-time lookup failure.
211
+ try {
212
+ process.kill(pid, 0);
213
+ return true;
214
+ } catch (reconfirmErr) {
215
+ return !isDefinitelyGone(reconfirmErr);
216
+ }
143
217
  }
144
218
  // Allow ±1s slack. The macOS/BSD fallback derives start time as
145
219
  // floor(Date.now()/1000) - `ps etimes` (elapsed whole seconds); both terms are
@@ -149,10 +223,13 @@ export class ProcessLifecycleService implements IProcessLifecycle {
149
223
  // within a 1s window at the identical PID is not a realistic false-match.
150
224
  return Math.abs(actualStartTime - expectedStartTime) <= 1;
151
225
  }
152
-
226
+
153
227
  return true;
154
228
  } catch {
155
- return false;
229
+ // The start-time lookup itself blew up (not a signal error — those are
230
+ // handled above). Existence was already established, so treat it as alive:
231
+ // reclaiming a confirmed-present owner is the worse failure.
232
+ return true;
156
233
  }
157
234
  }
158
235
 
@@ -163,8 +240,8 @@ export class ProcessLifecycleService implements IProcessLifecycle {
163
240
  try {
164
241
  process.kill(pid, 0);
165
242
  return true;
166
- } catch {
167
- return false;
243
+ } catch (err) {
244
+ return !isDefinitelyGone(err);
168
245
  }
169
246
  }
170
247
 
@@ -8,6 +8,7 @@ import * as fs from 'node:fs/promises';
8
8
  import * as crypto from 'node:crypto';
9
9
  import * as path from 'node:path';
10
10
  import { logger } from '../../logger.ts';
11
+ import { replaceFile } from '../../utils/atomic-replace.ts';
11
12
  import type { IService } from '../../core/service-registry.ts';
12
13
  import { ServiceLifecycle } from '../../core/service-registry.ts';
13
14
  import { ServiceNames } from '../../core/interfaces/service-names.ts';
@@ -253,15 +254,12 @@ export class StateBackupManager implements IService {
253
254
  await fh.close();
254
255
  }
255
256
  try {
256
- await fs.rename(tempPath, this.stateFilePath);
257
- } catch (renameErr) {
258
- if (process.platform === 'win32') {
259
- await fs.copyFile(tempPath, this.stateFilePath);
260
- await fs.unlink(tempPath).catch(() => { /* best-effort cleanup */ });
261
- } else {
257
+ if (await replaceFile(tempPath, this.stateFilePath) === 'copied') {
262
258
  await fs.unlink(tempPath).catch(() => { /* best-effort cleanup */ });
263
- throw renameErr;
264
259
  }
260
+ } catch (renameErr) {
261
+ await fs.unlink(tempPath).catch(() => { /* best-effort cleanup */ });
262
+ throw renameErr;
265
263
  }
266
264
  }
267
265
 
@@ -195,7 +195,7 @@ export class StateManagerService implements IStateManager {
195
195
  /**
196
196
  * Get the current embedding server information
197
197
  */
198
- async getEmbeddingServer(): Promise<{ port: number; pid: number; startTime?: number; serverId: string; model?: string } | null> {
198
+ async getEmbeddingServer(): Promise<{ port: number; pid: number; startTime?: number; serverId: string; model?: string; authSecret?: string } | null> {
199
199
  return this.getStateManager().getEmbeddingServer();
200
200
  }
201
201
 
@@ -23,6 +23,7 @@ import * as crypto from 'node:crypto';
23
23
  import process from 'node:process';
24
24
  import { getGlobalConfigDir } from '../../config.ts';
25
25
  import { logger } from '../../logger.ts';
26
+ import { replaceFile } from '../../utils/atomic-replace.ts';
26
27
  import { metrics } from '../../utils/metrics.ts';
27
28
  import type { IProcessLifecycle } from '../../core/interfaces/process-interfaces.ts';
28
29
  import type {
@@ -350,25 +351,23 @@ export class StateManager {
350
351
  } finally {
351
352
  await fh.close();
352
353
  }
353
- try {
354
- await fs.rename(tempFilePath, this.stateFilePath);
355
- } catch (renameErr) {
356
- // fs.rename fails on Windows if target exists (NTFS). Fall back to copy+delete.
357
- if (process.platform === 'win32') {
358
- await fs.copyFile(tempFilePath, this.stateFilePath);
359
- // copyFile succeeded → the state IS persisted. Make the temp-file removal
360
- // best-effort: a transient AV lock on the temp would otherwise throw here and
361
- // fall through to the catch below, reporting "Failed to write state" (status:
362
- // error) for a write that actually succeeded — a caller that retries/aborts on
363
- // the false failure would act wrongly.
364
- const tmp = tempFilePath;
365
- tempFilePath = null;
366
- await fs.unlink(tmp).catch((err) => {
367
- logger.warn('[StateManager] Failed to remove temp file after copy fallback (write succeeded):', err);
368
- });
369
- } else {
370
- throw renameErr;
371
- }
354
+ // Atomic where the platform allows; on Windows a third-party lock on the target
355
+ // is retried before conceding to a non-atomic copy (see replaceFile).
356
+ const outcome = await replaceFile(tempFilePath, this.stateFilePath);
357
+ if (outcome === 'copied') {
358
+ logger.warn(
359
+ '[StateManager] State written via non-atomic copy: the rename was blocked by another ' +
360
+ 'process holding the state file. A concurrent reader may observe a partial file.',
361
+ );
362
+ metrics.increment('state_write_non_atomic_total', 1);
363
+ // The state IS persisted, so removing the temp is best-effort: a transient AV lock
364
+ // on the temp would otherwise throw and report "Failed to write state" for a write
365
+ // that actually succeeded — a caller retrying or aborting on that would act wrongly.
366
+ const tmp = tempFilePath;
367
+ tempFilePath = null;
368
+ await fs.unlink(tmp).catch((err) => {
369
+ logger.warn('[StateManager] Failed to remove temp file after copy fallback (write succeeded):', err);
370
+ });
372
371
  }
373
372
  tempFilePath = null;
374
373
  await this.backupManager.cleanupOldBackups();
@@ -453,7 +452,7 @@ export class StateManager {
453
452
 
454
453
  // ==================== Embedding Server API ====================
455
454
 
456
- public async getEmbeddingServer(): Promise<{ port: number; pid: number; startTime?: number; serverId: string; model?: string } | null> {
455
+ public async getEmbeddingServer(): Promise<{ port: number; pid: number; startTime?: number; serverId: string; model?: string; authSecret?: string } | null> {
457
456
  const state = await this.readState();
458
457
  return state.embeddingServer ?? null;
459
458
  }