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