@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
@@ -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
  *
@@ -22,6 +55,17 @@ export class ProcessLifecycleService implements IProcessLifecycle {
22
55
 
23
56
  private cachedStartTime: number | null = null;
24
57
 
58
+ // Short-TTL memo for SUBPROCESS-backed start-time lookups (macOS `ps`, Windows
59
+ // powershell — Linux reads /proc and stays uncached). A queued run-slot waiter
60
+ // sweeps every 250ms and builds a fresh FileLockService per attempt, so without
61
+ // a cross-attempt memo each live owner costs up to two `ps` spawns per sweep —
62
+ // ~48 subprocess spawns/second sustained on macOS while queued. A live PID's
63
+ // start time never changes; the only staleness is a PID recycled within the
64
+ // TTL reading as its dead predecessor, which merely delays a stale-owner steal
65
+ // by up to the TTL (well under every staleness threshold in use).
66
+ private static readonly START_TIME_TTL_MS = 5000;
67
+ private startTimeMemo = new Map<number, { value: number | null; at: number }>();
68
+
25
69
  async initialize(): Promise<void> {
26
70
  this.lifecycle = ServiceLifecycle.INITIALIZED;
27
71
  }
@@ -54,6 +98,18 @@ export class ProcessLifecycleService implements IProcessLifecycle {
54
98
  }
55
99
  }
56
100
 
101
+ const memoized = this.startTimeMemo.get(pid);
102
+ if (memoized && Date.now() - memoized.at < ProcessLifecycleService.START_TIME_TTL_MS) {
103
+ return memoized.value;
104
+ }
105
+ const memoize = (value: number | null): number | null => {
106
+ // Bound the map: sweeps interrogate a handful of owners; anything beyond a
107
+ // few hundred distinct PIDs inside one TTL window means leaked entries.
108
+ if (this.startTimeMemo.size > 256) this.startTimeMemo.clear();
109
+ this.startTimeMemo.set(pid, { value, at: Date.now() });
110
+ return value;
111
+ };
112
+
57
113
  // Windows: use powershell to get process creation time
58
114
  if (process.platform === 'win32') {
59
115
  try {
@@ -66,31 +122,59 @@ export class ProcessLifecycleService implements IProcessLifecycle {
66
122
  );
67
123
  child.unref?.();
68
124
  });
69
- if (!output || !output.trim()) return null;
125
+ if (!output || !output.trim()) return memoize(null);
70
126
  const startTime = parseInt(output.trim(), 10);
71
- return isNaN(startTime) ? null : startTime;
127
+ return memoize(isNaN(startTime) ? null : startTime);
72
128
  } catch {
73
- return null;
129
+ return memoize(null);
74
130
  }
75
131
  }
76
132
 
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;
133
+ // Cross-platform fallback (macOS/BSD): derive start time from elapsed time.
134
+ //
135
+ // `etimes` (elapsed WHOLE SECONDS) is a procps-ng/FreeBSD extension. Apple's
136
+ // ps (adv_cmds) does not implement it — it has only the POSIX `etime`
137
+ // ([[dd-]hh:]mm:ss). Asking for an unsupported keyword does not fail loudly:
138
+ // procps-ng writes an error to stderr and still exits 0 with EMPTY stdout,
139
+ // BSD ps exits non-zero. Both collapse to `null` here, which meant every
140
+ // PID+start-time guard on macOS silently degraded to a bare signal-0 check —
141
+ // the file lock, the run-slot semaphore, the GPU lock and both leader
142
+ // elections all advertise PID-reuse safety they were not actually getting.
143
+ //
144
+ // So: try `etimes`, and fall back to parsing POSIX `etime` when it yields
145
+ // nothing. `etime` is mandated by POSIX, so the fallback works everywhere.
146
+ const elapsedSec = await this.readElapsedSeconds(pid);
147
+ if (elapsedSec === null) return memoize(null);
148
+ return memoize(Math.floor(Date.now() / 1000) - elapsedSec);
149
+ }
150
+
151
+ /** Elapsed seconds for `pid` via ps, or null. Tries `etimes`, then POSIX `etime`. */
152
+ private async readElapsedSeconds(pid: number): Promise<number | null> {
153
+ const run = async (fmt: string): Promise<string | null> => {
154
+ try {
155
+ return await new Promise<string>((resolve, reject) => {
156
+ const child = execFile(
157
+ 'ps', ['-o', fmt, '-p', String(pid)],
158
+ { encoding: 'utf8', timeout: 3000 },
159
+ (err, stdout) => err ? reject(err) : resolve(stdout),
160
+ );
161
+ child.unref?.();
162
+ });
163
+ } catch {
164
+ return null;
165
+ }
166
+ };
167
+
168
+ const etimes = await run('etimes=');
169
+ if (etimes && etimes.trim()) {
170
+ const n = parseInt(etimes.trim(), 10);
171
+ if (!isNaN(n)) return n;
93
172
  }
173
+
174
+ const etime = await run('etime=');
175
+ if (etime && etime.trim()) return parseEtime(etime.trim());
176
+
177
+ return null;
94
178
  }
95
179
 
96
180
  /**
@@ -111,11 +195,20 @@ export class ProcessLifecycleService implements IProcessLifecycle {
111
195
  * @returns true if process is alive (and matches start time if provided)
112
196
  */
113
197
  async isProcessAlive(pid: number, expectedStartTime?: number | null): Promise<boolean> {
198
+ // 1. EXISTENCE. Separate from identity (step 2) on purpose: a permission error
199
+ // means the process exists but is not ours to signal, which must NOT
200
+ // short-circuit the start-time comparison — a PID-reuse impostor owned by
201
+ // another user would otherwise read as a live owner and deadlock the lock,
202
+ // run slot or leadership it forged.
114
203
  try {
115
- // 1. Basic liveness check via signal 0
116
204
  process.kill(pid, 0);
117
-
118
- // 2. If start time verification requested, check it
205
+ } catch (err) {
206
+ if (isDefinitelyGone(err)) return false;
207
+ // EPERM/EACCES: exists, not signalable by us — continue to the identity check.
208
+ }
209
+
210
+ try {
211
+ // 2. IDENTITY. If start time verification requested, check it.
119
212
  if (expectedStartTime !== undefined && expectedStartTime !== null) {
120
213
  const actualStartTime = await this.getProcessStartTime(pid);
121
214
  if (actualStartTime === null) {
@@ -132,14 +225,18 @@ export class ProcessLifecycleService implements IProcessLifecycle {
132
225
  // caller — a live lock/leader/run-cap owner is reclaimed from under it
133
226
  // → two writers collide (lost update) or the run-cap admits extra
134
227
  // 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;
228
+ // CI). So re-confirm existence instead of guessing: if the PID is STILL
229
+ // there the process is alive → true; if it has gone it died between the
230
+ // two probes → false. A genuine death is therefore still detected within
231
+ // one poll tick (~100ms); the only behavioural change is that a
232
+ // confirmed-alive PID is never again misreported as dead on a transient
233
+ // start-time lookup failure.
234
+ try {
235
+ process.kill(pid, 0);
236
+ return true;
237
+ } catch (reconfirmErr) {
238
+ return !isDefinitelyGone(reconfirmErr);
239
+ }
143
240
  }
144
241
  // Allow ±1s slack. The macOS/BSD fallback derives start time as
145
242
  // floor(Date.now()/1000) - `ps etimes` (elapsed whole seconds); both terms are
@@ -149,10 +246,13 @@ export class ProcessLifecycleService implements IProcessLifecycle {
149
246
  // within a 1s window at the identical PID is not a realistic false-match.
150
247
  return Math.abs(actualStartTime - expectedStartTime) <= 1;
151
248
  }
152
-
249
+
153
250
  return true;
154
251
  } catch {
155
- return false;
252
+ // The start-time lookup itself blew up (not a signal error — those are
253
+ // handled above). Existence was already established, so treat it as alive:
254
+ // reclaiming a confirmed-present owner is the worse failure.
255
+ return true;
156
256
  }
157
257
  }
158
258
 
@@ -163,8 +263,8 @@ export class ProcessLifecycleService implements IProcessLifecycle {
163
263
  try {
164
264
  process.kill(pid, 0);
165
265
  return true;
166
- } catch {
167
- return false;
266
+ } catch (err) {
267
+ return !isDefinitelyGone(err);
168
268
  }
169
269
  }
170
270
 
@@ -195,7 +195,18 @@ export class ResearchRunSemaphore implements IService {
195
195
  try {
196
196
  await slot.initialize(); // resolves own startTime (cached) + startup stale cleanup
197
197
  await slot.acquireLock(); // try-take: wins, reclaims a dead owner, or throws fast
198
- } catch {
198
+ } catch (err) {
199
+ // Contention and breakage are different outcomes and must not be conflated.
200
+ // FileLockService synthesizes its own Errors (no errno code) for the two
201
+ // contention shapes a try-take produces — a live-contended slot ("timeout
202
+ // after 0ms", cause EEXIST) and a lost reclaim race ("after N retries") —
203
+ // and those genuinely mean "slot busy". A real I/O failure (ENOENT/EACCES/
204
+ // ENOSPC: the slot file is uncreatable; both mkdirs are warn-only) instead
205
+ // propagates the raw errno error. Swallowing that as "busy" would poll out
206
+ // the whole acquire window and reject with a FALSE ResearchRunCapacityError,
207
+ // making the documented fail-open contract (research proceeds when the
208
+ // semaphore is broken) unreachable. Rethrow so acquire() can tell them apart.
209
+ if (typeof (err as NodeJS.ErrnoException | null)?.code === 'string') throw err;
199
210
  return null; // busy (live owner) or lost a reclaim race — try the next slot
200
211
  }
201
212
  return {
@@ -246,16 +257,39 @@ export class ResearchRunSemaphore implements IService {
246
257
  // Round-robin from a rotating start so contending processes don't synchronize on slot 0.
247
258
  const start = this.nextStartIndex;
248
259
  this.nextStartIndex = (this.nextStartIndex + 1) % this.maxSlots;
260
+ // Slot BREAKAGE (I/O error) is tracked separately from contention within
261
+ // this sweep — a broken slot must never be reported as a busy one.
262
+ let brokenSlots = 0;
263
+ let firstBreakage: unknown = null;
249
264
  for (let offset = 0; offset < this.maxSlots; offset++) {
250
265
  const i = (start + offset) % this.maxSlots;
251
- const got = await this.trySlot(i);
266
+ let got: RunSlotAcquisition | null;
267
+ try {
268
+ got = await this.trySlot(i);
269
+ } catch (err) {
270
+ // This slot is broken, not busy. Keep sweeping — another slot may still
271
+ // be acquirable (mixed case: slot 0 uncreatable, slot 1 free).
272
+ brokenSlots++;
273
+ firstBreakage ??= err;
274
+ logger.debug(`[ResearchRunSemaphore] Slot ${i} errored (broken, not busy): ${err instanceof Error ? err.message : String(err)}`);
275
+ continue;
276
+ }
252
277
  if (got) {
253
278
  metrics.setGauge('research_run_slots_held', 1, { slot: String(i) });
254
279
  metrics.increment('research_run_acquire_total', 1, { status: 'acquired' });
255
280
  return got;
256
281
  }
257
282
  }
258
- // Every slot is held by a live process.
283
+ // EVERY slot errored: the semaphore itself is broken, not at capacity.
284
+ // Waiting cannot help — nothing is held that could free up — and a
285
+ // ResearchRunCapacityError here would misclassify breakage as retryable
286
+ // contention while the caller's fail-open path (research proceeds without
287
+ // the cap) stays unreachable. Rethrow the I/O error instead.
288
+ if (brokenSlots === this.maxSlots) {
289
+ metrics.increment('research_run_acquire_total', 1, { status: 'error' });
290
+ throw firstBreakage;
291
+ }
292
+ // Every remaining slot is held by a live process.
259
293
  if (Date.now() >= deadline) {
260
294
  metrics.increment('research_run_acquire_total', 1, { status: 'exhausted' });
261
295
  throw new ResearchRunCapacityError(this.maxSlots, maxWaitMs);
@@ -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
 
@@ -50,12 +50,15 @@ export class StateBrowserApi {
50
50
  /**
51
51
  * Clear the browser server information
52
52
  * @param updateState Function to update state atomically
53
+ * @param expected Optional expected-owner identity forwarded to the CAS check
54
+ * in StateBrowserManager.clearBrowserServer.
53
55
  */
54
56
  async clearBrowserServer(
55
- updateState: (updater: (state: SingletonState) => SingletonState | Promise<SingletonState>) => Promise<void>
57
+ updateState: (updater: (state: SingletonState) => SingletonState | Promise<SingletonState>) => Promise<void>,
58
+ expected?: { pid?: number; schedulerId?: string }
56
59
  ): Promise<void> {
57
60
  await updateState((state) => {
58
- return this.browserManager.clearBrowserServer(state);
61
+ return this.browserManager.clearBrowserServer(state, expected);
59
62
  });
60
63
  }
61
64
 
@@ -47,9 +47,23 @@ export class StateBrowserManager implements IService {
47
47
  /**
48
48
  * Clear the browser server information
49
49
  * @param state The current state
50
+ * @param expected Optional identity the caller believes is currently registered.
51
+ * When supplied, this is a compare-and-delete: the entry is only removed if it
52
+ * still matches (mirrors StateManager.clearEmbeddingServer). browserServer is
53
+ * shared cross-process state, and an unconditional delete lets a caller who
54
+ * decided the *previous* leader was dead deregister a DIFFERENT process's
55
+ * fresh, live registration that claimed the slot in the meantime — that new
56
+ * leader then runs undiscoverable (state shows no browserServer), so the next
57
+ * getScheduler() caller elects a second, redundant leader. A mismatch means
58
+ * someone else already claimed the slot, and the clear must no-op.
50
59
  * @returns Updated state without browser server info
51
60
  */
52
- clearBrowserServer(state: SingletonState): SingletonState {
61
+ clearBrowserServer(state: SingletonState, expected?: { pid?: number; schedulerId?: string }): SingletonState {
62
+ const entry = state.browserServer;
63
+ if (entry && expected !== undefined) {
64
+ if (expected.schedulerId !== undefined && entry.schedulerId !== expected.schedulerId) return state;
65
+ if (expected.pid !== undefined && entry.pid !== expected.pid) return state;
66
+ }
53
67
  delete state.browserServer;
54
68
  return state;
55
69
  }
@@ -188,22 +188,22 @@ export class StateManagerService implements IStateManager {
188
188
  /**
189
189
  * Clear the browser server information
190
190
  */
191
- async clearBrowserServer(): Promise<void> {
192
- return this.getStateManager().clearBrowserServer();
191
+ async clearBrowserServer(expected?: { pid?: number; schedulerId?: string }): Promise<void> {
192
+ return this.getStateManager().clearBrowserServer(expected);
193
193
  }
194
194
 
195
195
  /**
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
 
202
202
  /**
203
203
  * Clear the embedding server information
204
204
  */
205
- async clearEmbeddingServer(): Promise<void> {
206
- return this.getStateManager().clearEmbeddingServer();
205
+ async clearEmbeddingServer(expected?: { pid?: number; serverId?: string }): Promise<void> {
206
+ return this.getStateManager().clearEmbeddingServer(expected);
207
207
  }
208
208
 
209
209
  /**
@@ -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();
@@ -447,19 +446,31 @@ export class StateManager {
447
446
  );
448
447
  }
449
448
 
450
- public async clearBrowserServer(): Promise<void> {
451
- await this.browserApi.clearBrowserServer(this.updateState.bind(this));
449
+ public async clearBrowserServer(expected?: { pid?: number; schedulerId?: string }): Promise<void> {
450
+ await this.browserApi.clearBrowserServer(this.updateState.bind(this), expected);
452
451
  }
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
  }
460
459
 
461
- public async clearEmbeddingServer(): Promise<void> {
460
+ public async clearEmbeddingServer(expected?: { pid?: number; serverId?: string }): Promise<void> {
462
461
  await this.updateState((state) => {
462
+ // Compare-and-delete: the registration is shared cross-process state, and an
463
+ // unconditional delete lets a follower's shutdown (or a waiter acting on a
464
+ // stale snapshot) deregister a DIFFERENT process's live leader — which then
465
+ // never re-registers (its leadership check treats an absent entry as benign),
466
+ // so a fresh caller elects a second GPU model instance alongside the invisible
467
+ // one. Callers pass the identity they believe they are clearing; a mismatch
468
+ // means someone else already claimed the slot, and the clear must no-op.
469
+ const entry = state.embeddingServer;
470
+ if (entry && expected !== undefined) {
471
+ if (expected.serverId !== undefined && entry.serverId !== expected.serverId) return state;
472
+ if (expected.pid !== undefined && entry.pid !== expected.pid) return state;
473
+ }
463
474
  delete state.embeddingServer;
464
475
  return state;
465
476
  });
@@ -79,6 +79,15 @@ export const SingletonStateSchema = Type.Object({
79
79
  // in-process embedder instead. Optional for entries written by older
80
80
  // processes, which are tolerated as matching.
81
81
  model: Type.Optional(Type.String()),
82
+ // Shared secret a follower must present on every embed request. Mirrors
83
+ // browserServer.authSecret: the server listens on loopback, but loopback is
84
+ // not an authorization boundary — any local process could otherwise enqueue
85
+ // embedding work, and because the serial queue steps the leader down when
86
+ // poisoned, sustained abuse denies the knowledge store rather than merely
87
+ // burning GPU. Optional so an entry written by an older leader still parses;
88
+ // a client that finds none simply sends no header, and such a leader does not
89
+ // check one either.
90
+ authSecret: Type.Optional(Type.String()),
82
91
  })),
83
92
  });
84
93
 
@@ -42,6 +42,7 @@ export class Chunker {
42
42
  let spaceExtensions = 0;
43
43
 
44
44
  while (start < text.length) {
45
+ const iterationStart = start;
45
46
  let end = start + this.targetSize;
46
47
 
47
48
  if (end < text.length) {
@@ -118,6 +119,55 @@ export class Chunker {
118
119
 
119
120
  if (end <= start) end = Math.min(start + this.targetSize, text.length);
120
121
 
122
+ // Forward-progress invariant: every chunk after the first must end past the
123
+ // previous chunk's end. When the overlap step plants `start` inside a code
124
+ // block whose closing fence terminated the PREVIOUS chunk, the in-block
125
+ // re-extension above clamps `end` back onto that same fence — a chunk that
126
+ // re-covers only overlap content, and (via the 1-char anti-regression step
127
+ // below) one such sliver per character until `start` clears the fence.
128
+ // Covering fresh text is worth splitting the fence: the previous chunk
129
+ // already carries the block's tail intact.
130
+ if (chunks.length > 0 && end <= prevEnd) {
131
+ end = Math.min(start + this.targetSize, text.length);
132
+ }
133
+
134
+ // UTF-16 surrogate-pair safety: `end` is a raw code-unit index, and every
135
+ // adjustment above (code-block/heading/sentence/newline/space, plus the
136
+ // forward-progress guard) operates purely in code-unit space — none of
137
+ // them are aware of astral characters (emoji, rare CJK Extension B+),
138
+ // which are a HIGH surrogate (0xD800-0xDBFF) followed by a LOW surrogate
139
+ // (0xDC00-0xDFFF). If `end` lands between the two halves, slicing here
140
+ // emits a lone high surrogate. Node's UTF-16->UTF-8 conversion (JSON
141
+ // serialization, HTTP bodies, N-API marshalling into the LanceDB/Arrow
142
+ // layer) silently substitutes U+FFFD for that rather than throwing —
143
+ // silent corruption of text that gets embedded and stored. Mirrors the
144
+ // backoff in truncateWithMarker (src/utils/text-utils.ts). Applied last,
145
+ // right before the slice, and only shrinks `end` when doing so can't
146
+ // undo the forward-progress guard just above or produce an empty chunk.
147
+ if (end < text.length) {
148
+ const code = text.charCodeAt(end - 1);
149
+ if (code >= 0xd800 && code <= 0xdbff) {
150
+ const candidate = end - 1;
151
+ const staysPastPrev = chunks.length === 0 || candidate > prevEnd;
152
+ if (candidate > start && staysPastPrev) {
153
+ end = candidate;
154
+ } else {
155
+ // Backing off would violate the forward-progress guard above (or empty
156
+ // the chunk) — this happens when targetSize and overlap sit within one
157
+ // code unit of each other. Grow past the low surrogate instead: taking
158
+ // the full pair into THIS chunk only ever increases `end`, so it can
159
+ // never re-collide with the guard it just failed, and it still avoids
160
+ // splitting the pair (the alternative — leaving `end` right after the
161
+ // lone high surrogate — is the silent corruption this block exists to
162
+ // prevent).
163
+ const low = text.charCodeAt(end);
164
+ if (low >= 0xdc00 && low <= 0xdfff) {
165
+ end = Math.min(end + 1, text.length);
166
+ }
167
+ }
168
+ }
169
+ }
170
+
121
171
  const chunkText = text.slice(start, end);
122
172
  const actual_overlap = chunks.length === 0 ? 0 : Math.max(0, Math.min(chunkText.length, prevEnd - start));
123
173
 
@@ -131,7 +181,44 @@ export class Chunker {
131
181
  start = end - Math.min(this.overlap, chunkText.length - 1);
132
182
  }
133
183
  if (start < 0) start = 0;
134
- if (start >= end) start = end - 1;
184
+ if (start >= end) start = end - 1;
185
+
186
+ // Forward-progress invariant, start side: the constructor rejects
187
+ // overlap >= targetSize specifically "to prevent infinite loops," but that
188
+ // guarantee only holds for the raw arithmetic above — it says nothing about
189
+ // the end-side forward-progress guard earlier in this loop reusing the same
190
+ // `end` two iterations in a row, which can leave `start` no greater than the
191
+ // value this same iteration started with, reproducing an identical chunk
192
+ // forever (observed: OOM from an unbounded chunks array on a small
193
+ // targetSize/overlap gap plus a boundary emoji). Guarantee real progress
194
+ // independent of every heuristic above, mirroring the `end`-side guard.
195
+ // Applied BEFORE the surrogate-pair check below so that check always
196
+ // adjusts an already-safe value instead of being able to undo this guard.
197
+ if (start <= iterationStart) start = iterationStart + 1;
198
+
199
+ // Surrogate-pair safety, read side: `start` (end - overlap, further
200
+ // clamped above) is an independent raw code-unit index — it is not
201
+ // derived from any of the textual heuristics that picked `end` above,
202
+ // so the fix to `end` does not cover it. It can separately bisect an
203
+ // unrelated pair and produce a next chunk that OPENS with a lone low
204
+ // surrogate. The pair's high half is already inside the chunk just
205
+ // pushed (it sits before `end`), so pulling `start` back one unit only
206
+ // widens that chunk's overlap by one code unit — it never drops text.
207
+ // Only pull back when doing so keeps the forward-progress guard above
208
+ // satisfied; otherwise push forward past the low surrogate instead — the
209
+ // pair's high half is guaranteed already present in the chunk just
210
+ // pushed, so skipping the low half here loses nothing, it just narrows
211
+ // this chunk's overlap by one code unit instead of widening it.
212
+ if (start > 0) {
213
+ const code = text.charCodeAt(start);
214
+ if (code >= 0xdc00 && code <= 0xdfff) {
215
+ if (start - 1 > iterationStart) {
216
+ start -= 1;
217
+ } else if (start + 1 < end) {
218
+ start += 1;
219
+ }
220
+ }
221
+ }
135
222
  }
136
223
 
137
224
  const duration = Date.now() - startTime;
@@ -252,7 +252,7 @@ export async function purgeModelCache(model: string): Promise<void> {
252
252
  logger.warn(`[embedder] Refusing to purge unsafe model-cache path for model id ${JSON.stringify(model)}`);
253
253
  return;
254
254
  }
255
- await rm(modelDir, { recursive: true, force: true });
255
+ await rm(modelDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 });
256
256
  logger.warn(`[embedder] Purged corrupt model cache at ${modelDir}`);
257
257
  } catch (err) {
258
258
  logger.warn('[embedder] Failed to purge model cache:', err);