@dzhechkov/harness-core 0.8.36 → 0.8.37
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/.dz-manifest.json +195 -75
- package/README.md +235 -8
- package/dist/agentdb-index.d.ts +87 -7
- package/dist/agentdb-index.d.ts.map +1 -1
- package/dist/agentdb-index.js +416 -57
- package/dist/agentdb-index.js.map +1 -1
- package/dist/apply-leg.d.ts +19 -1
- package/dist/apply-leg.d.ts.map +1 -1
- package/dist/apply-leg.js +187 -36
- package/dist/apply-leg.js.map +1 -1
- package/dist/codex-rollouts.d.ts +118 -0
- package/dist/codex-rollouts.d.ts.map +1 -0
- package/dist/codex-rollouts.js +297 -0
- package/dist/codex-rollouts.js.map +1 -0
- package/dist/cost-ledger.d.ts +56 -4
- package/dist/cost-ledger.d.ts.map +1 -1
- package/dist/cost-ledger.js +176 -20
- package/dist/cost-ledger.js.map +1 -1
- package/dist/cross-family-control.d.ts +345 -0
- package/dist/cross-family-control.d.ts.map +1 -0
- package/dist/cross-family-control.js +802 -0
- package/dist/cross-family-control.js.map +1 -0
- package/dist/debt-ratchet.d.ts +53 -0
- package/dist/debt-ratchet.d.ts.map +1 -0
- package/dist/debt-ratchet.js +107 -0
- package/dist/debt-ratchet.js.map +1 -0
- package/dist/embedding-config.d.ts +42 -0
- package/dist/embedding-config.d.ts.map +1 -1
- package/dist/embedding-config.js +106 -10
- package/dist/embedding-config.js.map +1 -1
- package/dist/feature-adr-checkpoints.d.ts +6 -0
- package/dist/feature-adr-checkpoints.d.ts.map +1 -1
- package/dist/feature-adr-checkpoints.js +29 -0
- package/dist/feature-adr-checkpoints.js.map +1 -1
- package/dist/feature-adr-decision-recall.d.ts +2 -2
- package/dist/feature-adr-decision-recall.d.ts.map +1 -1
- package/dist/feature-adr-decision-recall.js +5 -3
- package/dist/feature-adr-decision-recall.js.map +1 -1
- package/dist/feature-adr-envelope.d.ts +96 -0
- package/dist/feature-adr-envelope.d.ts.map +1 -0
- package/dist/feature-adr-envelope.js +183 -0
- package/dist/feature-adr-envelope.js.map +1 -0
- package/dist/feature-adr-routing.d.ts +64 -0
- package/dist/feature-adr-routing.d.ts.map +1 -1
- package/dist/feature-adr-routing.js +122 -2
- package/dist/feature-adr-routing.js.map +1 -1
- package/dist/feature-adr-stage-canon.d.ts +79 -0
- package/dist/feature-adr-stage-canon.d.ts.map +1 -0
- package/dist/feature-adr-stage-canon.js +117 -0
- package/dist/feature-adr-stage-canon.js.map +1 -0
- package/dist/index.d.ts +19 -9
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +13 -5
- package/dist/index.js.map +1 -1
- package/dist/loop-blobs.generated.js +4 -4
- package/dist/loop-blobs.generated.js.map +1 -1
- package/dist/mutation-gate.d.ts +51 -0
- package/dist/mutation-gate.d.ts.map +1 -1
- package/dist/mutation-gate.js +295 -0
- package/dist/mutation-gate.js.map +1 -1
- package/dist/qe-bridge.d.ts.map +1 -1
- package/dist/qe-bridge.js +4 -2
- package/dist/qe-bridge.js.map +1 -1
- package/dist/qe-findings.d.ts +107 -0
- package/dist/qe-findings.d.ts.map +1 -0
- package/dist/qe-findings.js +417 -0
- package/dist/qe-findings.js.map +1 -0
- package/dist/recap.d.ts +1 -1
- package/dist/recap.d.ts.map +1 -1
- package/dist/recap.js +4 -2
- package/dist/recap.js.map +1 -1
- package/dist/round.d.ts +74 -1
- package/dist/round.d.ts.map +1 -1
- package/dist/round.js +112 -4
- package/dist/round.js.map +1 -1
- package/dist/run-records.d.ts +60 -0
- package/dist/run-records.d.ts.map +1 -1
- package/dist/run-records.js +244 -2
- package/dist/run-records.js.map +1 -1
- package/dist/score.d.ts +44 -1
- package/dist/score.d.ts.map +1 -1
- package/dist/score.js +78 -5
- package/dist/score.js.map +1 -1
- package/package.json +1 -1
- package/sbom.json +374 -74
- package/src/agentdb-index.ts +423 -60
- package/src/apply-leg.ts +187 -36
- package/src/codex-rollouts.ts +374 -0
- package/src/cost-ledger.ts +232 -24
- package/src/cross-family-control.ts +960 -0
- package/src/debt-ratchet.ts +143 -0
- package/src/embedding-config.ts +131 -10
- package/src/feature-adr-checkpoints.ts +29 -0
- package/src/feature-adr-decision-recall.ts +6 -4
- package/src/feature-adr-envelope.ts +242 -0
- package/src/feature-adr-routing.ts +139 -2
- package/src/feature-adr-stage-canon.ts +141 -0
- package/src/index.ts +60 -6
- package/src/loop-blobs.generated.ts +4 -4
- package/src/mutation-gate.ts +316 -0
- package/src/qe-bridge.ts +4 -2
- package/src/qe-findings.ts +463 -0
- package/src/recap.ts +10 -3
- package/src/round.ts +165 -6
- package/src/run-records.ts +282 -2
- package/src/score.ts +115 -6
package/src/agentdb-index.ts
CHANGED
|
@@ -34,6 +34,7 @@ import {
|
|
|
34
34
|
readEmbedManifest,
|
|
35
35
|
resolveEmbedModel,
|
|
36
36
|
writeEmbedManifest,
|
|
37
|
+
type EmbedDtype,
|
|
37
38
|
} from './embedding-config.js';
|
|
38
39
|
|
|
39
40
|
/** One record to index. `text` is stored as `approach` AND embedded (`${taskType}: ${text}`). */
|
|
@@ -50,7 +51,15 @@ export interface AgentdbRow {
|
|
|
50
51
|
|
|
51
52
|
/** Outcome of {@link indexPatternsToAgentdb}. `generationBumped`/`generationReason` are present only
|
|
52
53
|
* when a store write actually happened (`indexed > 0`) — FR-4: a failed counter write NEVER fails
|
|
53
|
-
* the indexing call itself, it is only reported so a caller (`dz doctor`, telemetry) can see it.
|
|
54
|
+
* the indexing call itself, it is only reported so a caller (`dz doctor`, telemetry) can see it.
|
|
55
|
+
*
|
|
56
|
+
* Fix-round 1 (CRITICAL, item 1a): `indexed`/`generationBumped`/`generationReason` and `error` are
|
|
57
|
+
* NOT mutually exclusive. A failure AFTER the row commit (today, only `writeEmbedManifest` throwing)
|
|
58
|
+
* reports the REAL `indexed` count and the REAL bump outcome alongside `error` — it never collapses
|
|
59
|
+
* back to `{indexed: 0, error}` once rows are already on disk. Collapsing to `indexed: 0` after a
|
|
60
|
+
* real commit was the CRITICAL finding: a caller reading `indexed === 0` as "nothing happened" would
|
|
61
|
+
* skip its own rescue-bump logic even though the store had genuinely changed — an under-bump C-1
|
|
62
|
+
* forbids. */
|
|
54
63
|
export interface AgentdbIndexResult {
|
|
55
64
|
readonly indexed: number;
|
|
56
65
|
readonly error?: string | undefined;
|
|
@@ -145,12 +154,38 @@ function generationFilePath(dbFile: string): string {
|
|
|
145
154
|
return `${dbFile}.generation`;
|
|
146
155
|
}
|
|
147
156
|
|
|
157
|
+
/** `<dbFile>.generation.recovered` (T2, `store-generation-residuals`, record `1d465496`) — the
|
|
158
|
+
* corrupt-sidecar recovery floor's own memory, a SEPARATE file next to the counter (never a
|
|
159
|
+
* module-level variable: two OS processes do not share one, and two processes are exactly what
|
|
160
|
+
* race here). Substring `.generation` deliberately preserved so any future sidecar-enumeration
|
|
161
|
+
* point that greps for the counter family (checked, none exists today — see Р-2 in
|
|
162
|
+
* `features/store-generation-residuals/06_implementation_plan.md`) still finds this file. */
|
|
163
|
+
function recoveredMemoryFilePath(genFile: string): string {
|
|
164
|
+
return `${genFile}.recovered`;
|
|
165
|
+
}
|
|
166
|
+
|
|
148
167
|
/** AM-5 (fix-round): the ONLY shape {@link readStoreGeneration} trusts — one or more ASCII digits,
|
|
149
168
|
* nothing else. `Number.parseInt` alone accepts a leading-numeric-with-trailing-junk string like
|
|
150
169
|
* `"12junk"` as `12`; that reads a corrupt sidecar as a plausible generation instead of degrading to
|
|
151
170
|
* the documented `0` compatibility floor. */
|
|
152
171
|
const STRICT_GENERATION = /^\d+$/;
|
|
153
172
|
|
|
173
|
+
/** Shared degrade-to-0 read for any sidecar holding a single non-negative decimal integer: garbage,
|
|
174
|
+
* a missing file, or anything not matching {@link STRICT_GENERATION} reads as `0`, never throws.
|
|
175
|
+
* Both {@link readStoreGeneration} (the counter itself) and T2's recovery memory
|
|
176
|
+
* ({@link recoveredMemoryFilePath}) use this ONE primitive — the recovery memory must degrade
|
|
177
|
+
* exactly like the counter it accompanies (T2 NFR-1), not by a second, possibly-diverging rule. */
|
|
178
|
+
function readNonNegativeIntFile(path: string): number {
|
|
179
|
+
try {
|
|
180
|
+
const raw = readFileSync(path, 'utf8').trim();
|
|
181
|
+
if (!STRICT_GENERATION.test(raw)) return 0;
|
|
182
|
+
const n = Number.parseInt(raw, 10);
|
|
183
|
+
return Number.isFinite(n) && n >= 0 ? n : 0;
|
|
184
|
+
} catch {
|
|
185
|
+
return 0;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
154
189
|
/**
|
|
155
190
|
* FR-2/FR-3 (`store-generation-counter`): the store's write-generation counter, read back. A
|
|
156
191
|
* missing file (a store that predates this feature, or one that has never been written through
|
|
@@ -161,14 +196,7 @@ const STRICT_GENERATION = /^\d+$/;
|
|
|
161
196
|
* to decide validity, only to convert an already-validated string.
|
|
162
197
|
*/
|
|
163
198
|
export function readStoreGeneration(projectRoot: string, dbPath?: string): number {
|
|
164
|
-
|
|
165
|
-
const raw = readFileSync(generationFilePath(resolveAgentdbPath(projectRoot, dbPath)), 'utf8').trim();
|
|
166
|
-
if (!STRICT_GENERATION.test(raw)) return 0;
|
|
167
|
-
const n = Number.parseInt(raw, 10);
|
|
168
|
-
return Number.isFinite(n) && n >= 0 ? n : 0;
|
|
169
|
-
} catch {
|
|
170
|
-
return 0;
|
|
171
|
-
}
|
|
199
|
+
return readNonNegativeIntFile(generationFilePath(resolveAgentdbPath(projectRoot, dbPath)));
|
|
172
200
|
}
|
|
173
201
|
|
|
174
202
|
/**
|
|
@@ -192,6 +220,37 @@ export function readStoreGeneration(projectRoot: string, dbPath?: string): numbe
|
|
|
192
220
|
* regardless, telemetry is never a gate. AM-4: {@link resolveAgentdbPath} itself now runs INSIDE this
|
|
193
221
|
* function's outer `try` — an unresolvable path can no longer throw OUT of `bumpStoreGeneration`
|
|
194
222
|
* either; "never throws" now covers the whole function, not just the file-write tail.
|
|
223
|
+
*
|
|
224
|
+
* T2 (`store-generation-residuals`, record `1d465496`): the corrupt-sidecar recovery floor below
|
|
225
|
+
* used to be a bare `Date.now()` — NOT strictly monotonic on its own (two recoveries inside the same
|
|
226
|
+
* millisecond publish the same value; a backward clock step can publish a SMALLER one than an
|
|
227
|
+
* earlier recovery). It is now `max(now(), lastPublished + 1)`, where `lastPublished` is read from
|
|
228
|
+
* {@link recoveredMemoryFilePath} — a file, not a module-level variable, because the two writers who
|
|
229
|
+
* actually race here are two OS PROCESSES, which do not share process memory. `now` is an injectable
|
|
230
|
+
* time source (default `Date.now`) — AC-3's only reason to exist: a real clock cannot be rolled back
|
|
231
|
+
* from a test.
|
|
232
|
+
*
|
|
233
|
+
* Fix-round 1 (independent Codex review, gpt-5.6-sol — items 2/3/4/5/7), on top of T2:
|
|
234
|
+
* - item 2: {@link recoveredMemoryFilePath} now holds the LAST **published** generation, not the
|
|
235
|
+
* last **recovered** one — it is written on EVERY successful bump, not only inside the
|
|
236
|
+
* corrupt-sidecar branch. Before this fix, a run of ordinary bumps after a recovery left the
|
|
237
|
+
* memory stale, so a LATER recovery under a rolled-back clock could float the counter below a
|
|
238
|
+
* generation an ordinary bump already published (HIGH #2 finding).
|
|
239
|
+
* - item 3: the memory file is published BEFORE the counter file (was: counter first, memory
|
|
240
|
+
* "best-effort" after). A crash between the two writes now leaves the memory AHEAD of the counter
|
|
241
|
+
* — the SAFE direction: the next recovery floors too high rather than too low, so monotonicity
|
|
242
|
+
* survives a half-done bump (HIGH #3 finding).
|
|
243
|
+
* - item 4: a memory-write failure (e.g. a directory sitting at its path) does NOT block the counter
|
|
244
|
+
* publish below it — invalidation is the load-bearing behaviour — but the degradation is reported
|
|
245
|
+
* on stderr via {@link reportBumpMemoryDegraded}, never swallowed. LIMITATION: while the memory
|
|
246
|
+
* sidecar stays unwritable, a future corrupt-sidecar recovery on this store floors only at the wall
|
|
247
|
+
* clock, same as pre-fix-round behaviour — not strictly above every ordinary bump published in the
|
|
248
|
+
* meantime (HIGH #4 finding).
|
|
249
|
+
* - item 5: every error-to-string conversion in this function goes through {@link safeErrorMessage},
|
|
250
|
+
* which cannot itself throw even if `err` carries a poisoned `toString` — "never throws" is
|
|
251
|
+
* absolute (MEDIUM #5 finding).
|
|
252
|
+
* - item 7: the memory sidecar's own tmp file is cleaned up on a failed write, matching the counter's
|
|
253
|
+
* existing tmp-cleanup discipline (LOW #7 finding).
|
|
195
254
|
*/
|
|
196
255
|
/** Codex round-2 (NEW HIGH): most mutators discard the bump result, so a failed bump must be
|
|
197
256
|
* VISIBLE on its own — one stderr line, written by the helper itself. Telemetry never throws. */
|
|
@@ -200,13 +259,50 @@ function reportBumpFailure(error: string): { readonly ok: false; readonly error:
|
|
|
200
259
|
return { ok: false, error };
|
|
201
260
|
}
|
|
202
261
|
|
|
262
|
+
/** Fix-round 1, item 4: a bump whose COUNTER publish succeeded but whose recovery-memory sidecar
|
|
263
|
+
* ({@link recoveredMemoryFilePath}) could not be written must not swallow that fact — same
|
|
264
|
+
* stderr-report shape as {@link reportBumpFailure}, but this one never changes the return value:
|
|
265
|
+
* the counter genuinely advanced, so `{ok:true, generation}` stands. LIMITATION (documented here per
|
|
266
|
+
* the brief, item 4): until the memory sidecar is writable again, a FUTURE corrupt-sidecar recovery
|
|
267
|
+
* on this store is not guaranteed to floor above every generation an ordinary bump already published
|
|
268
|
+
* in the meantime (item 2's fix depends on the memory file being current) — it still floors above the
|
|
269
|
+
* wall clock, same as before this fix-round. */
|
|
270
|
+
function reportBumpMemoryDegraded(reason: string): void {
|
|
271
|
+
try {
|
|
272
|
+
process.stderr.write(
|
|
273
|
+
`dz: store generation recovery memory not updated — ${reason} — a future corrupt-sidecar recovery on this store is not guaranteed to stay strictly monotonic until this is fixed\n`,
|
|
274
|
+
);
|
|
275
|
+
} catch { /* telemetry never throws */ }
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/** Fix-round 1, item 5: `String(err)` itself can throw if `err` carries a poisoned `toString` (or
|
|
279
|
+
* `Error.prototype.message` getter). `bumpStoreGeneration`'s "never throws" contract (FR-4) is
|
|
280
|
+
* ABSOLUTE, so every place in this function that turns a caught error into a string goes through
|
|
281
|
+
* this ONE protected helper — never a bare `err instanceof Error ? err.message : String(err)`.
|
|
282
|
+
* Exported test-only (same convention as {@link needsRescueBump}/{@link resetAgentdbEmbedderCache}).
|
|
283
|
+
*/
|
|
284
|
+
export function safeErrorMessage(err: unknown): string {
|
|
285
|
+
try {
|
|
286
|
+
return err instanceof Error ? err.message : String(err);
|
|
287
|
+
} catch {
|
|
288
|
+
return '(unstringifiable error)';
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
203
292
|
export function bumpStoreGeneration(
|
|
204
293
|
projectRoot: string,
|
|
205
294
|
dbPath?: string,
|
|
295
|
+
/** T2: the ONLY signature extension the plan permits — injectable wall clock, default `Date.now`,
|
|
296
|
+
* so AC-3 (a rolled-back system clock) can be reproduced without touching the real clock. */
|
|
297
|
+
now: () => number = Date.now,
|
|
206
298
|
): { readonly ok: true; readonly generation: number } | { readonly ok: false; readonly error: string } {
|
|
207
299
|
try {
|
|
208
300
|
const dbFile = resolveAgentdbPath(projectRoot, dbPath);
|
|
209
301
|
const genFile = generationFilePath(dbFile);
|
|
302
|
+
// Fix-round 1, item 2: computed UNCONDITIONALLY (was: only inside the corrupt-sidecar branch) —
|
|
303
|
+
// this sidecar now tracks "last PUBLISHED generation", updated on every successful bump, not just
|
|
304
|
+
// a recovery.
|
|
305
|
+
const recoveredMemoryFile = recoveredMemoryFilePath(genFile);
|
|
210
306
|
return withNamedLockSync(
|
|
211
307
|
dirname(dbFile),
|
|
212
308
|
'store-generation',
|
|
@@ -215,25 +311,61 @@ export function bumpStoreGeneration(
|
|
|
215
311
|
// acquisition may already be stale, another holder may have advanced it in the meantime.
|
|
216
312
|
let current = readStoreGeneration(projectRoot, dbPath);
|
|
217
313
|
const tmp = `${genFile}.tmp-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
|
314
|
+
const recTmp = `${recoveredMemoryFile}.tmp-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
|
218
315
|
try {
|
|
219
|
-
// Codex round-2 (AM-2 residual): a sidecar that EXISTS but is
|
|
220
|
-
// reset the counter to 1 — a value an engine-cache entry may
|
|
221
|
-
// corrupt value at
|
|
222
|
-
// count reached by bumping)
|
|
316
|
+
// Codex round-2 (AM-2 residual) / T2 (record `1d465496`): a sidecar that EXISTS but is
|
|
317
|
+
// corrupt reads as 0 and would reset the counter to 1 — a value an engine-cache entry may
|
|
318
|
+
// already be keyed on. Floor a corrupt value at max(wall-clock, lastPublished+1) instead:
|
|
319
|
+
// still monotonic (ms-since-epoch exceeds any count reached by bumping) AND strictly
|
|
320
|
+
// increasing across successive corrupt recoveries even inside the same millisecond or
|
|
321
|
+
// across a backward clock step — the bare `Date.now()` this replaces was neither. Absent
|
|
322
|
+
// file (not corrupt, simply missing) still takes the ordinary `current === 0` path below,
|
|
323
|
+
// unaffected — only a genuinely corrupt EXISTING sidecar enters this branch.
|
|
223
324
|
if (current === 0 && existsSync(genFile)) {
|
|
224
325
|
const raw = readFileSync(genFile, 'utf8').trim();
|
|
225
|
-
if (raw !== '0' && !STRICT_GENERATION.test(raw))
|
|
326
|
+
if (raw !== '0' && !STRICT_GENERATION.test(raw)) {
|
|
327
|
+
// NFR-1: a memory file that cannot be read (missing, or itself corrupt) degrades to 0,
|
|
328
|
+
// exactly like the counter's own read — never a throw, never a special-cased error.
|
|
329
|
+
// Fix-round 1, item 2: `lastPublished` now reflects every prior successful bump
|
|
330
|
+
// (ordinary or recovery), not only the previous recovery — see the doc comment above.
|
|
331
|
+
const lastPublished = readNonNegativeIntFile(recoveredMemoryFile);
|
|
332
|
+
current = Math.max(now(), lastPublished + 1);
|
|
333
|
+
}
|
|
226
334
|
}
|
|
227
335
|
const next = current + 1;
|
|
228
336
|
mkdirSync(dirname(genFile), { recursive: true });
|
|
337
|
+
|
|
338
|
+
// Fix-round 1, item 3: the memory sidecar is published BEFORE the counter sidecar — a crash
|
|
339
|
+
// between the two then leaves the memory AHEAD of the counter (safe: the next recovery
|
|
340
|
+
// floors too high, never too low). Fix-round 1, item 2: this now runs on EVERY successful
|
|
341
|
+
// bump, not only inside the corrupt-sidecar branch above.
|
|
342
|
+
let memoryError: string | undefined;
|
|
343
|
+
try {
|
|
344
|
+
writeFileSync(recTmp, String(next), { encoding: 'utf8', flag: 'wx' });
|
|
345
|
+
renameSync(recTmp, recoveredMemoryFile);
|
|
346
|
+
} catch (memErr) {
|
|
347
|
+
// Fix-round 1, item 4: the counter publish below still goes ahead — invalidation matters
|
|
348
|
+
// more than the memory sidecar — but the degradation is reported, not swallowed (see the
|
|
349
|
+
// stderr write after the counter publish). Fix-round 1, item 7: clean up a half-written
|
|
350
|
+
// memory tmp file the same way the counter's own tmp is cleaned up on failure below.
|
|
351
|
+
try { if (existsSync(recTmp)) unlinkSync(recTmp); } catch { /* best-effort only */ }
|
|
352
|
+
memoryError = safeErrorMessage(memErr);
|
|
353
|
+
}
|
|
354
|
+
|
|
229
355
|
writeFileSync(tmp, String(next), { encoding: 'utf8', flag: 'wx' });
|
|
230
356
|
renameSync(tmp, genFile);
|
|
357
|
+
|
|
358
|
+
// Fix-round 1, item 4: reported AFTER the counter publish succeeds, so the stderr line
|
|
359
|
+
// never implies the bump itself failed — it names exactly the narrower, degraded guarantee.
|
|
360
|
+
if (memoryError !== undefined) reportBumpMemoryDegraded(memoryError);
|
|
361
|
+
|
|
231
362
|
return { ok: true, generation: next };
|
|
232
363
|
} catch (err) {
|
|
233
364
|
// Best-effort cleanup of a half-written temp file (e.g. rename failed after a successful
|
|
234
365
|
// write) so it never lingers as clutter — never lets a cleanup failure mask the real error.
|
|
235
366
|
try { if (existsSync(tmp)) unlinkSync(tmp); } catch { /* best-effort only */ }
|
|
236
|
-
|
|
367
|
+
try { if (existsSync(recTmp)) unlinkSync(recTmp); } catch { /* best-effort only */ } // item 7
|
|
368
|
+
return reportBumpFailure(`store generation bump failed: ${safeErrorMessage(err)}`);
|
|
237
369
|
}
|
|
238
370
|
},
|
|
239
371
|
);
|
|
@@ -241,7 +373,7 @@ export function bumpStoreGeneration(
|
|
|
241
373
|
// AM-2/FR-4: a lock that could not be acquired by its deadline (`NamedLockTimeoutError`) — and
|
|
242
374
|
// any other failure reaching this point (an unresolvable path, AM-4) — degrades to the same
|
|
243
375
|
// honest `{ok:false, error}` shape; it never throws into the store write it accompanies.
|
|
244
|
-
return reportBumpFailure(`store generation bump failed: ${
|
|
376
|
+
return reportBumpFailure(`store generation bump failed: ${safeErrorMessage(err)}`);
|
|
245
377
|
}
|
|
246
378
|
}
|
|
247
379
|
|
|
@@ -256,37 +388,25 @@ export async function indexPatternsToAgentdb(
|
|
|
256
388
|
): Promise<AgentdbIndexResult> {
|
|
257
389
|
if (rows.length === 0) return { indexed: 0 };
|
|
258
390
|
let sqliteUrl: string;
|
|
259
|
-
let agentdbDir: string;
|
|
260
391
|
try {
|
|
261
392
|
const req = createRequire(join(projectRoot, 'package.json'));
|
|
262
393
|
sqliteUrl = pathToFileURL(req.resolve('better-sqlite3')).href;
|
|
263
|
-
agentdbDir = dirname(req.resolve('agentdb'));
|
|
264
394
|
} catch {
|
|
265
395
|
return { indexed: 0, error: 'agentdb/better-sqlite3 not installed in project (run: dz setup --memory agentdb)' };
|
|
266
396
|
}
|
|
267
397
|
try {
|
|
268
398
|
const { default: Database } = (await import(sqliteUrl)) as { default: new (p: string) => NativeDb };
|
|
269
|
-
const { EmbeddingService } = (await import(pathToFileURL(join(agentdbDir, 'controllers', 'EmbeddingService.js')).href)) as {
|
|
270
|
-
EmbeddingService: new (o: object) => { initialize: () => Promise<void>; embed: (t: string) => Promise<Float32Array> };
|
|
271
|
-
};
|
|
272
399
|
const model = resolveEmbedModel(projectRoot);
|
|
273
400
|
if ('error' in model) return { indexed: 0, error: model.error };
|
|
274
|
-
const emb = new EmbeddingService({
|
|
275
|
-
model: model.model,
|
|
276
|
-
dimension: model.dim,
|
|
277
|
-
provider: 'transformers',
|
|
278
|
-
// agentdb >= 3.0.0-alpha.20 refuses UNREGISTERED models without an explicit role policy
|
|
279
|
-
// (its built-in registry knows all-MiniLM-L6-v2 but not our multilingual variant — grounded
|
|
280
|
-
// in dist/src/controllers/EmbeddingService.js:53). paraphrase-multilingual-MiniLM is a
|
|
281
|
-
// SYMMETRIC sentence-transformer (no query/passage instruction prefixes), so the policy is
|
|
282
|
-
// {kind:'symmetric'} — the same one the registry assigns its own symmetric models. On
|
|
283
|
-
// alpha.18 the extra field is ignored; without it alpha.20 threw and the vector tier fell
|
|
284
|
-
// to lexical SILENTLY (mirror writes answered {indexed:0, error} — measured 2026-08-24).
|
|
285
|
-
rolePolicy: { kind: 'symmetric' },
|
|
286
|
-
} as never);
|
|
287
|
-
await emb.initialize();
|
|
288
|
-
|
|
289
401
|
const dbFile = resolveAgentdbPath(projectRoot, opts.dbPath);
|
|
402
|
+
// D1 (embed-daemon-memory, ADR-001): a single embedder per process — the SAME cached pipeline
|
|
403
|
+
// `resolveAgentdbEmbedder` hands to search/the daemon, never a private `new EmbeddingService(...)`
|
|
404
|
+
// built here. `dbFile` (not just `projectRoot`) so an EXISTING store's dtype (manifest, D2) wins
|
|
405
|
+
// over the config for an ordinary incremental index — only `reindexAgentdbRows` re-stamps the
|
|
406
|
+
// manifest first and thereby moves the dtype (see resolveStoreEmbedDtype's own doc comment).
|
|
407
|
+
const emb = await resolveAgentdbEmbedder(projectRoot, dbFile);
|
|
408
|
+
if ('error' in emb) return { indexed: 0, error: emb.error };
|
|
409
|
+
|
|
290
410
|
const db = new Database(dbFile);
|
|
291
411
|
try {
|
|
292
412
|
db.pragma('journal_mode = WAL');
|
|
@@ -337,7 +457,21 @@ export async function indexPatternsToAgentdb(
|
|
|
337
457
|
// whichever of the two calls below throws, the generation is already correct for the rows that
|
|
338
458
|
// are already on disk.
|
|
339
459
|
const bump = bumpStoreGeneration(projectRoot, opts.dbPath);
|
|
340
|
-
|
|
460
|
+
// Fix-round 1 (CRITICAL, item 1a): `writeEmbedManifest` can throw (a directory sitting at the
|
|
461
|
+
// manifest sidecar path — see the AM-3 test). Before this fix, that throw escaped to the outer
|
|
462
|
+
// `catch` below, which returned the GENERIC `{indexed: 0, error: ...}` — discarding the two
|
|
463
|
+
// facts already true by this point: `indexed` rows are on disk, and `bump` already ran. A
|
|
464
|
+
// caller reading `indexed === 0` would conclude "nothing happened" and skip its own rescue-bump
|
|
465
|
+
// logic even though the store had genuinely changed — the under-bump C-1 forbids. Catching the
|
|
466
|
+
// throw HERE, with the real `indexed`/bump outcome already captured in scope, preserves both.
|
|
467
|
+
try {
|
|
468
|
+
writeEmbedManifest(dbFile, currentEmbedManifest(model, guard.manifest.version, 'agentdb'));
|
|
469
|
+
} catch (manifestErr) {
|
|
470
|
+
const manifestError = `index failed: ${safeErrorMessage(manifestErr)}`;
|
|
471
|
+
return bump.ok
|
|
472
|
+
? { indexed, generationBumped: true, error: manifestError }
|
|
473
|
+
: { indexed, generationBumped: false, generationReason: bump.error, error: manifestError };
|
|
474
|
+
}
|
|
341
475
|
return bump.ok
|
|
342
476
|
? { indexed, generationBumped: true }
|
|
343
477
|
: { indexed, generationBumped: false, generationReason: bump.error };
|
|
@@ -345,7 +479,7 @@ export async function indexPatternsToAgentdb(
|
|
|
345
479
|
db.close();
|
|
346
480
|
}
|
|
347
481
|
} catch (err) {
|
|
348
|
-
return { indexed: 0, error: `index failed: ${
|
|
482
|
+
return { indexed: 0, error: `index failed: ${safeErrorMessage(err)}` };
|
|
349
483
|
}
|
|
350
484
|
}
|
|
351
485
|
|
|
@@ -420,21 +554,147 @@ type Embedder = { embed: (t: string) => Promise<Float32Array> } | { error: strin
|
|
|
420
554
|
*/
|
|
421
555
|
const embedderCache = new Map<string, Promise<Embedder>>();
|
|
422
556
|
let embedderCacheInitializations = 0;
|
|
557
|
+
/**
|
|
558
|
+
* Fix round 1 (F6, Codex #6): `embedderCacheInitializations` only counts calls to
|
|
559
|
+
* {@link resolveAgentdbEmbedder} that missed the cache — it proves cache REUSE, not that a real
|
|
560
|
+
* pipeline was actually constructed. This counter increments at the exact two call sites where a
|
|
561
|
+
* pipeline construction primitive actually runs: the direct `pipeline('feature-extraction', …)` call
|
|
562
|
+
* in {@link initAgentdbEmbedder} and `EmbeddingService.initialize()` in
|
|
563
|
+
* {@link initViaAgentdbEmbeddingService} (the COMPAT FALLBACK path) — never merely on entry to
|
|
564
|
+
* `initAgentdbEmbedder`, which can also return an `{error}` (dtype:'q8' with no resolvable
|
|
565
|
+
* transformers, NFR-4) without ever attempting either.
|
|
566
|
+
*/
|
|
567
|
+
let embedderCachePipelinesBuilt = 0;
|
|
423
568
|
|
|
424
569
|
/** Test-only (and future warm-start) reset — callers (`vector-tier.ts`, `backlog.ts`) are unaffected. */
|
|
425
570
|
export function resetAgentdbEmbedderCache(): void {
|
|
426
571
|
embedderCache.clear();
|
|
427
572
|
embedderCacheInitializations = 0;
|
|
573
|
+
embedderCachePipelinesBuilt = 0;
|
|
428
574
|
}
|
|
429
575
|
|
|
430
576
|
/** `entries` = cached keys right now — a SUCCESSFUL pipeline or an IN-FLIGHT initialization (the promise is
|
|
431
577
|
* cached before it settles, FR-4; a failed one is evicted, FR-3); `initializations` = pipelines actually
|
|
432
|
-
* started since the last reset. (Codex round-1, 2026-09-14: the earlier wording said "successful" only.)
|
|
433
|
-
|
|
434
|
-
|
|
578
|
+
* started since the last reset. (Codex round-1, 2026-09-14: the earlier wording said "successful" only.)
|
|
579
|
+
* `pipelinesBuilt` (fix round 1, F6) = the count of REAL pipeline-construction primitives that actually
|
|
580
|
+
* ran (`pipeline()` or `EmbeddingService.initialize()`), never merely the number of times the resolver
|
|
581
|
+
* was entered — see {@link embedderCachePipelinesBuilt}'s own doc comment for why the two can diverge. */
|
|
582
|
+
export function getAgentdbEmbedderCacheStats(): { entries: number; initializations: number; pipelinesBuilt: number } {
|
|
583
|
+
return { entries: embedderCache.size, initializations: embedderCacheInitializations, pipelinesBuilt: embedderCachePipelinesBuilt };
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
/**
|
|
587
|
+
* Walk UP from `startDir` (inclusive) looking for `<dir>/node_modules/<name>` as a real, existing
|
|
588
|
+
* path — a PLAIN FILESYSTEM CHECK, deliberately never `require.resolve()` alone. MEASURED
|
|
589
|
+
* 2026-09-16: under this package's own vitest harness, `createRequire(join(projectRoot,
|
|
590
|
+
* 'package.json')).resolve('@huggingface/transformers')` succeeds even for a deliberately isolated
|
|
591
|
+
* `/tmp` fixture that installs no such dependency at all (`agentdb-embedder-cache.test.ts`'s AC-3) —
|
|
592
|
+
* the test runner's module loader resolves more liberally than plain Node does, reaching the
|
|
593
|
+
* monorepo's real install regardless of `projectRoot`. `require.resolve` is used only AFTER this
|
|
594
|
+
* filesystem walk has already named a legitimate ancestor, so it can no longer be fooled that way.
|
|
595
|
+
* Returns the ancestor directory whose OWN `node_modules/<name>` exists, or `undefined` if none does
|
|
596
|
+
* all the way to the filesystem root (a handful of synchronous `existsSync` calls either way).
|
|
597
|
+
*/
|
|
598
|
+
function findAncestorWithModule(startDir: string, name: string): string | undefined {
|
|
599
|
+
let dir = resolve(startDir);
|
|
600
|
+
for (;;) {
|
|
601
|
+
if (existsSync(join(dir, 'node_modules', name))) return dir;
|
|
602
|
+
const parent = dirname(dir);
|
|
603
|
+
if (parent === dir) return undefined;
|
|
604
|
+
dir = parent;
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
/**
|
|
609
|
+
* C-3 (`embed-daemon-memory`): the SAME resolution order the daemon's own `resolveDeps` uses
|
|
610
|
+
* (`.claude/helpers/dz-embed-daemon.mjs`) — project `package.json` first, then `agentdb`'s own
|
|
611
|
+
* declared dependency (possibly hoisted elsewhere) — so core and the daemon agree on which install
|
|
612
|
+
* of transformers they find, in a monorepo or a plain install alike. Each candidate root is
|
|
613
|
+
* confirmed by {@link findAncestorWithModule} BEFORE `require.resolve` is trusted (see its own doc
|
|
614
|
+
* comment for why the plain try/catch this replaced was not safe under this package's test runner).
|
|
615
|
+
*
|
|
616
|
+
* Exported (fix round 1, F4, same convention as {@link safeErrorMessage}/{@link resetAgentdbEmbedderCache}):
|
|
617
|
+
* `embedder-single-owner.test.ts`'s live-dep skip gate needs the SAME resolution order the production
|
|
618
|
+
* code uses to decide, BEFORE running, whether a live embedder failure is a dependency gap (named skip)
|
|
619
|
+
* or a real defect (must fail) — a text-matching heuristic on the error message cannot tell those apart.
|
|
620
|
+
*/
|
|
621
|
+
export function resolveTransformersModule(projectRoot: string): { url: string } | { error: string } {
|
|
622
|
+
const candidates = ['@huggingface/transformers', '@xenova/transformers'];
|
|
623
|
+
for (const name of candidates) {
|
|
624
|
+
const ancestor = findAncestorWithModule(projectRoot, name);
|
|
625
|
+
if (ancestor === undefined) continue;
|
|
626
|
+
try {
|
|
627
|
+
return { url: pathToFileURL(createRequire(join(ancestor, 'package.json')).resolve(name)).href };
|
|
628
|
+
} catch {
|
|
629
|
+
/* an ancestor that named the directory but whose require still can't resolve it (e.g. a
|
|
630
|
+
broken symlink) — try the next candidate */
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
let agentdbDir: string;
|
|
634
|
+
try {
|
|
635
|
+
agentdbDir = dirname(createRequire(join(projectRoot, 'package.json')).resolve('agentdb'));
|
|
636
|
+
} catch {
|
|
637
|
+
return { error: DEPS_MISSING };
|
|
638
|
+
}
|
|
639
|
+
for (const name of candidates) {
|
|
640
|
+
const ancestor = findAncestorWithModule(agentdbDir, name);
|
|
641
|
+
if (ancestor === undefined) continue;
|
|
642
|
+
try {
|
|
643
|
+
return { url: pathToFileURL(createRequire(join(ancestor, 'package.json')).resolve(name)).href };
|
|
644
|
+
} catch {
|
|
645
|
+
/* try the next candidate */
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
return { error: DEPS_MISSING };
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
/**
|
|
652
|
+
* D1 (ADR-001): builds the `transformers` pipeline DIRECTLY — the exact call agentdb's own
|
|
653
|
+
* `EmbeddingService.embed` makes for a symmetric model (`pipeline(text, { pooling:'mean',
|
|
654
|
+
* normalize:true })`, `EmbeddingService.js:205`), never through agentdb's wrapper class. This is
|
|
655
|
+
* the ONLY way to request `dtype:'q8'` at pipeline construction (D2) — `EmbeddingService.initialize`
|
|
656
|
+
* hardcodes `transformers.pipeline('feature-extraction', model)` with no dtype option at all, so a
|
|
657
|
+
* quantized store is unreachable through it at any dtype but fp32.
|
|
658
|
+
*
|
|
659
|
+
* COMPAT FALLBACK (deviation from the ADR's literal "иначе ядро отдаёт {error} как сегодня" —
|
|
660
|
+
* documented in `features/embed-daemon-memory/07_code_changes/change_manifest.md`): when
|
|
661
|
+
* `@huggingface/transformers`/`@xenova/transformers` cannot be resolved directly AND the requested
|
|
662
|
+
* dtype is the default `fp32`, this falls back to agentdb's `EmbeddingService` exactly as this
|
|
663
|
+
* function's pre-T2 body did. Measured (2026-09-16): seven OTHER features' test files
|
|
664
|
+
* (`agentdb-index.test.ts`, `agentdb-snapshot-{consistency,lock,rotation}.test.ts`, `brain.test.ts`,
|
|
665
|
+
* `quarantine-mirror-projection.test.ts`, `store-generation.test.ts`, `vector-tier{,-rvf}.test.ts`)
|
|
666
|
+
* fake ONLY `agentdb/controllers/EmbeddingService.js` for their offline fixtures, never a
|
|
667
|
+
* `@huggingface/transformers`/`@xenova/transformers` stub — a literal "no fallback" implementation
|
|
668
|
+
* reddens all of them (out of scope here: `.claude/rules/cross-runtime-concurrency.md` and this
|
|
669
|
+
* feature's own hard rule both forbid touching another feature's files). `dtype:'q8'` NEVER falls
|
|
670
|
+
* back (NFR-4) — an unresolvable transformers module with `dtype:'q8'` requested is a hard `{error}`
|
|
671
|
+
* naming the model and dtype, exactly as the ADR specifies; only the fp32 path is widened.
|
|
672
|
+
*/
|
|
673
|
+
async function initAgentdbEmbedder(projectRoot: string, agentdbDir: string, model: string, dim: number, dtype: EmbedDtype): Promise<Embedder> {
|
|
674
|
+
const transformers = resolveTransformersModule(projectRoot);
|
|
675
|
+
if (!('error' in transformers)) {
|
|
676
|
+
try {
|
|
677
|
+
const { pipeline } = (await import(transformers.url)) as {
|
|
678
|
+
pipeline: (task: string, model: string, opts?: Record<string, unknown>) => Promise<(t: string, o: Record<string, unknown>) => Promise<{ data: ArrayLike<number> }>>;
|
|
679
|
+
};
|
|
680
|
+
const extractor = await pipeline('feature-extraction', model, dtype === 'q8' ? { dtype: 'q8' } : {});
|
|
681
|
+
embedderCachePipelinesBuilt += 1; // F6: the real primitive ran and returned a usable extractor
|
|
682
|
+
return { embed: async (t: string) => Float32Array.from((await extractor(t, { pooling: 'mean', normalize: true })).data) };
|
|
683
|
+
} catch (err) {
|
|
684
|
+
return { error: `embedder init failed (model ${model}, dtype ${dtype}): ${err instanceof Error ? err.message : String(err)}` };
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
if (dtype === 'q8') {
|
|
688
|
+
// NFR-4: a quantized store must never silently downgrade to fp32 for lack of a transformers
|
|
689
|
+
// install — the caller needs to know exactly why q8 is unreachable here.
|
|
690
|
+
return { error: `embedder init failed (model ${model}, dtype ${dtype}): ${transformers.error}` };
|
|
691
|
+
}
|
|
692
|
+
return initViaAgentdbEmbeddingService(agentdbDir, model, dim);
|
|
435
693
|
}
|
|
436
694
|
|
|
437
|
-
|
|
695
|
+
/** The pre-T2 implementation, preserved verbatim as the fp32-only COMPAT FALLBACK documented on
|
|
696
|
+
* {@link initAgentdbEmbedder} above. */
|
|
697
|
+
async function initViaAgentdbEmbeddingService(agentdbDir: string, model: string, dim: number): Promise<Embedder> {
|
|
438
698
|
try {
|
|
439
699
|
const { EmbeddingService } = (await import(pathToFileURL(join(agentdbDir, 'controllers', 'EmbeddingService.js')).href)) as {
|
|
440
700
|
EmbeddingService: new (o: object) => { initialize: () => Promise<void>; embed: (t: string) => Promise<Float32Array> };
|
|
@@ -453,6 +713,7 @@ async function initAgentdbEmbedder(agentdbDir: string, model: string, dim: numbe
|
|
|
453
713
|
rolePolicy: { kind: 'symmetric' },
|
|
454
714
|
} as never);
|
|
455
715
|
await emb.initialize();
|
|
716
|
+
embedderCachePipelinesBuilt += 1; // F6: the COMPAT FALLBACK's own primitive ran
|
|
456
717
|
return { embed: (t: string) => emb.embed(t) };
|
|
457
718
|
} catch (err) {
|
|
458
719
|
return { error: `embedder init failed: ${err instanceof Error ? err.message : String(err)}` };
|
|
@@ -460,11 +721,45 @@ async function initAgentdbEmbedder(agentdbDir: string, model: string, dim: numbe
|
|
|
460
721
|
}
|
|
461
722
|
|
|
462
723
|
/**
|
|
463
|
-
*
|
|
464
|
-
*
|
|
465
|
-
*
|
|
724
|
+
* D2 (`embed-daemon-memory`): the dtype a QUERY/write is embedded with is the STORE's own dtype
|
|
725
|
+
* (its manifest) when the store already exists, falling back to the CONFIGURED dtype only for a
|
|
726
|
+
* store that does not exist yet (its first-ever write picks up the config). This is the ONE place
|
|
727
|
+
* that decision is made — {@link resolveAgentdbEmbedder} calls it so every caller (search, an
|
|
728
|
+
* ordinary incremental index) agrees; `reindexAgentdbRows` is the sole exception (T2/plan): it
|
|
729
|
+
* stamps the manifest with the NEW configured dtype BEFORE it re-embeds, so by the time this
|
|
730
|
+
* function runs during a reindex the manifest already names the new dtype — config and manifest
|
|
731
|
+
* necessarily agree at that point, which is what makes reindex "the one place dtype changes".
|
|
732
|
+
*/
|
|
733
|
+
export function resolveStoreEmbedDtype(projectRoot: string, dbPath?: string): EmbedDtype | { error: string } {
|
|
734
|
+
const configured = resolveEmbedModel(projectRoot);
|
|
735
|
+
if ('error' in configured) return { error: configured.error };
|
|
736
|
+
const manifest = readEmbedManifest(resolveAgentdbPath(projectRoot, dbPath));
|
|
737
|
+
// Fix round 1 (Codex #4): a manifest dtype that is PRESENT but unrecognized must refuse, not fall
|
|
738
|
+
// through to the configured default — the same discipline guardEmbedSpace applies, needed here too
|
|
739
|
+
// because THIS is what resolveAgentdbEmbedder actually keys its cache and pipeline construction on.
|
|
740
|
+
if (manifest?.readError !== undefined) {
|
|
741
|
+
return { error: `embedding manifest unreadable (${manifest.readError}); run dz vector reindex` };
|
|
742
|
+
}
|
|
743
|
+
if (manifest?.dtypeError !== undefined) {
|
|
744
|
+
return { error: `unknown embedding dtype "${manifest.dtypeError}" in manifest; run dz vector reindex` };
|
|
745
|
+
}
|
|
746
|
+
return manifest?.dtype ?? configured.dtype;
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
/**
|
|
750
|
+
* Resolve the shared embedder from the PROJECT (same dynamic-resolution discipline as
|
|
751
|
+
* {@link indexPatternsToAgentdb}); every dz call site uses the same resolved model/dtype so query
|
|
752
|
+
* and row vectors stay in the same space. Cached per process — see {@link embedderCache} above.
|
|
753
|
+
*
|
|
754
|
+
* Fix round 1 (F1, doc correction — the prior wording was misleading): `dbPath` is passed straight
|
|
755
|
+
* to {@link resolveStoreEmbedDtype}, which calls {@link resolveAgentdbPath}`(projectRoot, dbPath)` —
|
|
756
|
+
* and THAT function already returns the project's DEFAULT store path (`<project>/.dz/agentdb.db`,
|
|
757
|
+
* or `AGENTDB_PATH`) when `dbPath` is omitted, not "no path". So an omitted `dbPath` still reads the
|
|
758
|
+
* default store's OWN manifest when one exists; the CONFIGURED dtype is used only as the fallback
|
|
759
|
+
* for a store that has no manifest yet (i.e. does not exist, or predates this feature) — never as
|
|
760
|
+
* the default behaviour for "no dbPath given".
|
|
466
761
|
*/
|
|
467
|
-
export async function resolveAgentdbEmbedder(projectRoot: string): Promise<Embedder> {
|
|
762
|
+
export async function resolveAgentdbEmbedder(projectRoot: string, dbPath?: string): Promise<Embedder> {
|
|
468
763
|
let agentdbDir: string;
|
|
469
764
|
try {
|
|
470
765
|
const req = createRequire(join(projectRoot, 'package.json'));
|
|
@@ -474,11 +769,13 @@ export async function resolveAgentdbEmbedder(projectRoot: string): Promise<Embed
|
|
|
474
769
|
}
|
|
475
770
|
const model = resolveEmbedModel(projectRoot);
|
|
476
771
|
if ('error' in model) return { error: model.error };
|
|
477
|
-
const
|
|
772
|
+
const dtype = resolveStoreEmbedDtype(projectRoot, dbPath);
|
|
773
|
+
if (typeof dtype === 'object' && 'error' in dtype) return { error: dtype.error };
|
|
774
|
+
const key = `${agentdbDir}|${model.model}|${model.dim}|${dtype}`;
|
|
478
775
|
const hit = embedderCache.get(key);
|
|
479
776
|
if (hit !== undefined) return hit;
|
|
480
777
|
embedderCacheInitializations += 1;
|
|
481
|
-
const promise = initAgentdbEmbedder(agentdbDir, model.model, model.dim);
|
|
778
|
+
const promise = initAgentdbEmbedder(projectRoot, agentdbDir, model.model, model.dim, dtype);
|
|
482
779
|
embedderCache.set(key, promise);
|
|
483
780
|
// FR-3: an init failure must not stick — evict so the next call retries instead of replaying
|
|
484
781
|
// the same {error} forever. `.catch` here only guards a rejection that slips past
|
|
@@ -618,7 +915,10 @@ export async function searchAgentdbPatterns(
|
|
|
618
915
|
reindexHint: opts.reindexHint ?? 'dz vector reindex',
|
|
619
916
|
});
|
|
620
917
|
if (!guard.ok) return { hits: [], error: guard.error };
|
|
621
|
-
|
|
918
|
+
// D2: the query is embedded with the STORE's own dtype (the guard above already proved the
|
|
919
|
+
// manifest and the config agree) — pass the resolved store path so resolveAgentdbEmbedder reads
|
|
920
|
+
// the same manifest guardEmbedSpace just read, never the config's dtype in isolation.
|
|
921
|
+
const emb = await resolveAgentdbEmbedder(projectRoot, resolveAgentdbPath(projectRoot, opts.dbPath));
|
|
622
922
|
if ('error' in emb) return { hits: [], error: emb.error };
|
|
623
923
|
let qvec: Float32Array;
|
|
624
924
|
try {
|
|
@@ -762,7 +1062,11 @@ interface UpsertDb {
|
|
|
762
1062
|
pragma: (s: string) => void;
|
|
763
1063
|
exec: (s: string) => void;
|
|
764
1064
|
prepare: (q: string) => {
|
|
765
|
-
|
|
1065
|
+
// T1 (`store-generation-residuals`, record `3cfcec83`): `changes` is added here (better-sqlite3
|
|
1066
|
+
// always returns it — this only makes an already-true fact visible to the type checker) so
|
|
1067
|
+
// `reindexAgentdbRows`'s own DELETE can read its OBSERVED row count instead of inferring it from
|
|
1068
|
+
// an adjacent signal (the exact mistake named in `06_implementation_plan.md`'s T1 section).
|
|
1069
|
+
run: (...a: unknown[]) => { changes: number; lastInsertRowid: number | bigint };
|
|
766
1070
|
get: (...a: unknown[]) => unknown;
|
|
767
1071
|
};
|
|
768
1072
|
transaction: <T>(fn: () => T) => () => T;
|
|
@@ -1027,6 +1331,28 @@ export function bumpAgentdbUses(
|
|
|
1027
1331
|
}
|
|
1028
1332
|
}
|
|
1029
1333
|
|
|
1334
|
+
/**
|
|
1335
|
+
* Fix-round 1 (CRITICAL, item 1b — the belt): whether {@link reindexAgentdbRows} must run its own
|
|
1336
|
+
* rescue bump, given the DELETE's own observed `changes` count and the nested
|
|
1337
|
+
* {@link indexPatternsToAgentdb} call's result. Exported test-only (same convention as
|
|
1338
|
+
* {@link resetAgentdbEmbedderCache}) so the DECISION can be exercised directly and deterministically,
|
|
1339
|
+
* independent of forcing a real concurrent bump-lock race.
|
|
1340
|
+
*
|
|
1341
|
+
* `!nestedBumped && (deleteChanges > 0 || indexed.indexed > 0 || indexed.error !== undefined)`:
|
|
1342
|
+
* - `deleteChanges > 0` — the DELETE genuinely removed rows; the store changed regardless of the
|
|
1343
|
+
* nested call's outcome.
|
|
1344
|
+
* - `indexed.indexed > 0` — the nested call committed rows itself but its OWN bump failed
|
|
1345
|
+
* (`generationBumped: false`) or was never attempted.
|
|
1346
|
+
* - `indexed.error !== undefined` — the nested call's post-write state is UNKNOWN (item 1a: an error
|
|
1347
|
+
* here may still carry accurate `indexed`/`generationBumped` facts, but a caller must not assume a
|
|
1348
|
+
* future error path will). C-1: when in doubt, bump — an extra bump only over-invalidates a cache
|
|
1349
|
+
* (safe), a missed one serves stale data (not safe).
|
|
1350
|
+
*/
|
|
1351
|
+
export function needsRescueBump(deleteChanges: number, indexed: AgentdbIndexResult): boolean {
|
|
1352
|
+
const nestedBumped = indexed.generationBumped === true;
|
|
1353
|
+
return !nestedBumped && (deleteChanges > 0 || indexed.indexed > 0 || indexed.error !== undefined);
|
|
1354
|
+
}
|
|
1355
|
+
|
|
1030
1356
|
export async function reindexAgentdbRows(
|
|
1031
1357
|
projectRoot: string,
|
|
1032
1358
|
rows: readonly AgentdbRow[],
|
|
@@ -1238,6 +1564,11 @@ export async function reindexAgentdbRows(
|
|
|
1238
1564
|
};
|
|
1239
1565
|
|
|
1240
1566
|
let stale: string[] = [];
|
|
1567
|
+
// T1 (`store-generation-residuals`, record `3cfcec83`): the OBSERVED fact — `changes` from the
|
|
1568
|
+
// DELETE's own prepared-statement result, never inferred from `rows.length` or any other adjacent
|
|
1569
|
+
// signal (06_implementation_plan.md's T1 section names exactly that inference as the mistake to
|
|
1570
|
+
// avoid). Declared outside the `db` block so it survives to the bump decision below `db.close()`.
|
|
1571
|
+
let deleteChanges = 0;
|
|
1241
1572
|
try {
|
|
1242
1573
|
mkdirSync(dirname(dbFile), { recursive: true });
|
|
1243
1574
|
const db = new Database(dbFile);
|
|
@@ -1255,11 +1586,11 @@ export async function reindexAgentdbRows(
|
|
|
1255
1586
|
stale = foreignTaskTypesWithEmbeddings(db, taskTypes);
|
|
1256
1587
|
const delEmb = db.prepare(`DELETE FROM pattern_embeddings WHERE pattern_id IN (SELECT id FROM reasoning_patterns WHERE task_type IN (${placeholders}))`);
|
|
1257
1588
|
const delPat = db.prepare(`DELETE FROM reasoning_patterns WHERE task_type IN (${placeholders})`);
|
|
1258
|
-
const tx = db.transaction(() => {
|
|
1589
|
+
const tx = db.transaction((): number => {
|
|
1259
1590
|
delEmb.run(...taskTypes);
|
|
1260
|
-
delPat.run(...taskTypes);
|
|
1591
|
+
return delPat.run(...taskTypes).changes;
|
|
1261
1592
|
});
|
|
1262
|
-
tx();
|
|
1593
|
+
deleteChanges = tx();
|
|
1263
1594
|
} finally {
|
|
1264
1595
|
db.close();
|
|
1265
1596
|
}
|
|
@@ -1274,6 +1605,15 @@ export async function reindexAgentdbRows(
|
|
|
1274
1605
|
|
|
1275
1606
|
const indexed = await indexPatternsToAgentdb(projectRoot, rows, { dbPath: dbFile });
|
|
1276
1607
|
if (indexed.error !== undefined) {
|
|
1608
|
+
// Fix-round 1 (CRITICAL, item 1b — the belt): before this fix, NO bump was attempted anywhere
|
|
1609
|
+
// on this branch, regardless of `deleteChanges` — the DELETE above may have genuinely removed
|
|
1610
|
+
// rows from the store (a real change on disk) and the counter would never move to reflect it,
|
|
1611
|
+
// even though `rollback()` below may itself fail and leave that changed state in place. An
|
|
1612
|
+
// error from the nested call means the post-write state is UNKNOWN — C-1 resolves unknown in
|
|
1613
|
+
// favour of bumping (a spurious extra bump only over-invalidates a cache; a missed one serves
|
|
1614
|
+
// stale data). `needsRescueBump` is the SAME decision used on the success path below — one
|
|
1615
|
+
// rule, not two that could drift apart.
|
|
1616
|
+
if (needsRescueBump(deleteChanges, indexed)) bumpStoreGeneration(projectRoot, opts.dbPath);
|
|
1277
1617
|
const rb = await rollback();
|
|
1278
1618
|
if (rb.restored === 'failed') rollbackFailed = true; // AM-3: the `finally` below must not clear the marker
|
|
1279
1619
|
return {
|
|
@@ -1301,13 +1641,36 @@ export async function reindexAgentdbRows(
|
|
|
1301
1641
|
if (!(err instanceof NamedLockTimeoutError)) throw err;
|
|
1302
1642
|
snapshots = { kept: [], removed: [], removedBytes: 0, keep: keepSnapshots, errors: [`lock busy: ${err.message}`] };
|
|
1303
1643
|
}
|
|
1304
|
-
//
|
|
1305
|
-
//
|
|
1306
|
-
//
|
|
1307
|
-
//
|
|
1308
|
-
//
|
|
1309
|
-
//
|
|
1310
|
-
|
|
1644
|
+
// T1 (`store-generation-residuals`, record `3cfcec83` — supersedes the AM-1 comment this
|
|
1645
|
+
// replaces, which documented the double-bump as "harmless" rather than fixing it). AM-1's
|
|
1646
|
+
// underlying concern stands unchanged: `reindexAgentdbRows` is ITSELF a mutator (the DELETE
|
|
1647
|
+
// above rebuilds the owned task types) and must not rely solely on `indexPatternsToAgentdb`'s
|
|
1648
|
+
// own internal bump, because that nested call is a no-op — bumps nothing, sets no
|
|
1649
|
+
// `generationBumped` — when `rows` is empty, yet the DELETE just above may have changed the
|
|
1650
|
+
// store regardless of whether there was anything to re-insert.
|
|
1651
|
+
//
|
|
1652
|
+
// The rule (now the shared {@link needsRescueBump} helper — fix-round 1, item 1b — used
|
|
1653
|
+
// identically on the error branch above) uses OBSERVED facts, never inferred from an adjacent
|
|
1654
|
+
// signal (the mistake named in the plan's T1 section, fresh from the worker-ceiling fix that
|
|
1655
|
+
// predates this one): `deleteChanges` is the DELETE's own `changes` count, read directly off the
|
|
1656
|
+
// prepared-statement result; `indexed.generationBumped` is a field `indexPatternsToAgentdb` sets
|
|
1657
|
+
// ONLY where its own bump actually ran (never guessed from `indexed.indexed > 0`, which is
|
|
1658
|
+
// itself a real fact but a DIFFERENT one — see below).
|
|
1659
|
+
//
|
|
1660
|
+
// The condition is intentionally `!nestedBumped && (deleteChanges > 0 || indexed.indexed > 0 ||
|
|
1661
|
+
// indexed.error !== undefined)`, NOT the narrower `deleteChanges > 0 && !nestedBumped` the
|
|
1662
|
+
// plan's prose formula reads as: a bare `deleteChanges > 0` gate would MISS the case where the
|
|
1663
|
+
// DELETE removed nothing (a first-ever reindex of these task types) but the nested insert then
|
|
1664
|
+
// ran and its OWN bump failed (`indexed.generationBumped === false`, e.g. a transient lock
|
|
1665
|
+
// timeout) — under the narrower gate the store would have changed on disk with no rescue bump at
|
|
1666
|
+
// all, a genuine under-bump. C-1 (`01_requirements.md`) makes correctness here non-negotiable:
|
|
1667
|
+
// "при сомнении поднимать счётчик ЛИШНИЙ раз безопаснее, чем не поднять" — so the OR-of-facts
|
|
1668
|
+
// form below is what actually ships; it satisfies every case FR-1's AC-1 enumerates AND closes
|
|
1669
|
+
// the gaps the plan's literal formula and the pre-fix-round-1 condition left open, verified by
|
|
1670
|
+
// exhaustive case analysis in `features/store-generation-residuals/07_code_changes/change_manifest.md`.
|
|
1671
|
+
if (needsRescueBump(deleteChanges, indexed)) {
|
|
1672
|
+
bumpStoreGeneration(projectRoot, opts.dbPath);
|
|
1673
|
+
}
|
|
1311
1674
|
return {
|
|
1312
1675
|
reembedded: indexed.indexed,
|
|
1313
1676
|
model: model.model,
|