@remnic/core 9.25.5 → 9.25.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/access-admin-ops-surface.d.ts +2 -2
- package/dist/access-authorization-probe.d.ts +2 -2
- package/dist/access-boundary.d.ts +2 -2
- package/dist/access-cli.js +1 -1
- package/dist/access-http.d.ts +2 -2
- package/dist/access-identity-continuity-surface.d.ts +1 -1
- package/dist/access-lcm-surface.d.ts +2 -2
- package/dist/access-mcp.d.ts +2 -2
- package/dist/access-observe-write-surface.d.ts +2 -2
- package/dist/access-operations.d.ts +5 -5
- package/dist/access-recall-concurrency.d.ts +2 -2
- package/dist/access-recall-response.d.ts +2 -2
- package/dist/access-recall-surface.d.ts +2 -2
- package/dist/access-schema.d.ts +76 -76
- package/dist/{access-service-BJQ22URi.d.ts → access-service-CIdWkP6J.d.ts} +1 -1
- package/dist/access-service.d.ts +2 -2
- package/dist/access-surface-catalog.d.ts +2 -2
- package/dist/bootstrap.d.ts +1 -1
- package/dist/{chunk-FR75B4UU.js → chunk-KZVPTJNZ.js} +334 -647
- package/dist/chunk-KZVPTJNZ.js.map +1 -0
- package/dist/{cli-1W7cNjJJ.d.ts → cli-Da5kesWC.d.ts} +2 -2
- package/dist/cli.d.ts +3 -3
- package/dist/explicit-capture.d.ts +1 -1
- package/dist/index.d.ts +392 -392
- package/dist/index.js +1 -1
- package/dist/mcp-memory-inspector-app.d.ts +2 -2
- package/dist/{orchestrator-BtA1wC5K.d.ts → orchestrator-CFJ_TG6D.d.ts} +15 -10
- package/dist/orchestrator.d.ts +1 -1
- package/dist/orchestrator.js +1 -1
- package/dist/schemas.d.ts +76 -76
- package/dist/shared-context/manager.d.ts +8 -8
- package/dist/transfer/types.d.ts +66 -66
- package/package.json +2 -2
- package/src/meetings/recall-exclusion.test.ts +4 -2
- package/src/orchestration/generic-recall-paths.ts +119 -0
- package/src/orchestration/namespace-read-fanout.ts +0 -12
- package/src/orchestration/orchestrator-helpers.ts +0 -35
- package/src/orchestration/recall-internal.ts +6 -6
- package/src/orchestration/recall-search-pipeline.test.ts +194 -28
- package/src/orchestration/recall-search-pipeline.ts +39 -89
- package/src/orchestrator.ts +5 -17
- package/src/recall-cold-deadline.test.ts +11 -13
- package/src/testing/subjects/generic-recall-paths.test.ts +157 -0
- package/dist/chunk-FR75B4UU.js.map +0 -1
- package/src/recall/archive-scoring.ts +0 -544
|
@@ -1,544 +0,0 @@
|
|
|
1
|
-
// ---------------------------------------------------------------------------
|
|
2
|
-
// Off-thread archive scoring for the cold-fallback recall path (issue #1674).
|
|
3
|
-
//
|
|
4
|
-
// `searchLongTermArchiveFallback` (orchestrator.ts) falls back to scanning
|
|
5
|
-
// EVERY archived memory file when hybrid/vector search returns zero hits.
|
|
6
|
-
// The scoring loop — for each memory × for each token, `haystack.includes(token)`
|
|
7
|
-
// — is fully synchronous, unbounded, and CPU-bound. Under concurrent recall
|
|
8
|
-
// load this monopolized the JS main thread: N concurrent recalls serialized
|
|
9
|
-
// on one core and each blew past the client-side timeout even though total
|
|
10
|
-
// CPU work would have finished comfortably if parallelized.
|
|
11
|
-
//
|
|
12
|
-
// This module extracts the scoring loop into a pure function and provides two
|
|
13
|
-
// strategies:
|
|
14
|
-
//
|
|
15
|
-
// 1. `SyncArchiveScoring` — runs the pure function on the calling thread
|
|
16
|
-
// (the OLD behavior; preserved for prove-fail
|
|
17
|
-
// tests and as the graceful fallback).
|
|
18
|
-
// 2. `OffThreadArchiveScoring` — dispatches the pure function to a
|
|
19
|
-
// `worker_threads` pool so concurrent recalls
|
|
20
|
-
// run on separate cores instead of serializing
|
|
21
|
-
// on the main thread. Falls back to sync if
|
|
22
|
-
// workers cannot be created.
|
|
23
|
-
//
|
|
24
|
-
// Both strategies share the identical `scoreArchiveMemories` pure function,
|
|
25
|
-
// so the scoring semantics are byte-identical regardless of which path runs.
|
|
26
|
-
// ---------------------------------------------------------------------------
|
|
27
|
-
|
|
28
|
-
import os from "node:os";
|
|
29
|
-
import { Worker } from "node:worker_threads";
|
|
30
|
-
import { log } from "../logger.js";
|
|
31
|
-
import type { MemoryFile } from "../types.js";
|
|
32
|
-
|
|
33
|
-
// ─────────────────────────────────────────────────────────────────────────────
|
|
34
|
-
// Wire types — plain-serializable shapes that cross the worker boundary via
|
|
35
|
-
// structured clone. Only the fields the scoring loop reads are included.
|
|
36
|
-
// ─────────────────────────────────────────────────────────────────────────────
|
|
37
|
-
|
|
38
|
-
/** Minimal serializable projection of {@link MemoryFile} for scoring. */
|
|
39
|
-
export interface ArchiveScoreItem {
|
|
40
|
-
id: string;
|
|
41
|
-
path: string;
|
|
42
|
-
content: string;
|
|
43
|
-
category: string;
|
|
44
|
-
tags: string[];
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
/** Scoring output — maps 1:1 to the relevant QmdSearchResult fields. */
|
|
48
|
-
export interface ArchiveScoreResult {
|
|
49
|
-
docid: string;
|
|
50
|
-
path: string;
|
|
51
|
-
score: number;
|
|
52
|
-
snippet: string;
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
/** Worker request envelope. */
|
|
56
|
-
interface ScoreTask {
|
|
57
|
-
items: ArchiveScoreItem[];
|
|
58
|
-
tokens: string[];
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
/** Worker reply envelope. */
|
|
62
|
-
type ScoreReply = { ok: true; results: ArchiveScoreResult[] } | { ok: false; error: string };
|
|
63
|
-
|
|
64
|
-
// ─────────────────────────────────────────────────────────────────────────────
|
|
65
|
-
// Pure scoring function — shared by both strategies and by the inline worker.
|
|
66
|
-
// Extracted verbatim from the original inline loop in
|
|
67
|
-
// `searchLongTermArchiveFallback` so behavior is identical.
|
|
68
|
-
// ─────────────────────────────────────────────────────────────────────────────
|
|
69
|
-
|
|
70
|
-
/**
|
|
71
|
-
* Score archived memories against query tokens using substring overlap.
|
|
72
|
-
*
|
|
73
|
-
* For each memory, builds a lowercase haystack from `[content, category, ...tags]`,
|
|
74
|
-
* counts how many distinct tokens appear in it, and scores by `hits / tokens.length`.
|
|
75
|
-
* Memories with zero hits are dropped. Snippets are the first 400 chars of content
|
|
76
|
-
* with newlines collapsed to spaces — matching the original orchestrator behavior.
|
|
77
|
-
*
|
|
78
|
-
* This function is intentionally synchronous and CPU-bound; that is precisely
|
|
79
|
-
* why the off-thread strategy exists.
|
|
80
|
-
*/
|
|
81
|
-
export function scoreArchiveMemories(
|
|
82
|
-
items: ReadonlyArray<ArchiveScoreItem>,
|
|
83
|
-
tokens: ReadonlyArray<string>
|
|
84
|
-
): ArchiveScoreResult[] {
|
|
85
|
-
if (items.length === 0 || tokens.length === 0) return [];
|
|
86
|
-
|
|
87
|
-
const scored: ArchiveScoreResult[] = [];
|
|
88
|
-
for (const item of items) {
|
|
89
|
-
const haystack = [item.content, item.category, ...item.tags].join(" ").toLowerCase();
|
|
90
|
-
let hits = 0;
|
|
91
|
-
for (const token of tokens) {
|
|
92
|
-
if (haystack.includes(token)) hits += 1;
|
|
93
|
-
}
|
|
94
|
-
if (hits === 0) continue;
|
|
95
|
-
scored.push({
|
|
96
|
-
docid: item.id,
|
|
97
|
-
path: item.path,
|
|
98
|
-
score: hits / tokens.length,
|
|
99
|
-
snippet: item.content.slice(0, 400).replace(/\n/g, " "),
|
|
100
|
-
});
|
|
101
|
-
}
|
|
102
|
-
return scored;
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
/**
|
|
106
|
-
* Project a {@link MemoryFile} into the minimal serializable shape the scoring
|
|
107
|
-
* function consumes. Called on the main thread BEFORE dispatching to a worker
|
|
108
|
-
* so the heavy `MemoryFrontmatter` (dozens of optional fields, nested objects)
|
|
109
|
-
* never crosses the worker boundary.
|
|
110
|
-
*/
|
|
111
|
-
export function memoryFileToScoreItem(memory: MemoryFile): ArchiveScoreItem {
|
|
112
|
-
return {
|
|
113
|
-
id: memory.frontmatter.id,
|
|
114
|
-
path: memory.path,
|
|
115
|
-
content: memory.content,
|
|
116
|
-
category: memory.frontmatter.category,
|
|
117
|
-
tags: memory.frontmatter.tags ?? [],
|
|
118
|
-
};
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
// ─────────────────────────────────────────────────────────────────────────────
|
|
122
|
-
// Strategy interface
|
|
123
|
-
// ─────────────────────────────────────────────────────────────────────────────
|
|
124
|
-
|
|
125
|
-
/**
|
|
126
|
-
* Pluggable scoring backend. The orchestrator holds one instance and calls
|
|
127
|
-
* `score()` from the cold-fallback path. The default is off-thread; tests and
|
|
128
|
-
* restricted environments can swap in the sync strategy.
|
|
129
|
-
*/
|
|
130
|
-
export interface ArchiveScoringStrategy {
|
|
131
|
-
score(
|
|
132
|
-
items: ReadonlyArray<ArchiveScoreItem>,
|
|
133
|
-
tokens: ReadonlyArray<string>,
|
|
134
|
-
abortSignal?: AbortSignal
|
|
135
|
-
): Promise<ArchiveScoreResult[]>;
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
// ─────────────────────────────────────────────────────────────────────────────
|
|
139
|
-
// 1. SyncArchiveScoring — the OLD serialized behavior, preserved for fallback
|
|
140
|
-
// ─────────────────────────────────────────────────────────────────────────────
|
|
141
|
-
|
|
142
|
-
/**
|
|
143
|
-
* Runs the scoring loop synchronously on the calling thread.
|
|
144
|
-
*
|
|
145
|
-
* This is the exact behavior that caused issue #1674: the synchronous loop
|
|
146
|
-
* blocks the event loop for its entire duration, so concurrent recall
|
|
147
|
-
* requests serialize behind each other. It is retained as the graceful
|
|
148
|
-
* fallback when worker_threads are unavailable, and as the prove-fail
|
|
149
|
-
* baseline in regression tests.
|
|
150
|
-
*/
|
|
151
|
-
export class SyncArchiveScoring implements ArchiveScoringStrategy {
|
|
152
|
-
async score(
|
|
153
|
-
items: ReadonlyArray<ArchiveScoreItem>,
|
|
154
|
-
tokens: ReadonlyArray<string>,
|
|
155
|
-
abortSignal?: AbortSignal
|
|
156
|
-
): Promise<ArchiveScoreResult[]> {
|
|
157
|
-
if (items.length === 0 || tokens.length === 0) return [];
|
|
158
|
-
if (abortSignal?.aborted) return [];
|
|
159
|
-
// Process in chunks so an abort during a large archive scan is observed
|
|
160
|
-
// without burning the full synchronous CPU pass (#1674 review: sync
|
|
161
|
-
// fallback should check mid-scoring abort, like the old inline loop).
|
|
162
|
-
const CHUNK = 500;
|
|
163
|
-
if (items.length <= CHUNK) return scoreArchiveMemories(items, tokens);
|
|
164
|
-
const results: ArchiveScoreResult[] = [];
|
|
165
|
-
for (let i = 0; i < items.length; i += CHUNK) {
|
|
166
|
-
if (abortSignal?.aborted) return [];
|
|
167
|
-
const scored = scoreArchiveMemories(items.slice(i, i + CHUNK), tokens);
|
|
168
|
-
for (const r of scored) results.push(r);
|
|
169
|
-
}
|
|
170
|
-
return results;
|
|
171
|
-
}
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
// ─────────────────────────────────────────────────────────────────────────────
|
|
175
|
-
// Inline worker code (eval mode) — eliminates file-resolution / build-config
|
|
176
|
-
// issues entirely. The worker is self-contained CJS (no imports), so it runs
|
|
177
|
-
// identically under tsx, compiled dist, and published npm packages.
|
|
178
|
-
// ─────────────────────────────────────────────────────────────────────────────
|
|
179
|
-
|
|
180
|
-
/**
|
|
181
|
-
* Inline worker source. Runs via `new Worker(code, { eval: true })`.
|
|
182
|
-
*
|
|
183
|
-
* MUST stay byte-identical to {@link scoreArchiveMemories} above. The
|
|
184
|
-
* regression test "worker scoring matches canonical scoreArchiveMemories"
|
|
185
|
-
* asserts this equivalence at runtime.
|
|
186
|
-
*/
|
|
187
|
-
const WORKER_SOURCE = String.raw`
|
|
188
|
-
const { parentPort } = require('node:worker_threads');
|
|
189
|
-
|
|
190
|
-
function scoreArchiveMemories(items, tokens) {
|
|
191
|
-
if (items.length === 0 || tokens.length === 0) return [];
|
|
192
|
-
var scored = [];
|
|
193
|
-
for (var i = 0; i < items.length; i++) {
|
|
194
|
-
var item = items[i];
|
|
195
|
-
var haystack = [item.content, item.category].concat(item.tags || []).join(' ').toLowerCase();
|
|
196
|
-
var hits = 0;
|
|
197
|
-
for (var j = 0; j < tokens.length; j++) {
|
|
198
|
-
if (haystack.indexOf(tokens[j]) !== -1) hits++;
|
|
199
|
-
}
|
|
200
|
-
if (hits === 0) continue;
|
|
201
|
-
scored.push({
|
|
202
|
-
docid: item.id,
|
|
203
|
-
path: item.path,
|
|
204
|
-
score: hits / tokens.length,
|
|
205
|
-
snippet: item.content.slice(0, 400).replace(/\n/g, ' '),
|
|
206
|
-
});
|
|
207
|
-
}
|
|
208
|
-
return scored;
|
|
209
|
-
}
|
|
210
|
-
|
|
211
|
-
if (parentPort) {
|
|
212
|
-
parentPort.on('message', function(task) {
|
|
213
|
-
try {
|
|
214
|
-
var results = scoreArchiveMemories(task.items, task.tokens);
|
|
215
|
-
parentPort.postMessage({ ok: true, results: results });
|
|
216
|
-
} catch (err) {
|
|
217
|
-
parentPort.postMessage({ ok: false, error: err && err.message ? err.message : String(err) });
|
|
218
|
-
}
|
|
219
|
-
});
|
|
220
|
-
}
|
|
221
|
-
`;
|
|
222
|
-
|
|
223
|
-
// ─────────────────────────────────────────────────────────────────────────────
|
|
224
|
-
// 2. OffThreadArchiveScoring — worker pool for genuine multi-core parallelism
|
|
225
|
-
// ─────────────────────────────────────────────────────────────────────────────
|
|
226
|
-
|
|
227
|
-
/**
|
|
228
|
-
* Default worker pool size. Uses `availableParallelism()` (Node 19.4+) minus
|
|
229
|
-
* one so the main thread always has a dedicated core for I/O dispatch. Capped
|
|
230
|
-
* at 8 to bound memory overhead (each worker has its own V8 heap).
|
|
231
|
-
*/
|
|
232
|
-
function defaultPoolSize(): number {
|
|
233
|
-
const cpus = typeof os.availableParallelism === "function" ? os.availableParallelism() : os.cpus().length;
|
|
234
|
-
return Math.max(1, Math.min(Math.max(1, cpus - 1), 8));
|
|
235
|
-
}
|
|
236
|
-
|
|
237
|
-
/**
|
|
238
|
-
* Safety-net timeout for a single dispatch. The cold-fallback pipeline already
|
|
239
|
-
* has its own deadline mechanism (runColdStepWithinDeadline); this is a
|
|
240
|
-
* last-resort guard so a hung worker never blocks recall indefinitely.
|
|
241
|
-
* Generously large to avoid interfering with large corpora (issue #1674
|
|
242
|
-
* reported scans up to ~70s on large archives).
|
|
243
|
-
*/
|
|
244
|
-
const DISPATCH_TIMEOUT_MS = 120_000;
|
|
245
|
-
|
|
246
|
-
/** Lazy worker pool. Workers are created on first use and recycled. */
|
|
247
|
-
class ArchiveScoringWorkerPool {
|
|
248
|
-
private readonly targetSize: number;
|
|
249
|
-
private workers: Worker[] = [];
|
|
250
|
-
private idle: Worker[] = [];
|
|
251
|
-
private waiters: Array<{ resolve: (worker: Worker) => void; reject: (err: Error) => void }> = [];
|
|
252
|
-
private terminated = false;
|
|
253
|
-
private busy = 0;
|
|
254
|
-
|
|
255
|
-
/**
|
|
256
|
-
* Workers currently checked out and scoring — NOT callers waiting in line.
|
|
257
|
-
* A caller parked in `acquire()` is queued, not running, so this is the only
|
|
258
|
-
* honest measure of real task overlap: with a size-1 pool it never exceeds 1
|
|
259
|
-
* no matter how many callers are queued behind it.
|
|
260
|
-
*/
|
|
261
|
-
get busyWorkers(): number {
|
|
262
|
-
return this.busy;
|
|
263
|
-
}
|
|
264
|
-
|
|
265
|
-
constructor(size: number = defaultPoolSize()) {
|
|
266
|
-
this.targetSize = Math.max(1, size);
|
|
267
|
-
}
|
|
268
|
-
|
|
269
|
-
async run(task: ScoreTask, abortSignal?: AbortSignal): Promise<ArchiveScoreResult[]> {
|
|
270
|
-
if (this.terminated) throw new Error("archive-scoring pool terminated");
|
|
271
|
-
const worker = await this.acquire(abortSignal);
|
|
272
|
-
// If the caller already aborted before dispatch, return the worker to idle
|
|
273
|
-
// instead of retiring it — it was never posted to (#1674).
|
|
274
|
-
if (abortSignal?.aborted) {
|
|
275
|
-
this.release(worker);
|
|
276
|
-
return [];
|
|
277
|
-
}
|
|
278
|
-
let abandoned = false;
|
|
279
|
-
this.busy += 1;
|
|
280
|
-
try {
|
|
281
|
-
return await this.dispatch(worker, task, abortSignal, () => {
|
|
282
|
-
abandoned = true;
|
|
283
|
-
});
|
|
284
|
-
} finally {
|
|
285
|
-
this.busy -= 1;
|
|
286
|
-
if (abandoned) this.retireWorker(worker);
|
|
287
|
-
else this.release(worker);
|
|
288
|
-
}
|
|
289
|
-
}
|
|
290
|
-
|
|
291
|
-
async terminate(): Promise<void> {
|
|
292
|
-
if (this.terminated) return;
|
|
293
|
-
this.terminated = true;
|
|
294
|
-
// Reject all queued waiters so they don't hang indefinitely (#1674).
|
|
295
|
-
const queued = this.waiters;
|
|
296
|
-
this.waiters = [];
|
|
297
|
-
for (const w of queued) w.reject(new Error("archive-scoring pool terminated"));
|
|
298
|
-
const all = [...this.workers];
|
|
299
|
-
this.workers = [];
|
|
300
|
-
this.idle = [];
|
|
301
|
-
await Promise.allSettled(all.map((w) => w.terminate()));
|
|
302
|
-
}
|
|
303
|
-
|
|
304
|
-
private async acquire(abortSignal?: AbortSignal): Promise<Worker> {
|
|
305
|
-
const idle = this.idle.pop();
|
|
306
|
-
if (idle) return idle;
|
|
307
|
-
if (this.workers.length < this.targetSize) return this.spawn();
|
|
308
|
-
// Park until a worker is released. If the caller aborts (or the pool
|
|
309
|
-
// terminates) while queued, reject so the recall falls back to sync
|
|
310
|
-
// instead of consuming a worker for a request that already timed out.
|
|
311
|
-
return new Promise<Worker>((resolve, reject) => {
|
|
312
|
-
const entry = { resolve, reject };
|
|
313
|
-
this.waiters.push(entry);
|
|
314
|
-
if (!abortSignal) return;
|
|
315
|
-
const onAbort = () => {
|
|
316
|
-
const idx = this.waiters.indexOf(entry);
|
|
317
|
-
if (idx !== -1) this.waiters.splice(idx, 1);
|
|
318
|
-
reject(new Error("archive-scoring acquire aborted"));
|
|
319
|
-
};
|
|
320
|
-
if (abortSignal.aborted) { onAbort(); return; }
|
|
321
|
-
abortSignal.addEventListener("abort", onAbort, { once: true });
|
|
322
|
-
});
|
|
323
|
-
}
|
|
324
|
-
|
|
325
|
-
private release(worker: Worker): void {
|
|
326
|
-
const next = this.waiters.shift();
|
|
327
|
-
if (next) {
|
|
328
|
-
next.resolve(worker);
|
|
329
|
-
} else if (!this.terminated) {
|
|
330
|
-
this.idle.push(worker);
|
|
331
|
-
} else {
|
|
332
|
-
void worker.terminate();
|
|
333
|
-
}
|
|
334
|
-
}
|
|
335
|
-
|
|
336
|
-
/** Terminate a worker that may still be busy, then spawn a replacement
|
|
337
|
-
* if a waiter is queued. */
|
|
338
|
-
private retireWorker(worker: Worker): void {
|
|
339
|
-
const idx = this.workers.indexOf(worker);
|
|
340
|
-
if (idx !== -1) this.workers.splice(idx, 1);
|
|
341
|
-
void worker.terminate();
|
|
342
|
-
const next = this.waiters.shift();
|
|
343
|
-
if (next) next.resolve(this.spawn());
|
|
344
|
-
}
|
|
345
|
-
|
|
346
|
-
private spawn(): Worker {
|
|
347
|
-
const worker = new Worker(WORKER_SOURCE, { eval: true });
|
|
348
|
-
// Unref so idle workers never keep the event loop alive (#1674).
|
|
349
|
-
worker.unref();
|
|
350
|
-
this.workers.push(worker);
|
|
351
|
-
return worker;
|
|
352
|
-
}
|
|
353
|
-
|
|
354
|
-
private dispatch(
|
|
355
|
-
worker: Worker,
|
|
356
|
-
task: ScoreTask,
|
|
357
|
-
abortSignal: AbortSignal | undefined,
|
|
358
|
-
onAbandon: () => void
|
|
359
|
-
): Promise<ArchiveScoreResult[]> {
|
|
360
|
-
return new Promise<ArchiveScoreResult[]>((resolve, reject) => {
|
|
361
|
-
let settled = false;
|
|
362
|
-
const timer = setTimeout(() => {
|
|
363
|
-
if (settled) return;
|
|
364
|
-
settled = true;
|
|
365
|
-
cleanup();
|
|
366
|
-
onAbandon();
|
|
367
|
-
log.debug(`archive-scoring dispatch timed out after ${DISPATCH_TIMEOUT_MS}ms — falling back to sync`);
|
|
368
|
-
reject(new Error(`archive-scoring dispatch timed out after ${DISPATCH_TIMEOUT_MS}ms`));
|
|
369
|
-
}, DISPATCH_TIMEOUT_MS);
|
|
370
|
-
|
|
371
|
-
const onMessage = (reply: ScoreReply) => {
|
|
372
|
-
if (settled) return;
|
|
373
|
-
settled = true;
|
|
374
|
-
cleanup();
|
|
375
|
-
if (reply.ok) resolve(reply.results);
|
|
376
|
-
else reject(new Error(reply.error));
|
|
377
|
-
};
|
|
378
|
-
const onError = (err: Error) => {
|
|
379
|
-
if (settled) return;
|
|
380
|
-
settled = true;
|
|
381
|
-
cleanup();
|
|
382
|
-
onAbandon();
|
|
383
|
-
reject(err);
|
|
384
|
-
};
|
|
385
|
-
// worker.terminate() ends via 'exit', not 'error' — listen so in-flight
|
|
386
|
-
// dispatches during pool shutdown reject immediately instead of hanging
|
|
387
|
-
// until the 120s timeout (#1674).
|
|
388
|
-
const onExit = (code: number) => {
|
|
389
|
-
if (settled) return;
|
|
390
|
-
settled = true;
|
|
391
|
-
cleanup();
|
|
392
|
-
onAbandon();
|
|
393
|
-
reject(new Error(`archive-scoring worker exited with code ${code}`));
|
|
394
|
-
};
|
|
395
|
-
const onAbort = () => {
|
|
396
|
-
if (settled) return;
|
|
397
|
-
settled = true;
|
|
398
|
-
cleanup();
|
|
399
|
-
onAbandon();
|
|
400
|
-
resolve([]);
|
|
401
|
-
};
|
|
402
|
-
|
|
403
|
-
const cleanup = () => {
|
|
404
|
-
clearTimeout(timer);
|
|
405
|
-
worker.off("message", onMessage);
|
|
406
|
-
worker.off("error", onError);
|
|
407
|
-
worker.off("exit", onExit);
|
|
408
|
-
abortSignal?.removeEventListener("abort", onAbort);
|
|
409
|
-
};
|
|
410
|
-
|
|
411
|
-
worker.on("message", onMessage);
|
|
412
|
-
worker.on("error", onError);
|
|
413
|
-
worker.on("exit", onExit);
|
|
414
|
-
abortSignal?.addEventListener("abort", onAbort, { once: true });
|
|
415
|
-
worker.postMessage(task);
|
|
416
|
-
});
|
|
417
|
-
}
|
|
418
|
-
}
|
|
419
|
-
|
|
420
|
-
/**
|
|
421
|
-
* Off-thread scoring via a `worker_threads` pool.
|
|
422
|
-
*
|
|
423
|
-
* Concurrent `score()` calls are dispatched to separate workers, giving
|
|
424
|
-
* genuine multi-core parallelism: K concurrent recalls run on K cores
|
|
425
|
-
* instead of serializing on the main JS thread. If the pool cannot be
|
|
426
|
-
* created (e.g. restricted runtime), it transparently falls back to
|
|
427
|
-
* {@link SyncArchiveScoring} so recall never breaks.
|
|
428
|
-
*/
|
|
429
|
-
export class OffThreadArchiveScoring implements ArchiveScoringStrategy {
|
|
430
|
-
private pool: ArchiveScoringWorkerPool | null = null;
|
|
431
|
-
private poolFailed = false;
|
|
432
|
-
private readonly syncFallback = new SyncArchiveScoring();
|
|
433
|
-
|
|
434
|
-
constructor(poolSize?: number) {
|
|
435
|
-
if (poolSize !== undefined) {
|
|
436
|
-
this.pool = new ArchiveScoringWorkerPool(poolSize);
|
|
437
|
-
}
|
|
438
|
-
}
|
|
439
|
-
|
|
440
|
-
/** Workers currently scoring; 0 when no pool has been created yet. */
|
|
441
|
-
get busyWorkers(): number {
|
|
442
|
-
return this.pool?.busyWorkers ?? 0;
|
|
443
|
-
}
|
|
444
|
-
|
|
445
|
-
async score(
|
|
446
|
-
items: ReadonlyArray<ArchiveScoreItem>,
|
|
447
|
-
tokens: ReadonlyArray<string>,
|
|
448
|
-
abortSignal?: AbortSignal
|
|
449
|
-
): Promise<ArchiveScoreResult[]> {
|
|
450
|
-
if (items.length === 0 || tokens.length === 0) return [];
|
|
451
|
-
if (abortSignal?.aborted) return [];
|
|
452
|
-
|
|
453
|
-
// Lazy pool init — workers are only created when the cold-fallback path
|
|
454
|
-
// is first hit, so hot-path recall pays zero overhead.
|
|
455
|
-
if (this.pool === null && !this.poolFailed) {
|
|
456
|
-
try {
|
|
457
|
-
this.pool = new ArchiveScoringWorkerPool();
|
|
458
|
-
} catch (err) {
|
|
459
|
-
this.poolFailed = true;
|
|
460
|
-
log.debug(`archive-scoring: worker pool unavailable, using sync fallback — ${(err as Error).message}`);
|
|
461
|
-
}
|
|
462
|
-
}
|
|
463
|
-
|
|
464
|
-
if (this.pool !== null) {
|
|
465
|
-
const dispatchStart = Date.now();
|
|
466
|
-
try {
|
|
467
|
-
const task: ScoreTask = {
|
|
468
|
-
items: items as ArchiveScoreItem[],
|
|
469
|
-
tokens: tokens as string[],
|
|
470
|
-
};
|
|
471
|
-
const results = await this.pool.run(task, abortSignal);
|
|
472
|
-
if (abortSignal?.aborted) return [];
|
|
473
|
-
return results;
|
|
474
|
-
} catch (err) {
|
|
475
|
-
// Timeout or worker error — fall back to sync scoring so recall
|
|
476
|
-
// quality is never silently dropped. An already-aborted signal
|
|
477
|
-
// short-circuits first. If the dispatch consumed most of the timeout
|
|
478
|
-
// budget (genuine timeout, not a fast error), skip the sync rescore —
|
|
479
|
-
// the recall deadline has very likely expired by then (#1674).
|
|
480
|
-
if (abortSignal?.aborted) return [];
|
|
481
|
-
if (Date.now() - dispatchStart > DISPATCH_TIMEOUT_MS * 0.5) return [];
|
|
482
|
-
log.debug(`archive-scoring: worker dispatch failed, using sync fallback — ${(err as Error).message}`);
|
|
483
|
-
}
|
|
484
|
-
}
|
|
485
|
-
|
|
486
|
-
return this.syncFallback.score(items, tokens, abortSignal);
|
|
487
|
-
}
|
|
488
|
-
|
|
489
|
-
/** @internal — terminate the underlying pool (tests / shutdown). */
|
|
490
|
-
async terminate(): Promise<void> {
|
|
491
|
-
if (this.pool !== null) {
|
|
492
|
-
await this.pool.terminate();
|
|
493
|
-
this.pool = null;
|
|
494
|
-
}
|
|
495
|
-
}
|
|
496
|
-
}
|
|
497
|
-
|
|
498
|
-
// ─────────────────────────────────────────────────────────────────────────────
|
|
499
|
-
// Factory + process-wide default
|
|
500
|
-
// ─────────────────────────────────────────────────────────────────────────────
|
|
501
|
-
|
|
502
|
-
let defaultStrategy: ArchiveScoringStrategy | null = null;
|
|
503
|
-
|
|
504
|
-
/**
|
|
505
|
-
* Process-wide default archive-scoring strategy. Lazily creates an
|
|
506
|
-
* {@link OffThreadArchiveScoring} on first use. All orchestrator instances
|
|
507
|
-
* share one pool — a single daemon serves all concurrent sessions, so one
|
|
508
|
-
* shared pool is the correct sizing unit.
|
|
509
|
-
*/
|
|
510
|
-
export function getDefaultArchiveScoring(): ArchiveScoringStrategy {
|
|
511
|
-
if (defaultStrategy === null) {
|
|
512
|
-
defaultStrategy = new OffThreadArchiveScoring();
|
|
513
|
-
}
|
|
514
|
-
return defaultStrategy;
|
|
515
|
-
}
|
|
516
|
-
|
|
517
|
-
/**
|
|
518
|
-
* Dispose the process-wide default archive-scoring strategy, terminating any
|
|
519
|
-
* worker threads. Called from `Orchestrator.destroy()` so worker threads don't
|
|
520
|
-
* outlive the orchestrator (#1674). The strategy is lazily recreated on the
|
|
521
|
-
* next cold-fallback recall, so this is safe to call from tests that create
|
|
522
|
-
* and destroy orchestrator instances.
|
|
523
|
-
*/
|
|
524
|
-
export async function disposeDefaultArchiveScoring(): Promise<void> {
|
|
525
|
-
if (defaultStrategy !== null) {
|
|
526
|
-
const strategy = defaultStrategy;
|
|
527
|
-
defaultStrategy = null;
|
|
528
|
-
if (strategy instanceof OffThreadArchiveScoring) {
|
|
529
|
-
await strategy.terminate();
|
|
530
|
-
}
|
|
531
|
-
}
|
|
532
|
-
}
|
|
533
|
-
|
|
534
|
-
/**
|
|
535
|
-
* Create a fresh strategy instance (for tests that need isolation or
|
|
536
|
-
* explicit control over pool size / sync vs off-thread).
|
|
537
|
-
*/
|
|
538
|
-
export function createArchiveScoring(opts?: {
|
|
539
|
-
poolSize?: number;
|
|
540
|
-
sync?: boolean;
|
|
541
|
-
}): ArchiveScoringStrategy {
|
|
542
|
-
if (opts?.sync) return new SyncArchiveScoring();
|
|
543
|
-
return new OffThreadArchiveScoring(opts?.poolSize);
|
|
544
|
-
}
|