@lincoln504/pi-research 1.2.0 → 1.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli.mjs +1334 -750
- package/dist/thread-worker.mjs +194 -136
- package/docs/ARCHITECTURE.md +1 -1
- package/docs/CHANGELOG.md +129 -16
- package/package.json +1 -1
- package/scripts/cleanup.cjs +64 -2
- package/skills/pi-research/SKILL.md +1 -1
- package/src/cleanup/research-cleanup.ts +12 -4
- package/src/cli.ts +228 -70
- package/src/config.ts +78 -2
- package/src/core/interfaces/health-check-interfaces.ts +3 -39
- package/src/core/interfaces/planning-interfaces.ts +8 -0
- package/src/core/interfaces/service-names.ts +0 -1
- package/src/core/interfaces/state-manager-interfaces.ts +10 -2
- package/src/core/llm/agentic-repair.ts +14 -0
- package/src/core/planning-service.ts +43 -1
- package/src/core/service-initialization.ts +6 -14
- package/src/core/service-interfaces.ts +1 -1
- package/src/healthcheck/index.ts +7 -2
- package/src/infrastructure/browser/browser-client.ts +64 -9
- package/src/infrastructure/browser/browser-server.ts +24 -7
- package/src/infrastructure/browser/browser-task-scheduler.ts +58 -12
- package/src/infrastructure/browser/cleanup-utils.ts +50 -2
- package/src/infrastructure/browser/client-agent.ts +6 -6
- package/src/infrastructure/browser/config.ts +14 -3
- package/src/infrastructure/browser/scheduler-factory.ts +18 -3
- package/src/infrastructure/browser/thread-worker-messaging.ts +54 -2
- package/src/infrastructure/browser/thread-worker-types.ts +3 -1
- package/src/infrastructure/browser/thread-worker.mjs +194 -136
- package/src/infrastructure/browser/worker-pool-manager.ts +7 -2
- package/src/infrastructure/embedding/embedding-factory.ts +35 -6
- package/src/infrastructure/embedding/embedding-server.ts +40 -7
- package/src/infrastructure/file-lock-service.ts +72 -2
- package/src/infrastructure/knowledge-store-service.ts +48 -3
- package/src/infrastructure/process-lifecycle-service.ts +28 -5
- package/src/infrastructure/research-run-semaphore.ts +37 -3
- package/src/infrastructure/state/state-browser-api.ts +5 -2
- package/src/infrastructure/state/state-browser-manager.ts +15 -1
- package/src/infrastructure/state/state-manager-service.ts +4 -4
- package/src/infrastructure/state/state-manager.ts +15 -3
- package/src/knowledge/chunker.ts +88 -1
- package/src/knowledge/embedder.ts +136 -43
- package/src/knowledge/store.ts +66 -12
- package/src/logger.ts +5 -1
- package/src/observers/research-observer-impl.ts +31 -0
- package/src/orchestration/abort-utils.ts +60 -0
- package/src/orchestration/deep-research-orchestrator.ts +12 -2
- package/src/orchestration/headless-observer.ts +8 -0
- package/src/orchestration/research-orchestration-service.ts +63 -11
- package/src/orchestration/research-session-service.ts +37 -11
- package/src/orchestration/researcher-executor.ts +100 -20
- package/src/orchestration/researcher.ts +39 -20
- package/src/orchestration/session-state.ts +11 -0
- package/src/sdk.ts +125 -19
- package/src/security/index.ts +51 -30
- package/src/security/nvd-client.ts +7 -2
- package/src/skill-install/skill-installer.ts +18 -2
- package/src/stackexchange/index.ts +20 -5
- package/src/stackexchange/output/table.ts +7 -2
- package/src/stackexchange/rest-client.ts +35 -1
- package/src/tools/research-tool-definition.ts +42 -8
- package/src/tools/scrape.ts +48 -25
- package/src/tools/search.ts +10 -1
- package/src/tools/security.ts +68 -7
- package/src/tui/research-health.ts +18 -2
- package/src/utils/circuit-breaker.ts +9 -2
- package/src/utils/disk-space-checker.ts +11 -6
- package/src/utils/json-utils.ts +116 -76
- package/src/utils/llm-usage.ts +17 -1
- package/src/utils/log-rotation.ts +42 -10
- package/src/utils/log-utils.ts +40 -0
- package/src/utils/metrics-summary.ts +27 -9
- package/src/utils/stdio-capture.ts +63 -22
- package/src/utils/text-utils.ts +8 -1
- package/src/utils/tool-usage-tracker.ts +9 -2
- package/src/web-research/browser-search.ts +26 -7
- package/src/web-research/scraper-utils.ts +13 -0
- package/src/web-research/search.ts +6 -2
- package/src/web-research/types.ts +4 -2
- package/src/web-research/web-scraper.ts +65 -4
- package/src/core/health-check-service.ts +0 -265
|
@@ -76,6 +76,13 @@ export async function isEmbeddingLeaderAlive(
|
|
|
76
76
|
|
|
77
77
|
let _embeddingInstance: IEmbedder | null = null;
|
|
78
78
|
let _embeddingInitPromise: Promise<IEmbedder> | null = null;
|
|
79
|
+
// Config the in-flight init was started for. The join below must compare against
|
|
80
|
+
// these — the cached-instance path checks model/device before reuse, and the
|
|
81
|
+
// adoption paths refuse mismatched leaders, but a caller joining a promise built
|
|
82
|
+
// for a different config would silently receive (and stamp the cache with) the
|
|
83
|
+
// wrong-model embedder, defeating both guards.
|
|
84
|
+
let _initModel: string | null = null;
|
|
85
|
+
let _initDevice: string | null = null;
|
|
79
86
|
let _cachedModel: string | null = null;
|
|
80
87
|
let _cachedDevice: string | null = null;
|
|
81
88
|
|
|
@@ -130,8 +137,18 @@ export async function getEmbedder(config?: Config, _attempt = 0): Promise<IEmbed
|
|
|
130
137
|
|
|
131
138
|
// An internal retry (_attempt > 0) is re-entering from INSIDE the promise stored
|
|
132
139
|
// here, so handing that promise back would make it await itself. Only a fresh
|
|
133
|
-
// external call may join an in-flight init
|
|
134
|
-
|
|
140
|
+
// external call may join an in-flight init — and only one started for the SAME
|
|
141
|
+
// config: a mismatched joiner would get the wrong-model embedder (same-dimension
|
|
142
|
+
// models cross-embed silently). On mismatch, wait the in-flight init out (never
|
|
143
|
+
// race a second election in this process), then re-resolve for this config — the
|
|
144
|
+
// cached-instance check above disposes the mismatched instance.
|
|
145
|
+
if (_embeddingInitPromise && _attempt === 0) {
|
|
146
|
+
if (_initModel === cfg.EMBEDDING_MODEL && _initDevice === cfg.EMBEDDING_DEVICE) {
|
|
147
|
+
return _embeddingInitPromise;
|
|
148
|
+
}
|
|
149
|
+
await _embeddingInitPromise.catch(() => {});
|
|
150
|
+
return getEmbedder(cfg, _attempt);
|
|
151
|
+
}
|
|
135
152
|
|
|
136
153
|
let p: Promise<IEmbedder>;
|
|
137
154
|
|
|
@@ -222,7 +239,12 @@ export async function getEmbedder(config?: Config, _attempt = 0): Promise<IEmbed
|
|
|
222
239
|
if (!(await isEmbeddingLeaderAlive(info, processLifecycle))) {
|
|
223
240
|
// Candidate process died without cleaning up (or its PID was recycled by
|
|
224
241
|
// an unrelated process — the start-time pairing catches that too).
|
|
225
|
-
|
|
242
|
+
// serverId-scoped: several waiters typically detect the death in the same
|
|
243
|
+
// poll tick; the first one's clear+claim can commit before this one's clear
|
|
244
|
+
// runs, and an unconditional delete here would deregister that NEW claimant
|
|
245
|
+
// (whose Phase-3 port publish is CAS-guarded and silently no-ops) — leaving
|
|
246
|
+
// two concurrent model inits and an invisible leader.
|
|
247
|
+
await stateManager.clearEmbeddingServer({ serverId: info.serverId }).catch((err) => logger.debug('Swallowed clear embedding server error:', err));
|
|
226
248
|
break;
|
|
227
249
|
}
|
|
228
250
|
// Re-check model identity every poll: the leader may have changed while
|
|
@@ -246,7 +268,10 @@ export async function getEmbedder(config?: Config, _attempt = 0): Promise<IEmbed
|
|
|
246
268
|
const secondCheck = await isPortListening(info.port);
|
|
247
269
|
if (!secondCheck) {
|
|
248
270
|
logger.warn(`[EmbeddingFactory] Registered port ${info.port} is unreachable after two checks — clearing stale state`);
|
|
249
|
-
|
|
271
|
+
// serverId-scoped: this snapshot is up to ~2.5s old (two probes + sleep);
|
|
272
|
+
// the entry may already belong to a new claimant. See the dead-leader
|
|
273
|
+
// clear above for the failure this prevents.
|
|
274
|
+
await stateManager.clearEmbeddingServer({ serverId: info.serverId }).catch((err) => logger.debug('Swallowed clear embedding server error:', err));
|
|
250
275
|
break;
|
|
251
276
|
}
|
|
252
277
|
}
|
|
@@ -282,8 +307,10 @@ export async function getEmbedder(config?: Config, _attempt = 0): Promise<IEmbed
|
|
|
282
307
|
try {
|
|
283
308
|
port = await server.startServer();
|
|
284
309
|
} catch (err) {
|
|
285
|
-
// Init failed — clear our placeholder so others can try
|
|
286
|
-
|
|
310
|
+
// Init failed — clear our placeholder so others can try. serverId-scoped so a
|
|
311
|
+
// concurrent steal-and-reclaim (a waiter that judged us dead and claimed) is
|
|
312
|
+
// never deregistered by our own failure path.
|
|
313
|
+
await stateManager.clearEmbeddingServer({ serverId }).catch((err) => logger.debug('Swallowed clear embedding server error:', err));
|
|
287
314
|
throw err;
|
|
288
315
|
}
|
|
289
316
|
|
|
@@ -307,6 +334,8 @@ export async function getEmbedder(config?: Config, _attempt = 0): Promise<IEmbed
|
|
|
307
334
|
|
|
308
335
|
p = init();
|
|
309
336
|
_embeddingInitPromise = p;
|
|
337
|
+
_initModel = cfg.EMBEDDING_MODEL;
|
|
338
|
+
_initDevice = cfg.EMBEDDING_DEVICE;
|
|
310
339
|
// init() already assigns _embeddingInstance before it resolves, so do NOT
|
|
311
340
|
// re-assign it here. If clearEmbeddingInstance() ran (and disposed the server)
|
|
312
341
|
// between init resolving and this microtask, re-assigning would resurrect the
|
|
@@ -16,6 +16,7 @@ import { Utf8Body } from '../../utils/http-body.ts';
|
|
|
16
16
|
import type { IEmbedder } from '../../core/interfaces/knowledge-interfaces.ts';
|
|
17
17
|
import type { IStateManager } from '../../core/interfaces/state-manager-interfaces.ts';
|
|
18
18
|
import type { Embedder } from '../../knowledge/embedder.ts';
|
|
19
|
+
import { markWebGpuFallback } from '../../knowledge/embedder-utils.ts';
|
|
19
20
|
|
|
20
21
|
/**
|
|
21
22
|
* Per-process shared secret every embed request must present.
|
|
@@ -49,6 +50,24 @@ function isAuthorized(presented: string | string[] | undefined): boolean {
|
|
|
49
50
|
return actual.length === expected.length && crypto.timingSafeEqual(actual, expected);
|
|
50
51
|
}
|
|
51
52
|
|
|
53
|
+
/**
|
|
54
|
+
* Refuse to serialize a vector containing NaN/±Infinity. JSON.stringify emits
|
|
55
|
+
* `null` for non-finite numbers, and the client's `new Float32Array(resp.data)`
|
|
56
|
+
* coerces null back to 0 — a silently part-zeroed vector that passes every
|
|
57
|
+
* downstream width/finite gate (0 IS finite) and lands in the ANN index. Failing
|
|
58
|
+
* the request instead surfaces it as an ordinary embed failure on the client.
|
|
59
|
+
*/
|
|
60
|
+
function assertFiniteVector(vec: ArrayLike<number>, vectorIndex?: number): void {
|
|
61
|
+
for (let i = 0; i < vec.length; i++) {
|
|
62
|
+
if (!Number.isFinite(vec[i]!)) {
|
|
63
|
+
const where = vectorIndex === undefined ? `position ${i}` : `vector ${vectorIndex}, position ${i}`;
|
|
64
|
+
throw new Error(
|
|
65
|
+
`[EmbeddingServer] embedder produced a non-finite value (${vec[i]}) at ${where} — refusing to serialize (JSON would launder it to null/0)`,
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
52
71
|
// ---------------------------------------------------------------------------
|
|
53
72
|
// SerialQueue — ensures embed/embedMany never run concurrently on the GPU
|
|
54
73
|
// ---------------------------------------------------------------------------
|
|
@@ -274,6 +293,15 @@ export class EmbeddingServer implements IEmbedder {
|
|
|
274
293
|
'Stepping down as leader so a fresh process can re-elect with a clean GPU context. ' +
|
|
275
294
|
err.message,
|
|
276
295
|
);
|
|
296
|
+
// INVARIANT: after a poisoned (permanently hung) inference, THIS process must
|
|
297
|
+
// never touch WebGPU again. The wedged native call still holds the same-PID
|
|
298
|
+
// re-entrant GPU lock, so if this process wins the next election and
|
|
299
|
+
// re-initializes WebGPU it would run a second inference beside the stuck one —
|
|
300
|
+
// the concurrent-GPU-inference segfault class the SerialQueue exists to
|
|
301
|
+
// prevent. Latch the process-wide fallback so any embedder built in this
|
|
302
|
+
// process from here on comes up on CPU; only a FRESH process may re-elect
|
|
303
|
+
// with a clean GPU context.
|
|
304
|
+
markWebGpuFallback();
|
|
277
305
|
// Step down WITHOUT disposing the embedder: pipeline.dispose() would join the
|
|
278
306
|
// stuck native thread (itself hanging) or touch a device-lost context. Clearing
|
|
279
307
|
// the state registration + closing the HTTP server is enough for re-election;
|
|
@@ -339,14 +367,14 @@ export class EmbeddingServer implements IEmbedder {
|
|
|
339
367
|
this.leadershipTimer = null;
|
|
340
368
|
}
|
|
341
369
|
|
|
342
|
-
// Clear our registration if we still own it
|
|
370
|
+
// Clear our registration if we still own it. The serverId CAS runs inside the
|
|
371
|
+
// state lock, so a successor that claimed the slot between our last check and
|
|
372
|
+
// this shutdown can never be deregistered by us (the old read-then-clear had
|
|
373
|
+
// exactly that window).
|
|
343
374
|
try {
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
logger.warn('[EmbeddingServer] Failed to clear embedding server state:', err);
|
|
348
|
-
});
|
|
349
|
-
}
|
|
375
|
+
await this.stateManager.clearEmbeddingServer({ serverId: this.serverId }).catch((err) => {
|
|
376
|
+
logger.warn('[EmbeddingServer] Failed to clear embedding server state:', err);
|
|
377
|
+
});
|
|
350
378
|
} catch (err) {
|
|
351
379
|
const msg = err instanceof Error ? err.message : String(err);
|
|
352
380
|
// State manager is torn down before the embedding server during process exit —
|
|
@@ -552,6 +580,9 @@ export class EmbeddingServer implements IEmbedder {
|
|
|
552
580
|
// message drives the client to reconnect to the new leader.
|
|
553
581
|
this.assertServing();
|
|
554
582
|
const result = await this.queue.enqueue(() => this.embedder.embed(data.text as string));
|
|
583
|
+
// Scan BEFORE serialization: a device-lost mid-inference yields NaN
|
|
584
|
+
// entries that JSON would launder into null → 0 on the client.
|
|
585
|
+
assertFiniteVector(result);
|
|
555
586
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
556
587
|
res.end(JSON.stringify({ data: Array.from(result) }));
|
|
557
588
|
break;
|
|
@@ -559,6 +590,8 @@ export class EmbeddingServer implements IEmbedder {
|
|
|
559
590
|
case '/embedMany': {
|
|
560
591
|
this.assertServing();
|
|
561
592
|
const results = await this.queue.enqueue(() => this.embedder.embedMany(data.texts as string[]));
|
|
593
|
+
// Same non-finite scan as /embed, per vector (see assertFiniteVector).
|
|
594
|
+
for (let i = 0; i < results.length; i++) assertFiniteVector(results[i]!, i);
|
|
562
595
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
563
596
|
res.end(JSON.stringify({ results: results.map(r => Array.from(r)) }));
|
|
564
597
|
break;
|
|
@@ -38,6 +38,16 @@ const CLEANUP_DRAIN_TIMEOUT_MS = 5_000;
|
|
|
38
38
|
*/
|
|
39
39
|
const TRASH_SWEEP_AGE_MS = 10 * 60_000;
|
|
40
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
|
+
|
|
41
51
|
/**
|
|
42
52
|
* Lock configuration options
|
|
43
53
|
*/
|
|
@@ -101,9 +111,17 @@ export class FileLockService implements IService {
|
|
|
101
111
|
// Set by cleanup(). A retired instance no longer manages the lock file, so
|
|
102
112
|
// acquiring through it would hand out exclusivity it cannot honour.
|
|
103
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;
|
|
104
121
|
|
|
105
122
|
constructor(options: FileLockOptions) {
|
|
106
123
|
this.lockFilePath = options.lockFilePath;
|
|
124
|
+
this.lockName = pathmod.basename(options.lockFilePath);
|
|
107
125
|
this.lockTimeout = options.lockTimeout ?? 20000;
|
|
108
126
|
this.lockRetries = options.lockRetries ?? 200;
|
|
109
127
|
this.lockRetryDelay = options.lockRetryDelay ?? 100;
|
|
@@ -254,6 +272,48 @@ export class FileLockService implements IService {
|
|
|
254
272
|
if (resolve) resolve();
|
|
255
273
|
}
|
|
256
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
|
+
|
|
257
317
|
/**
|
|
258
318
|
* Wait, bounded, for an in-flight critical section to finish.
|
|
259
319
|
*
|
|
@@ -448,6 +508,9 @@ export class FileLockService implements IService {
|
|
|
448
508
|
// Fully acquired — commit to instance state
|
|
449
509
|
this.lockHandle = handle;
|
|
450
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();
|
|
451
514
|
|
|
452
515
|
// Track in ALS for nested calls
|
|
453
516
|
if (heldLocks) {
|
|
@@ -457,7 +520,7 @@ export class FileLockService implements IService {
|
|
|
457
520
|
const duration = Date.now() - startTime;
|
|
458
521
|
metrics.observe('state_lock_acquire_duration_ms', duration);
|
|
459
522
|
metrics.increment('state_lock_acquire_total', 1, { status: 'success' });
|
|
460
|
-
metrics.setGauge('state_lock_held', 1);
|
|
523
|
+
metrics.setGauge('state_lock_held', 1, { lock: this.lockName });
|
|
461
524
|
if (contentionCount > 0) {
|
|
462
525
|
metrics.increment('state_lock_contention_total', 1);
|
|
463
526
|
metrics.observe('state_lock_contention_retries', contentionCount);
|
|
@@ -596,6 +659,10 @@ export class FileLockService implements IService {
|
|
|
596
659
|
return;
|
|
597
660
|
}
|
|
598
661
|
|
|
662
|
+
// Fully releasing (not a re-entrant pop) — the mtime heartbeat must stop
|
|
663
|
+
// with the hold it maintains.
|
|
664
|
+
this._stopHeartbeat();
|
|
665
|
+
|
|
599
666
|
try {
|
|
600
667
|
// 1. Double check ownership before unlinking
|
|
601
668
|
const content = await fs.readFile(this.lockFilePath, 'utf8').catch(() => '');
|
|
@@ -615,7 +682,7 @@ export class FileLockService implements IService {
|
|
|
615
682
|
heldLocks.delete(`${this.lockFilePath}:${this.lockUuid}`);
|
|
616
683
|
}
|
|
617
684
|
|
|
618
|
-
metrics.setGauge('state_lock_held', 0);
|
|
685
|
+
metrics.setGauge('state_lock_held', 0, { lock: this.lockName });
|
|
619
686
|
} finally {
|
|
620
687
|
// 4. Signal the next caller in the queue that it's their turn.
|
|
621
688
|
this._releaseTurn();
|
|
@@ -674,6 +741,9 @@ export class FileLockService implements IService {
|
|
|
674
741
|
// Retire BEFORE releasing the turn, so a waiter woken below re-checks and
|
|
675
742
|
// fails fast instead of acquiring a lock this instance no longer manages.
|
|
676
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();
|
|
677
747
|
|
|
678
748
|
if (this.lockHandle) {
|
|
679
749
|
try {
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
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) {
|
|
@@ -55,6 +55,17 @@ export class ProcessLifecycleService implements IProcessLifecycle {
|
|
|
55
55
|
|
|
56
56
|
private cachedStartTime: number | null = null;
|
|
57
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
|
+
|
|
58
69
|
async initialize(): Promise<void> {
|
|
59
70
|
this.lifecycle = ServiceLifecycle.INITIALIZED;
|
|
60
71
|
}
|
|
@@ -87,6 +98,18 @@ export class ProcessLifecycleService implements IProcessLifecycle {
|
|
|
87
98
|
}
|
|
88
99
|
}
|
|
89
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
|
+
|
|
90
113
|
// Windows: use powershell to get process creation time
|
|
91
114
|
if (process.platform === 'win32') {
|
|
92
115
|
try {
|
|
@@ -99,11 +122,11 @@ export class ProcessLifecycleService implements IProcessLifecycle {
|
|
|
99
122
|
);
|
|
100
123
|
child.unref?.();
|
|
101
124
|
});
|
|
102
|
-
if (!output || !output.trim()) return null;
|
|
125
|
+
if (!output || !output.trim()) return memoize(null);
|
|
103
126
|
const startTime = parseInt(output.trim(), 10);
|
|
104
|
-
return isNaN(startTime) ? null : startTime;
|
|
127
|
+
return memoize(isNaN(startTime) ? null : startTime);
|
|
105
128
|
} catch {
|
|
106
|
-
return null;
|
|
129
|
+
return memoize(null);
|
|
107
130
|
}
|
|
108
131
|
}
|
|
109
132
|
|
|
@@ -121,8 +144,8 @@ export class ProcessLifecycleService implements IProcessLifecycle {
|
|
|
121
144
|
// So: try `etimes`, and fall back to parsing POSIX `etime` when it yields
|
|
122
145
|
// nothing. `etime` is mandated by POSIX, so the fallback works everywhere.
|
|
123
146
|
const elapsedSec = await this.readElapsedSeconds(pid);
|
|
124
|
-
if (elapsedSec === null) return null;
|
|
125
|
-
return Math.floor(Date.now() / 1000) - elapsedSec;
|
|
147
|
+
if (elapsedSec === null) return memoize(null);
|
|
148
|
+
return memoize(Math.floor(Date.now() / 1000) - elapsedSec);
|
|
126
149
|
}
|
|
127
150
|
|
|
128
151
|
/** Elapsed seconds for `pid` via ps, or null. Tries `etimes`, then POSIX `etime`. */
|
|
@@ -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
|
-
|
|
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
|
-
//
|
|
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);
|
|
@@ -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,8 +188,8 @@ 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
|
/**
|
|
@@ -202,8 +202,8 @@ export class StateManagerService implements IStateManager {
|
|
|
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
|
/**
|
|
@@ -446,8 +446,8 @@ export class StateManager {
|
|
|
446
446
|
);
|
|
447
447
|
}
|
|
448
448
|
|
|
449
|
-
public async clearBrowserServer(): Promise<void> {
|
|
450
|
-
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);
|
|
451
451
|
}
|
|
452
452
|
|
|
453
453
|
// ==================== Embedding Server API ====================
|
|
@@ -457,8 +457,20 @@ export class StateManager {
|
|
|
457
457
|
return state.embeddingServer ?? null;
|
|
458
458
|
}
|
|
459
459
|
|
|
460
|
-
public async clearEmbeddingServer(): Promise<void> {
|
|
460
|
+
public async clearEmbeddingServer(expected?: { pid?: number; serverId?: string }): Promise<void> {
|
|
461
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
|
+
}
|
|
462
474
|
delete state.embeddingServer;
|
|
463
475
|
return state;
|
|
464
476
|
});
|