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