@lincoln504/pi-research 1.3.12 → 1.4.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/README.md +2 -2
- package/dist/cli.mjs +5495 -4645
- package/dist/prompts/research-tool-usage.md +1 -1
- package/dist/prompts/researcher.md +1 -0
- package/dist/prompts/system-lead-router.md +122 -0
- package/dist/prompts/system-lead-synthesizer.md +46 -0
- package/dist/thread-worker.mjs +94 -19
- package/docs/ARCHITECTURE.md +18 -10
- package/docs/CHANGELOG.md +149 -0
- package/docs/CONFIGURATION.md +106 -7
- package/package.json +2 -2
- package/scripts/build.cjs +8 -1
- package/skills/pi-research/SKILL.md +1 -1
- package/src/cli.ts +5 -1
- package/src/config.ts +12 -8
- package/src/constants.ts +1 -1
- package/src/core/interfaces/observer-interfaces.ts +3 -3
- package/src/core/interfaces/orchestration-interfaces.ts +2 -0
- package/src/core/interfaces/planning-interfaces.ts +7 -1
- package/src/core/interfaces/scheduler-interfaces.ts +18 -2
- package/src/core/llm/agentic-repair.ts +7 -2
- package/src/core/llm/inject-date.ts +4 -1
- package/src/core/llm/llm-utils.ts +7 -0
- package/src/core/planning-service.ts +719 -91
- package/src/core/planning-utils.ts +21 -5
- package/src/core/service-initialization.ts +19 -3
- package/src/healthcheck/index.ts +11 -2
- package/src/index.ts +4 -3
- package/src/infrastructure/browser/browser-client.ts +68 -34
- package/src/infrastructure/browser/browser-task-scheduler.ts +220 -34
- package/src/infrastructure/browser/config.ts +41 -1
- package/src/infrastructure/browser/priority-task-queue.ts +55 -1
- package/src/infrastructure/browser/scheduler-factory.ts +12 -1
- package/src/infrastructure/browser/task-execution-service.ts +5 -5
- package/src/infrastructure/browser/thread-worker-browser.ts +32 -2
- package/src/infrastructure/browser/thread-worker-messaging.ts +38 -6
- package/src/infrastructure/browser/thread-worker.mjs +94 -19
- package/src/infrastructure/browser/thread-worker.ts +2 -2
- package/src/infrastructure/browser/worker-pool-manager.ts +127 -12
- package/src/infrastructure/embedding/embedding-client.ts +14 -4
- package/src/infrastructure/embedding/embedding-factory.ts +83 -15
- package/src/infrastructure/file-lock-service.ts +214 -19
- package/src/infrastructure/knowledge-store-service.ts +14 -0
- package/src/infrastructure/research-run-semaphore.ts +1 -1
- package/src/infrastructure/service-initialization.ts +7 -0
- package/src/knowledge/embedder-init.ts +43 -17
- package/src/logger.ts +11 -0
- package/src/orchestration/deep-research-orchestrator.ts +141 -87
- package/src/orchestration/quick-research-orchestrator.ts +11 -2
- package/src/orchestration/research-orchestration-service.ts +8 -0
- package/src/orchestration/research-synthesis-service.ts +53 -1
- package/src/orchestration/researcher-executor.ts +53 -2
- package/src/orchestration/researcher.ts +15 -2
- package/src/prompts/research-tool-usage.md +1 -1
- package/src/prompts/researcher.md +1 -0
- package/src/prompts/system-lead-router.md +122 -0
- package/src/prompts/system-lead-synthesizer.md +46 -0
- package/src/tools/research-knowledge-search.ts +11 -3
- package/src/tools/scrape.ts +6 -0
- package/src/tools/search.ts +7 -0
- package/src/tools/security.ts +27 -9
- package/src/tools/stackexchange.ts +13 -7
- package/src/tools/youtube-transcript.ts +26 -12
- package/src/utils/cancellation.ts +36 -0
- package/src/utils/coverage-digest.ts +204 -0
- package/src/utils/llm-usage.ts +33 -6
- package/src/utils/metrics-summary.ts +20 -4
- package/src/utils/metrics.ts +27 -1
- package/src/web-research/browser-search.ts +93 -20
- package/dist/prompts/system-lead-evaluator.md +0 -118
- package/src/prompts/system-lead-evaluator.md +0 -118
|
@@ -227,6 +227,33 @@ export async function getEmbedder(config?: Config, _attempt = 0): Promise<IEmbed
|
|
|
227
227
|
const POLL_TIMEOUT_MS = 120_000;
|
|
228
228
|
const deadline = Date.now() + POLL_TIMEOUT_MS;
|
|
229
229
|
|
|
230
|
+
/**
|
|
231
|
+
* How many CONSECUTIVE polls must find a confirmed-live leader's endpoint
|
|
232
|
+
* unusable before its registration is judged stale.
|
|
233
|
+
*
|
|
234
|
+
* Deleting a live leader's entry is the expensive mistake in this loop: the
|
|
235
|
+
* leader is not told, treats a missing entry as benign, and never steps down,
|
|
236
|
+
* so the follower's subsequent election produces a second model init and a
|
|
237
|
+
* second GPU context while the first server keeps running, invisible. The old
|
|
238
|
+
* code reached that from two failed TCP probes ~2.5s apart — well inside a
|
|
239
|
+
* blip from a saturated accept backlog, a paused event loop during a large
|
|
240
|
+
* batch embed, or CPU starvation on a shared box.
|
|
241
|
+
*
|
|
242
|
+
* Not clearing at all is not the answer either: a leader whose HTTP server
|
|
243
|
+
* died while the process lives on leaves a registration nothing else collects,
|
|
244
|
+
* and every later process then falls back to its own in-process embedder. So
|
|
245
|
+
* the threshold is raised rather than removed. Each iteration costs the poll
|
|
246
|
+
* interval plus up to the 2s socket probe (isPortListening) plus up to a
|
|
247
|
+
* further 2s health-check probe (fetchHealth, called below with an explicit
|
|
248
|
+
* short timeout — NOT the client's 120s default, which would let a merely-busy
|
|
249
|
+
* leader blow the outer POLL_TIMEOUT_MS long before this counter ever fired),
|
|
250
|
+
* making this roughly 45s of uninterrupted unreachability — far past any blip,
|
|
251
|
+
* far short of the 120s wait.
|
|
252
|
+
*/
|
|
253
|
+
const UNREACHABLE_POLLS_BEFORE_STALE = 10;
|
|
254
|
+
let unreachablePolls = 0;
|
|
255
|
+
let unreachableEndpoint: string | null = null;
|
|
256
|
+
|
|
230
257
|
logger.info(`[EmbeddingFactory] Another process is initializing (port=${fastPathPort}), waiting...`);
|
|
231
258
|
|
|
232
259
|
while (Date.now() < deadline) {
|
|
@@ -253,24 +280,50 @@ export async function getEmbedder(config?: Config, _attempt = 0): Promise<IEmbed
|
|
|
253
280
|
return localFallbackForMismatch(info.model);
|
|
254
281
|
}
|
|
255
282
|
if (info.port > 0) {
|
|
256
|
-
|
|
257
|
-
|
|
283
|
+
// A new claimant resets the streak — the count must describe ONE endpoint's
|
|
284
|
+
// continuous unreachability, not a tally across successive leaders.
|
|
285
|
+
const endpoint = `${info.serverId}:${info.port}`;
|
|
286
|
+
if (endpoint !== unreachableEndpoint) {
|
|
287
|
+
unreachableEndpoint = endpoint;
|
|
288
|
+
unreachablePolls = 0;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// Reaching the counter below means "not usable this poll" by construction:
|
|
292
|
+
// the only usable outcome returns.
|
|
293
|
+
if (await isPortListening(info.port)) {
|
|
258
294
|
logger.info(`[EmbeddingFactory] Connecting to embedding server on port ${info.port}`);
|
|
259
295
|
const client = new EmbeddingClient(info.port, cfg.EMBEDDING_DEVICE, info.authSecret);
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
296
|
+
try {
|
|
297
|
+
// A short, poll-scale timeout — NOT EmbeddingClient's 120s default. The
|
|
298
|
+
// unreachable-poll count above assumes each iteration costs roughly the poll
|
|
299
|
+
// interval plus this probe's timeout (~2.5s total); at the 120s default, a
|
|
300
|
+
// leader that is merely busy (not dead) can take the full 120s to answer,
|
|
301
|
+
// blowing the outer POLL_TIMEOUT_MS well before the stale-registration
|
|
302
|
+
// threshold below would ever fire. Matches isPortListening's own 2s probe.
|
|
303
|
+
await client.fetchHealth(2_000);
|
|
304
|
+
_embeddingInstance = client;
|
|
305
|
+
_cachedModel = cfg.EMBEDDING_MODEL;
|
|
306
|
+
_cachedDevice = cfg.EMBEDDING_DEVICE;
|
|
307
|
+
return client;
|
|
308
|
+
} catch (err) {
|
|
309
|
+
// The socket accepted but the server did not answer. Previously this
|
|
310
|
+
// rejected out of getEmbedder entirely, so one failed health request
|
|
311
|
+
// against an otherwise-recoverable leader (mid model swap, momentarily
|
|
312
|
+
// wedged) took embedding down for the whole session instead of costing
|
|
313
|
+
// one poll. Count it as an unreachable poll and keep waiting.
|
|
314
|
+
logger.debug(`[EmbeddingFactory] Health check against port ${info.port} failed:`, err);
|
|
315
|
+
}
|
|
265
316
|
}
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
//
|
|
317
|
+
|
|
318
|
+
unreachablePolls++;
|
|
319
|
+
if (unreachablePolls >= UNREACHABLE_POLLS_BEFORE_STALE) {
|
|
320
|
+
logger.warn(
|
|
321
|
+
`[EmbeddingFactory] Registered port ${info.port} unreachable for ${unreachablePolls} consecutive polls ` +
|
|
322
|
+
`(PID ${info.pid} is alive) — treating the registration as stale and electing a replacement.`,
|
|
323
|
+
);
|
|
324
|
+
// serverId-scoped: this snapshot is many seconds old by now and the entry
|
|
325
|
+
// may already belong to a new claimant. See the dead-leader clear above
|
|
326
|
+
// for the failure an unconditional delete causes.
|
|
274
327
|
await stateManager.clearEmbeddingServer({ serverId: info.serverId }).catch((err) => logger.debug('Swallowed clear embedding server error:', err));
|
|
275
328
|
break;
|
|
276
329
|
}
|
|
@@ -355,6 +408,21 @@ export async function getEmbedder(config?: Config, _attempt = 0): Promise<IEmbed
|
|
|
355
408
|
// clearEmbeddingInstance — for testing / restart scenarios
|
|
356
409
|
// ---------------------------------------------------------------------------
|
|
357
410
|
|
|
411
|
+
/**
|
|
412
|
+
* Does THIS process currently hold a live embedding server (as opposed to a client
|
|
413
|
+
* pointing at someone else's, or nothing at all)?
|
|
414
|
+
*
|
|
415
|
+
* Asked by the teardown-time state clear, which is pid-scoped and therefore cannot
|
|
416
|
+
* tell "my leftover registration" from "my server that is still listening". That
|
|
417
|
+
* clear runs before disposeAllServices, so at that moment our own server usually
|
|
418
|
+
* IS still up — and deleting its entry makes it an invisible leader, since its
|
|
419
|
+
* leadership check reads a missing entry as benign and never steps down. Its own
|
|
420
|
+
* dispose clears the entry properly, keyed on serverId.
|
|
421
|
+
*/
|
|
422
|
+
export function ownsLiveEmbeddingServer(): boolean {
|
|
423
|
+
return _embeddingInstance !== null && _embeddingInstance instanceof EmbeddingServer;
|
|
424
|
+
}
|
|
425
|
+
|
|
358
426
|
export async function clearEmbeddingInstance(): Promise<void> {
|
|
359
427
|
if (_embeddingInstance) {
|
|
360
428
|
try {
|
|
@@ -44,6 +44,49 @@ const TRASH_SWEEP_AGE_MS = 10 * 60_000;
|
|
|
44
44
|
*/
|
|
45
45
|
const HEARTBEAT_MIN_INTERVAL_MS = 2_000;
|
|
46
46
|
|
|
47
|
+
/**
|
|
48
|
+
* Ceiling on the held-lock mtime heartbeat interval.
|
|
49
|
+
*
|
|
50
|
+
* The interval derives from liveOwnerStaleThreshold/4, which is sized so a live
|
|
51
|
+
* holder is never judged stale between two refreshes. That is the right shape for
|
|
52
|
+
* the live-owner rule and the wrong size for everything downstream of it: the
|
|
53
|
+
* knowledge-store init lock derived a 60s interval, and _unidentifiedStaleThreshold
|
|
54
|
+
* doubles it, so a lock torn by SIGKILL — unidentifiable by construction — was left
|
|
55
|
+
* alone for two minutes while the acquirer's stall bound gave up after twenty
|
|
56
|
+
* seconds. The recovery the unidentified rule exists to allow could not happen.
|
|
57
|
+
*
|
|
58
|
+
* A utimes every few seconds costs nothing, so cap it. The cap does not change who
|
|
59
|
+
* may be reclaimed: liveOwnerStaleThreshold is untouched, and refreshing more often
|
|
60
|
+
* than required only makes a live holder harder to mistake for a dead one.
|
|
61
|
+
*/
|
|
62
|
+
const HEARTBEAT_MAX_INTERVAL_MS = 5_000;
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Ceiling on how long a lock whose owner cannot be identified is left alone.
|
|
66
|
+
*
|
|
67
|
+
* Such a lock is treated as live (we could not prove otherwise), but "treated as
|
|
68
|
+
* live" must not mean "immortal" — see _shouldReclaim for why the never-steal
|
|
69
|
+
* opt-out would otherwise strand a run slot permanently.
|
|
70
|
+
*/
|
|
71
|
+
const UNIDENTIFIED_MAX_STALE_MS = 10 * 60_000;
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Absolute ceiling on one acquireLock() wait, however healthy the holder looks.
|
|
75
|
+
*
|
|
76
|
+
* `lockTimeout` bounds a STALL — time in which the lock did not change hands and
|
|
77
|
+
* its holder did not refresh it. It deliberately does not bound a wait behind a
|
|
78
|
+
* holder that is visibly working, because that turned ordinary contention into
|
|
79
|
+
* failed runs: the knowledge-store init lock allowed 20s while the same lock
|
|
80
|
+
* covers a post-run FTS rebuild plus optimize, so a second run starting during
|
|
81
|
+
* another's housekeeping failed outright. Twice, on 2026-08-14. Widening the
|
|
82
|
+
* constant is the fix that does not work — housekeeping has no fixed duration.
|
|
83
|
+
*
|
|
84
|
+
* This ceiling is what keeps "wait while progress is happening" from meaning
|
|
85
|
+
* "wait forever" under sustained contention, where the lock keeps changing hands
|
|
86
|
+
* and every observation looks like progress while this caller never wins.
|
|
87
|
+
*/
|
|
88
|
+
export const LOCK_ACQUIRE_CEILING_MS = 5 * 60_000;
|
|
89
|
+
|
|
47
90
|
/** setTimeout/setInterval clamp delays above 2^31-1 to 1ms — treat anything
|
|
48
91
|
* bigger as "no heartbeat" rather than a busy-loop. */
|
|
49
92
|
const MAX_TIMER_DELAY_MS = 0x7fffffff;
|
|
@@ -62,6 +105,13 @@ export interface FileLockOptions {
|
|
|
62
105
|
lockRetryDelay?: number;
|
|
63
106
|
/** Stale lock threshold in milliseconds (default: 15 seconds) */
|
|
64
107
|
lockStaleThreshold?: number;
|
|
108
|
+
/**
|
|
109
|
+
* Absolute ceiling on one acquireLock() wait, however healthy the holder looks
|
|
110
|
+
* (default: LOCK_ACQUIRE_CEILING_MS). `lockTimeout` bounds a stall; this bounds
|
|
111
|
+
* the total. Exposed so the bound is reachable in a test — a five-minute
|
|
112
|
+
* backstop that no test can drive is a five-minute backstop nobody has checked.
|
|
113
|
+
*/
|
|
114
|
+
acquireCeilingMs?: number;
|
|
65
115
|
/**
|
|
66
116
|
* Stale threshold (ms) applied ONLY when the lock's owner process is provably
|
|
67
117
|
* alive. Much larger than lockStaleThreshold so a live process holding the lock
|
|
@@ -95,6 +145,7 @@ export class FileLockService implements IService {
|
|
|
95
145
|
private readonly lockRetryDelay: number;
|
|
96
146
|
private readonly lockStaleThreshold: number;
|
|
97
147
|
private readonly liveOwnerStaleThreshold: number;
|
|
148
|
+
private readonly acquireCeilingMs: number;
|
|
98
149
|
private readonly processLifecycle: IProcessLifecycle | null;
|
|
99
150
|
|
|
100
151
|
// Lock tracking
|
|
@@ -128,18 +179,41 @@ export class FileLockService implements IService {
|
|
|
128
179
|
this.lockStaleThreshold = options.lockStaleThreshold ?? 15000;
|
|
129
180
|
this.liveOwnerStaleThreshold =
|
|
130
181
|
options.liveOwnerStaleThreshold ?? Math.max(120000, this.lockStaleThreshold * 4);
|
|
182
|
+
this.acquireCeilingMs = options.acquireCeilingMs ?? LOCK_ACQUIRE_CEILING_MS;
|
|
131
183
|
this.processLifecycle = options.processLifecycle ?? null;
|
|
132
184
|
}
|
|
133
185
|
|
|
134
186
|
/**
|
|
135
187
|
* Decide whether a contended lock may be reclaimed.
|
|
136
|
-
* - Dead owner
|
|
137
|
-
* -
|
|
138
|
-
*
|
|
139
|
-
*
|
|
140
|
-
*
|
|
141
|
-
*
|
|
142
|
-
*
|
|
188
|
+
* - Dead owner → reclaim immediately (crash cleanup).
|
|
189
|
+
* - Provably-alive owner → reclaim ONLY after liveOwnerStaleThreshold, so we
|
|
190
|
+
* never steal a lock a live process is actively
|
|
191
|
+
* holding during a (possibly slow) critical section.
|
|
192
|
+
* Stealing it would let two writers run the same
|
|
193
|
+
* read-modify-write and lose an update.
|
|
194
|
+
* - Unidentifiable (no pid) → judged by whether this lock has a HEARTBEAT to
|
|
195
|
+
* judge it by; see below.
|
|
196
|
+
*
|
|
197
|
+
* The unidentifiable case used to take the short threshold outright, which put it
|
|
198
|
+
* on the crash-cleanup schedule on the strength of no evidence at all — while
|
|
199
|
+
* `_isOwnerAlive(null)` returns true, so the two halves openly disagreed. The
|
|
200
|
+
* forensic cost was the worse half: the reclaim log read
|
|
201
|
+
* "Cleaning up stale lock file (90s old, owner alive: true)", a sentence
|
|
202
|
+
* describing the one thing this class exists not to do.
|
|
203
|
+
*
|
|
204
|
+
* Treating it as fully alive instead was an overcorrection, and it broke a real
|
|
205
|
+
* recovery: a lock file torn by a SIGKILL between `open(...,'wx')` and `write()`
|
|
206
|
+
* is unidentifiable by construction, and holding it for the live-owner threshold
|
|
207
|
+
* made knowledge-store init FAIL where it used to reclaim and proceed.
|
|
208
|
+
*
|
|
209
|
+
* The resolution is that a pid is not the only liveness evidence available. A
|
|
210
|
+
* lock class with a heartbeat has its mtime refreshed by its holder, so an
|
|
211
|
+
* unreadable lock that has ALSO gone motionless past the stale threshold is very
|
|
212
|
+
* likely abandoned — the heartbeat is exactly the proof that does not need a pid.
|
|
213
|
+
* Where there is no heartbeat (the run semaphore opts out of live-owner reclaim,
|
|
214
|
+
* so its slot files never move) age proves nothing, and only the ten-minute
|
|
215
|
+
* ceiling keeps an unreadable slot file from holding a run slot for the life of
|
|
216
|
+
* the machine.
|
|
143
217
|
*/
|
|
144
218
|
private async _shouldReclaim(
|
|
145
219
|
pid: number | null,
|
|
@@ -148,10 +222,49 @@ export class FileLockService implements IService {
|
|
|
148
222
|
memo?: Map<string, boolean>,
|
|
149
223
|
): Promise<boolean> {
|
|
150
224
|
if (!(await this._resolveOwnerAlive(pid, startTime, memo))) return true;
|
|
151
|
-
if (pid === null) return lockAge > this.
|
|
225
|
+
if (pid === null) return lockAge > this._unidentifiedStaleThreshold();
|
|
152
226
|
return lockAge > this.liveOwnerStaleThreshold;
|
|
153
227
|
}
|
|
154
228
|
|
|
229
|
+
/**
|
|
230
|
+
* How long an unidentifiable lock is left alone before it may be reclaimed.
|
|
231
|
+
*
|
|
232
|
+
* With a heartbeat running for this lock class, a motionless file is evidence of
|
|
233
|
+
* abandonment even though its owner cannot be named, so the ordinary stale
|
|
234
|
+
* threshold applies. Without one — `_startHeartbeat` skips the effectively
|
|
235
|
+
* infinite opt-out — age carries no information and only the absolute ceiling
|
|
236
|
+
* prevents an unreadable file from holding the lock forever.
|
|
237
|
+
*/
|
|
238
|
+
private _unidentifiedStaleThreshold(): number {
|
|
239
|
+
const heartbeatInterval = this._heartbeatIntervalMs();
|
|
240
|
+
// Never shorter than a heartbeat interval plus a tick, or a live holder could be
|
|
241
|
+
// reclaimed in the gap between two refreshes.
|
|
242
|
+
return heartbeatInterval === null
|
|
243
|
+
? UNIDENTIFIED_MAX_STALE_MS
|
|
244
|
+
: Math.max(this.lockStaleThreshold, heartbeatInterval * 2);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* The interval at which a held lock's mtime is refreshed, or null when this lock
|
|
249
|
+
* class opts out of live-owner reclaim entirely and therefore runs no heartbeat.
|
|
250
|
+
*
|
|
251
|
+
* One helper because two call sites depend on the SAME number: the timer that does
|
|
252
|
+
* the refreshing, and the window that judges an unrefreshed lock abandoned. They
|
|
253
|
+
* were computed independently from the same formula, which is a drift waiting to
|
|
254
|
+
* happen in exactly the direction that reclaims a live holder.
|
|
255
|
+
*
|
|
256
|
+
* The opt-out is detected from the DERIVED interval, before the cap: an effectively
|
|
257
|
+
* infinite liveOwnerStaleThreshold is how the run semaphore says "never steal from a
|
|
258
|
+
* live owner", and clamping it into a runnable timer would quietly start a heartbeat
|
|
259
|
+
* there — which in turn would shorten its unidentified window from the ten-minute
|
|
260
|
+
* ceiling to seconds, reclaiming run slots the opt-out exists to protect.
|
|
261
|
+
*/
|
|
262
|
+
private _heartbeatIntervalMs(): number | null {
|
|
263
|
+
const derived = Math.ceil(this.liveOwnerStaleThreshold / 4);
|
|
264
|
+
if (derived > MAX_TIMER_DELAY_MS) return null;
|
|
265
|
+
return Math.min(HEARTBEAT_MAX_INTERVAL_MS, Math.max(HEARTBEAT_MIN_INTERVAL_MS, derived));
|
|
266
|
+
}
|
|
267
|
+
|
|
155
268
|
/**
|
|
156
269
|
* Liveness of a contended owner, memoized per acquire() episode. The memo (keyed
|
|
157
270
|
* by pid:startTime) ensures the PID+start-time check — which may spawn a ps/powershell
|
|
@@ -292,12 +405,12 @@ export class FileLockService implements IService {
|
|
|
292
405
|
*/
|
|
293
406
|
private _startHeartbeat(): void {
|
|
294
407
|
this._stopHeartbeat();
|
|
295
|
-
const interval = Math.max(HEARTBEAT_MIN_INTERVAL_MS, Math.ceil(this.liveOwnerStaleThreshold / 4));
|
|
296
408
|
// An effectively-infinite threshold (the run-semaphore's never-steal-from-a-
|
|
297
409
|
// 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).
|
|
299
|
-
// the opt-out's semantics are unchanged.
|
|
300
|
-
|
|
410
|
+
// timer delay, which Node clamps to 1ms (a utimes busy-loop). _heartbeatIntervalMs
|
|
411
|
+
// returns null there; the opt-out's semantics are unchanged.
|
|
412
|
+
const interval = this._heartbeatIntervalMs();
|
|
413
|
+
if (interval === null) return;
|
|
301
414
|
this.heartbeatTimer = setInterval(() => {
|
|
302
415
|
const handle = this.lockHandle;
|
|
303
416
|
if (handle === null) return;
|
|
@@ -370,8 +483,16 @@ export class FileLockService implements IService {
|
|
|
370
483
|
const ownerAlive = await this._resolveOwnerAlive(parsed?.pid ?? null, parsed?.startTime ?? null, memo);
|
|
371
484
|
|
|
372
485
|
if (await this._shouldReclaim(parsed?.pid ?? null, parsed?.startTime ?? null, lockAge, memo)) {
|
|
486
|
+
// Name the lock and say what was actually known about its owner. The old
|
|
487
|
+
// line printed "owner alive: true" for a lock with no readable pid —
|
|
488
|
+
// because unknown owners report as alive — so the reclaim read as a steal
|
|
489
|
+
// from a living process, and it named no path, in a log every concurrent
|
|
490
|
+
// process shares.
|
|
491
|
+
const owner = parsed?.pid == null
|
|
492
|
+
? 'owner unidentifiable (unreadable lock content)'
|
|
493
|
+
: `owner pid ${parsed.pid} ${ownerAlive ? 'alive but silent' : 'gone'}`;
|
|
373
494
|
logger.log(
|
|
374
|
-
`[FileLockService]
|
|
495
|
+
`[FileLockService] Reclaiming ${this.lockName} — untouched for ${Math.round(lockAge / 1000)}s, ${owner}`
|
|
375
496
|
);
|
|
376
497
|
// The liveness lookups above can take long enough for another process to
|
|
377
498
|
// reclaim the stale lock and write its own fresh one at this path. A bare
|
|
@@ -391,7 +512,7 @@ export class FileLockService implements IService {
|
|
|
391
512
|
return;
|
|
392
513
|
}
|
|
393
514
|
await fs.unlink(trashPath).catch(() => { /* leaked copy; the sweep above collects it */ });
|
|
394
|
-
logger.log(
|
|
515
|
+
logger.log(`[FileLockService] Reclaimed ${this.lockName}`);
|
|
395
516
|
} catch {
|
|
396
517
|
// A failure AFTER the rename means the verify never ran — restore
|
|
397
518
|
// rather than leave the (possibly live) lock destroyed in the trash.
|
|
@@ -469,14 +590,39 @@ export class FileLockService implements IService {
|
|
|
469
590
|
this.resolveTurn = myResolve;
|
|
470
591
|
|
|
471
592
|
const startTime = Date.now();
|
|
593
|
+
// Last moment the contended lock demonstrably moved: it changed hands, or its
|
|
594
|
+
// holder refreshed the mtime its heartbeat maintains. `lockTimeout` is measured
|
|
595
|
+
// from here rather than from startTime, so waiting behind work in progress is
|
|
596
|
+
// free and only a genuinely motionless lock times out.
|
|
597
|
+
let lastProgressAt = startTime;
|
|
598
|
+
let lastSeenUuid: string | null = null;
|
|
599
|
+
let lastSeenMtime = 0;
|
|
472
600
|
let contentionCount = 0;
|
|
473
601
|
// One entry per distinct (pid,startTime) owner seen during THIS acquireLock()
|
|
474
602
|
// call, so a live contended owner waited out across many ~100ms retry ticks is
|
|
475
603
|
// liveness-checked once, not once per tick (no ps/powershell storm on non-Linux).
|
|
476
604
|
const contendedOwnerAliveMemo = new Map<string, boolean>();
|
|
477
605
|
|
|
606
|
+
// `lockRetries` was sized to expire at roughly the same moment as the old
|
|
607
|
+
// elapsed-time timeout (200 x 100ms = 20s), so leaving it alone would cap the
|
|
608
|
+
// progress-aware wait right back at the bound it replaces. A try-take
|
|
609
|
+
// (lockTimeout 0) keeps its configured count exactly: the run semaphore sizes
|
|
610
|
+
// it to complete a dead-owner reclaim and reacquire, and must still fail fast.
|
|
611
|
+
const maxAttempts = this.lockTimeout === 0
|
|
612
|
+
? this.lockRetries
|
|
613
|
+
: this.lockRetries + Math.ceil(this.acquireCeilingMs / Math.max(1, this.lockRetryDelay));
|
|
614
|
+
|
|
478
615
|
try {
|
|
479
|
-
for (let _attempt = 0; _attempt <
|
|
616
|
+
for (let _attempt = 0; _attempt < maxAttempts; _attempt++) {
|
|
617
|
+
// Bound EVERY iteration, not just the ones that reach the timeout check
|
|
618
|
+
// below: the reclaim branches `continue` without sleeping, so a lock that
|
|
619
|
+
// kept changing hands could otherwise spin here indefinitely.
|
|
620
|
+
if (this.lockTimeout > 0 && Date.now() - startTime >= this.acquireCeilingMs) {
|
|
621
|
+
metrics.increment('state_lock_acquire_total', 1, { status: 'timeout' });
|
|
622
|
+
throw new Error(
|
|
623
|
+
`Failed to acquire lock at ${this.lockFilePath}: gave up after ${Date.now() - startTime}ms of contention (ceiling ${this.acquireCeilingMs}ms)`,
|
|
624
|
+
);
|
|
625
|
+
}
|
|
480
626
|
// cleanup() can retire the instance while we are contending here — its
|
|
481
627
|
// drain polls only the HELD handle, so a caller mid-retry (holding
|
|
482
628
|
// nothing yet) is invisible to it and would otherwise win the lock and
|
|
@@ -548,6 +694,12 @@ export class FileLockService implements IService {
|
|
|
548
694
|
const errnoError = error as NodeJS.ErrnoException;
|
|
549
695
|
if (errnoError.code === 'EEXIST') {
|
|
550
696
|
contentionCount++;
|
|
697
|
+
// When the contended lock becomes eligible for reclaim, if this tick can
|
|
698
|
+
// know it. Scoped per observation: a tick that could not read the lock
|
|
699
|
+
// learned nothing, and an eligibility carried over from an earlier one is
|
|
700
|
+
// no longer evidence of anything. See the stall check below for why the
|
|
701
|
+
// stall bound must not fire before this moment.
|
|
702
|
+
let reclaimEligibleAt: number | null = null;
|
|
551
703
|
// Read lock content to verify ownership and liveness before considering stale
|
|
552
704
|
try {
|
|
553
705
|
const rawContent = await fs.readFile(this.lockFilePath, 'utf-8');
|
|
@@ -556,6 +708,30 @@ export class FileLockService implements IService {
|
|
|
556
708
|
const stats = await fs.stat(this.lockFilePath);
|
|
557
709
|
const lockAge = Date.now() - stats.mtimeMs;
|
|
558
710
|
|
|
711
|
+
// Progress: a different owner than last time, or the same owner's
|
|
712
|
+
// heartbeat freshening the file. Either way the system is moving and
|
|
713
|
+
// this wait is queueing, not hanging.
|
|
714
|
+
if (lastSeenUuid !== null && (lockUuid !== lastSeenUuid || stats.mtimeMs > lastSeenMtime)) {
|
|
715
|
+
lastProgressAt = Date.now();
|
|
716
|
+
}
|
|
717
|
+
lastSeenUuid = lockUuid;
|
|
718
|
+
lastSeenMtime = stats.mtimeMs;
|
|
719
|
+
|
|
720
|
+
// A lock with no readable pid is judged purely on age (see
|
|
721
|
+
// _shouldReclaim), and its mtime never moves again — so it can never
|
|
722
|
+
// show progress and the stall bound is guaranteed to expire first.
|
|
723
|
+
// Record when it becomes reclaimable so the wait below can outlast the
|
|
724
|
+
// stall bound rather than abandoning the recovery at 20s.
|
|
725
|
+
//
|
|
726
|
+
// Deliberately NOT extended to a live owner: there the heartbeat is the
|
|
727
|
+
// designed progress signal, its absence past lockTimeout is real evidence
|
|
728
|
+
// of a wedge, and reporting that to the caller promptly beats waiting out
|
|
729
|
+
// a two-to-four-minute live-owner threshold on a lock we may well never
|
|
730
|
+
// be handed.
|
|
731
|
+
reclaimEligibleAt = (parsed?.pid ?? null) === null
|
|
732
|
+
? stats.mtimeMs + this._unidentifiedStaleThreshold()
|
|
733
|
+
: null;
|
|
734
|
+
|
|
559
735
|
if (lockUuid === this.lockUuid) {
|
|
560
736
|
// This is our own lock — a previous attempt was interrupted. Reclaim.
|
|
561
737
|
try { await fs.unlink(this.lockFilePath); } catch { /* ignore */ }
|
|
@@ -613,12 +789,31 @@ export class FileLockService implements IService {
|
|
|
613
789
|
// Fall through to the timeout check + backoff below.
|
|
614
790
|
}
|
|
615
791
|
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
792
|
+
const stalledFor = Date.now() - lastProgressAt;
|
|
793
|
+
const waitedFor = Date.now() - startTime;
|
|
794
|
+
// Never give up on a lock we are about to be ALLOWED to take. The stall
|
|
795
|
+
// bound and the reclaim window are two different clocks, and on every
|
|
796
|
+
// lock class configured here the stall bound is the shorter one — so a
|
|
797
|
+
// torn, ownerless lock timed the acquirer out before its reclaim window
|
|
798
|
+
// opened, and the pid-less recovery path could not run. The knowledge-store
|
|
799
|
+
// init lock is the measured case: reclaimable at 60s, abandoned at 20s.
|
|
800
|
+
// The ceiling below is still absolute.
|
|
801
|
+
// A try-take (lockTimeout 0) is excluded outright: it is configured to fail
|
|
802
|
+
// on the spot rather than wait for anything, and the run semaphore's slot
|
|
803
|
+
// files carry a TEN-MINUTE unidentified window, so honouring eligibility
|
|
804
|
+
// there would turn "is this slot free right now" into a ten-minute block.
|
|
805
|
+
const awaitingReclaim =
|
|
806
|
+
this.lockTimeout > 0 && reclaimEligibleAt !== null && Date.now() < reclaimEligibleAt;
|
|
807
|
+
if ((stalledFor >= this.lockTimeout && !awaitingReclaim) || waitedFor >= this.acquireCeilingMs) {
|
|
808
|
+
metrics.observe('state_lock_acquire_duration_ms', waitedFor, { status: 'timeout' });
|
|
619
809
|
metrics.increment('state_lock_acquire_total', 1, { status: 'timeout' });
|
|
810
|
+
// Say which bound was hit. "Timeout after 20000ms" on a lock whose
|
|
811
|
+
// holder was working the whole time sent readers looking for a
|
|
812
|
+
// deadlock that was never there.
|
|
620
813
|
throw new Error(
|
|
621
|
-
|
|
814
|
+
waitedFor >= this.acquireCeilingMs
|
|
815
|
+
? `Failed to acquire lock at ${this.lockFilePath}: gave up after ${waitedFor}ms of contention (ceiling ${this.acquireCeilingMs}ms)`
|
|
816
|
+
: `Failed to acquire lock at ${this.lockFilePath}: timeout after ${this.lockTimeout}ms with no sign of progress from its holder`,
|
|
622
817
|
{ cause: error },
|
|
623
818
|
);
|
|
624
819
|
}
|
|
@@ -193,6 +193,20 @@ export class KnowledgeStoreService implements IKnowledgeStoreService {
|
|
|
193
193
|
this._initLock = new FileLockService({
|
|
194
194
|
lockFilePath: lockPath,
|
|
195
195
|
lockStaleThreshold: 60000,
|
|
196
|
+
// The default lockTimeout (20s, class default) is the WAITING side's own
|
|
197
|
+
// "give up and throw" bound — separate from, and much shorter than, the
|
|
198
|
+
// liveOwnerStaleThreshold (120s default) that actually governs when a
|
|
199
|
+
// provably-alive holder's lock may be physically reclaimed. This lock
|
|
200
|
+
// protects a post-run FTS rebuild plus optimize with no fixed duration; if
|
|
201
|
+
// that holder has a stretch of non-yielding synchronous work (a single
|
|
202
|
+
// large better-sqlite3 call blocks the event loop for its full duration,
|
|
203
|
+
// during which the heartbeat literally cannot fire) longer than 20s, a
|
|
204
|
+
// waiting caller would abandon and throw "no sign of progress from its
|
|
205
|
+
// holder" against a holder that was never actually wedged — up to 100s
|
|
206
|
+
// before the lock would even become reclaim-eligible. Raised to match
|
|
207
|
+
// liveOwnerStaleThreshold's own default so a live holder is given the same
|
|
208
|
+
// grace period here that the reclaim logic itself already grants it.
|
|
209
|
+
lockTimeout: 120000,
|
|
196
210
|
processLifecycle,
|
|
197
211
|
});
|
|
198
212
|
await this._initLock.initialize();
|
|
@@ -184,7 +184,7 @@ export class ResearchRunSemaphore implements IService {
|
|
|
184
184
|
lockTimeout: 0, // fail immediately on a live-contended slot (try-take)
|
|
185
185
|
lockRetries: 24, // enough iterations to complete a dead-owner reclaim + reacquire
|
|
186
186
|
lockRetryDelay: 50,
|
|
187
|
-
lockStaleThreshold: 15000, //
|
|
187
|
+
lockStaleThreshold: 15000, // derivation base only; no-pid locks now follow the live-owner rule under its 10-minute ceiling
|
|
188
188
|
// A live process holding a run slot for the entire (minutes-long) run is the
|
|
189
189
|
// NORMAL case, not a stall. Never steal from a live owner — doing so would
|
|
190
190
|
// admit an (N+1)th concurrent run, the exact condition this guard prevents.
|
|
@@ -65,6 +65,13 @@ export function registerInfrastructureServices(container: ServiceContainer = get
|
|
|
65
65
|
return new FileLockService({
|
|
66
66
|
lockFilePath: pathConfig.getLockFilePath(),
|
|
67
67
|
processLifecycle,
|
|
68
|
+
// The shared state lock guards a temp-file write plus fsync plus rename —
|
|
69
|
+
// sub-second work, unlike the knowledge-store init lock that the default
|
|
70
|
+
// five-minute ceiling was sized for (an FTS rebuild has no fixed duration).
|
|
71
|
+
// Inheriting that default here would let SDK teardown, which awaits a state
|
|
72
|
+
// write, stall for five minutes under sustained contention where it used to
|
|
73
|
+
// fail in twenty seconds. A ceiling should match what its lock protects.
|
|
74
|
+
acquireCeilingMs: 30_000,
|
|
68
75
|
});
|
|
69
76
|
},
|
|
70
77
|
{
|
|
@@ -73,14 +73,31 @@ export async function warmupPipeline(
|
|
|
73
73
|
useCache: boolean
|
|
74
74
|
): Promise<{ dummy: any; success: boolean; error?: Error }> {
|
|
75
75
|
try {
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
76
|
+
// Inside runCapturingStderr for the same reason the load above is, and for one
|
|
77
|
+
// more: warmup is the FIRST inference, so it is where a GPU that cannot serve the
|
|
78
|
+
// model actually fails. transformers.js reports that failure by printing
|
|
79
|
+
// "An error occurred during model execution: ..." plus a dump of the input tensors
|
|
80
|
+
// to console.error BEFORE rejecting — and this call was the only pipeline
|
|
81
|
+
// invocation in the module left unwrapped, so that dump went straight to the
|
|
82
|
+
// user's terminal. It is pure noise there: the rejection is caught two frames up,
|
|
83
|
+
// the embedder falls back to CPU, and the run continues to completion. What the
|
|
84
|
+
// user saw was a multi-line raw error for something that had already been handled.
|
|
85
|
+
//
|
|
86
|
+
// The load's capture ends before this runs, so wrapping the caller is not enough;
|
|
87
|
+
// and the server's captureStdio around startServer() covers only the FIRST
|
|
88
|
+
// initialization, not the re-initialization that follows every idle-timeout
|
|
89
|
+
// teardown — which is exactly when this was observed (2026-08-16, the fourth
|
|
90
|
+
// init of the session).
|
|
91
|
+
const dummy = await logger.runCapturingStderr(async () =>
|
|
92
|
+
withTimeout(
|
|
93
|
+
pipeline('warmup', {
|
|
94
|
+
pooling: poolingMode as any,
|
|
95
|
+
normalize: false,
|
|
96
|
+
...(useCache === false ? { use_cache: false } : {}),
|
|
97
|
+
}),
|
|
98
|
+
20_000,
|
|
99
|
+
'Model warmup timed out after 20000ms.'
|
|
100
|
+
),
|
|
84
101
|
);
|
|
85
102
|
return { dummy, success: true };
|
|
86
103
|
} catch (warmupErr) {
|
|
@@ -402,16 +419,25 @@ export async function handleWebGPUWarmupError(
|
|
|
402
419
|
initializationTimeoutMs: number,
|
|
403
420
|
useCache: boolean
|
|
404
421
|
): Promise<{ success: boolean; pipeline?: FeatureExtractionPipeline; error?: Error; dummy?: any }> {
|
|
405
|
-
const isValidationError = warmupErr.message.toLowerCase().includes('validation');
|
|
406
|
-
|
|
407
422
|
markWebGpuFallback();
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
423
|
+
|
|
424
|
+
// Deliberately does NOT try to name the cause from this error's text. The split it
|
|
425
|
+
// replaced ("validation error" vs "OOM", chosen on whether the message contains
|
|
426
|
+
// "validation") reported the SYMPTOM as the diagnosis, and got it backwards in the
|
|
427
|
+
// case that matters: when the GPU is out of VRAM, Dawn raises
|
|
428
|
+
// VK_ERROR_OUT_OF_DEVICE_MEMORY through its device-error callback — native stderr,
|
|
429
|
+
// not this promise — and CreateBuffer then hands back an invalid buffer. What
|
|
430
|
+
// reaches JS is the first thing to *use* that buffer, i.e.
|
|
431
|
+
// "[Invalid Buffer] ... While calling CreateBindGroup", which contains the word
|
|
432
|
+
// "validation" and so was logged as a validation error. A 2026-08-16 run read
|
|
433
|
+
// exactly that way in the log while eighteen vkAllocateMemory failures sat directly
|
|
434
|
+
// above it. The native lines are captured into this same log, so point at them
|
|
435
|
+
// rather than paraphrasing them wrongly.
|
|
436
|
+
logger.warn(
|
|
437
|
+
'[embedder] WebGPU error during warmup — falling back to CPU. ' +
|
|
438
|
+
'The underlying cause (commonly VRAM exhaustion) is in the native Dawn/ONNX output logged just above this line.',
|
|
439
|
+
);
|
|
440
|
+
logger.debug('[embedder] Warmup error details:', warmupErr.message);
|
|
415
441
|
|
|
416
442
|
// Clean up
|
|
417
443
|
if (pipeline) {
|
package/src/logger.ts
CHANGED
|
@@ -259,6 +259,17 @@ export class Logger implements ILogger {
|
|
|
259
259
|
timestamp,
|
|
260
260
|
level,
|
|
261
261
|
...getLogContext(),
|
|
262
|
+
// Every process writes to the SAME log file — up to three concurrent research
|
|
263
|
+
// runs plus the elected browser-pool leader — and until this field existed
|
|
264
|
+
// there was nothing to tell their lines apart. Reconstructing a lock
|
|
265
|
+
// contention from the log then meant inferring process boundaries from
|
|
266
|
+
// timing, which is guesswork: two runs a second apart interleave into what
|
|
267
|
+
// reads like one process contradicting itself. Twelve bytes a line buys
|
|
268
|
+
// attributable forensics.
|
|
269
|
+
//
|
|
270
|
+
// After the context spread, not before: which process wrote a line is not
|
|
271
|
+
// something a scoped context gets to claim otherwise.
|
|
272
|
+
pid: process.pid,
|
|
262
273
|
message,
|
|
263
274
|
...(firstError
|
|
264
275
|
? { errorMessage: redactSecrets(firstError.message), errorStack: redactSecrets(firstError.stack ?? '') }
|