@dzhechkov/harness-core 0.8.36 → 0.8.38
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 +216 -76
- package/README.md +349 -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 +380 -0
- package/dist/cross-family-control.d.ts.map +1 -0
- package/dist/cross-family-control.js +848 -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 +133 -3
- 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 +21 -9
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +15 -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 +8 -0
- 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/review-cost.d.ts +51 -0
- package/dist/review-cost.d.ts.map +1 -0
- package/dist/review-cost.js +110 -0
- package/dist/review-cost.js.map +1 -0
- package/dist/round.d.ts +207 -1
- package/dist/round.d.ts.map +1 -1
- package/dist/round.js +321 -4
- package/dist/round.js.map +1 -1
- package/dist/run-records.d.ts +97 -0
- package/dist/run-records.d.ts.map +1 -1
- package/dist/run-records.js +336 -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 +425 -75
- 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 +1038 -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 +150 -3
- package/src/feature-adr-stage-canon.ts +141 -0
- package/src/index.ts +65 -6
- package/src/loop-blobs.generated.ts +4 -4
- package/src/mutation-gate.ts +316 -0
- package/src/qe-bridge.ts +12 -2
- package/src/qe-findings.ts +463 -0
- package/src/recap.ts +10 -3
- package/src/review-cost.ts +139 -0
- package/src/round.ts +481 -6
- package/src/run-records.ts +388 -2
- package/src/score.ts +115 -6
package/dist/agentdb-index.js
CHANGED
|
@@ -100,11 +100,37 @@ export function ensureAgentdbSchema(projectRoot, dbPath) {
|
|
|
100
100
|
function generationFilePath(dbFile) {
|
|
101
101
|
return `${dbFile}.generation`;
|
|
102
102
|
}
|
|
103
|
+
/** `<dbFile>.generation.recovered` (T2, `store-generation-residuals`, record `1d465496`) — the
|
|
104
|
+
* corrupt-sidecar recovery floor's own memory, a SEPARATE file next to the counter (never a
|
|
105
|
+
* module-level variable: two OS processes do not share one, and two processes are exactly what
|
|
106
|
+
* race here). Substring `.generation` deliberately preserved so any future sidecar-enumeration
|
|
107
|
+
* point that greps for the counter family (checked, none exists today — see Р-2 in
|
|
108
|
+
* `features/store-generation-residuals/06_implementation_plan.md`) still finds this file. */
|
|
109
|
+
function recoveredMemoryFilePath(genFile) {
|
|
110
|
+
return `${genFile}.recovered`;
|
|
111
|
+
}
|
|
103
112
|
/** AM-5 (fix-round): the ONLY shape {@link readStoreGeneration} trusts — one or more ASCII digits,
|
|
104
113
|
* nothing else. `Number.parseInt` alone accepts a leading-numeric-with-trailing-junk string like
|
|
105
114
|
* `"12junk"` as `12`; that reads a corrupt sidecar as a plausible generation instead of degrading to
|
|
106
115
|
* the documented `0` compatibility floor. */
|
|
107
116
|
const STRICT_GENERATION = /^\d+$/;
|
|
117
|
+
/** Shared degrade-to-0 read for any sidecar holding a single non-negative decimal integer: garbage,
|
|
118
|
+
* a missing file, or anything not matching {@link STRICT_GENERATION} reads as `0`, never throws.
|
|
119
|
+
* Both {@link readStoreGeneration} (the counter itself) and T2's recovery memory
|
|
120
|
+
* ({@link recoveredMemoryFilePath}) use this ONE primitive — the recovery memory must degrade
|
|
121
|
+
* exactly like the counter it accompanies (T2 NFR-1), not by a second, possibly-diverging rule. */
|
|
122
|
+
function readNonNegativeIntFile(path) {
|
|
123
|
+
try {
|
|
124
|
+
const raw = readFileSync(path, 'utf8').trim();
|
|
125
|
+
if (!STRICT_GENERATION.test(raw))
|
|
126
|
+
return 0;
|
|
127
|
+
const n = Number.parseInt(raw, 10);
|
|
128
|
+
return Number.isFinite(n) && n >= 0 ? n : 0;
|
|
129
|
+
}
|
|
130
|
+
catch {
|
|
131
|
+
return 0;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
108
134
|
/**
|
|
109
135
|
* FR-2/FR-3 (`store-generation-counter`): the store's write-generation counter, read back. A
|
|
110
136
|
* missing file (a store that predates this feature, or one that has never been written through
|
|
@@ -115,16 +141,7 @@ const STRICT_GENERATION = /^\d+$/;
|
|
|
115
141
|
* to decide validity, only to convert an already-validated string.
|
|
116
142
|
*/
|
|
117
143
|
export function readStoreGeneration(projectRoot, dbPath) {
|
|
118
|
-
|
|
119
|
-
const raw = readFileSync(generationFilePath(resolveAgentdbPath(projectRoot, dbPath)), 'utf8').trim();
|
|
120
|
-
if (!STRICT_GENERATION.test(raw))
|
|
121
|
-
return 0;
|
|
122
|
-
const n = Number.parseInt(raw, 10);
|
|
123
|
-
return Number.isFinite(n) && n >= 0 ? n : 0;
|
|
124
|
-
}
|
|
125
|
-
catch {
|
|
126
|
-
return 0;
|
|
127
|
-
}
|
|
144
|
+
return readNonNegativeIntFile(generationFilePath(resolveAgentdbPath(projectRoot, dbPath)));
|
|
128
145
|
}
|
|
129
146
|
/**
|
|
130
147
|
* FR-1 (`store-generation-counter`): monotonically advance the store's write-generation counter by
|
|
@@ -147,6 +164,37 @@ export function readStoreGeneration(projectRoot, dbPath) {
|
|
|
147
164
|
* regardless, telemetry is never a gate. AM-4: {@link resolveAgentdbPath} itself now runs INSIDE this
|
|
148
165
|
* function's outer `try` — an unresolvable path can no longer throw OUT of `bumpStoreGeneration`
|
|
149
166
|
* either; "never throws" now covers the whole function, not just the file-write tail.
|
|
167
|
+
*
|
|
168
|
+
* T2 (`store-generation-residuals`, record `1d465496`): the corrupt-sidecar recovery floor below
|
|
169
|
+
* used to be a bare `Date.now()` — NOT strictly monotonic on its own (two recoveries inside the same
|
|
170
|
+
* millisecond publish the same value; a backward clock step can publish a SMALLER one than an
|
|
171
|
+
* earlier recovery). It is now `max(now(), lastPublished + 1)`, where `lastPublished` is read from
|
|
172
|
+
* {@link recoveredMemoryFilePath} — a file, not a module-level variable, because the two writers who
|
|
173
|
+
* actually race here are two OS PROCESSES, which do not share process memory. `now` is an injectable
|
|
174
|
+
* time source (default `Date.now`) — AC-3's only reason to exist: a real clock cannot be rolled back
|
|
175
|
+
* from a test.
|
|
176
|
+
*
|
|
177
|
+
* Fix-round 1 (independent Codex review, gpt-5.6-sol — items 2/3/4/5/7), on top of T2:
|
|
178
|
+
* - item 2: {@link recoveredMemoryFilePath} now holds the LAST **published** generation, not the
|
|
179
|
+
* last **recovered** one — it is written on EVERY successful bump, not only inside the
|
|
180
|
+
* corrupt-sidecar branch. Before this fix, a run of ordinary bumps after a recovery left the
|
|
181
|
+
* memory stale, so a LATER recovery under a rolled-back clock could float the counter below a
|
|
182
|
+
* generation an ordinary bump already published (HIGH #2 finding).
|
|
183
|
+
* - item 3: the memory file is published BEFORE the counter file (was: counter first, memory
|
|
184
|
+
* "best-effort" after). A crash between the two writes now leaves the memory AHEAD of the counter
|
|
185
|
+
* — the SAFE direction: the next recovery floors too high rather than too low, so monotonicity
|
|
186
|
+
* survives a half-done bump (HIGH #3 finding).
|
|
187
|
+
* - item 4: a memory-write failure (e.g. a directory sitting at its path) does NOT block the counter
|
|
188
|
+
* publish below it — invalidation is the load-bearing behaviour — but the degradation is reported
|
|
189
|
+
* on stderr via {@link reportBumpMemoryDegraded}, never swallowed. LIMITATION: while the memory
|
|
190
|
+
* sidecar stays unwritable, a future corrupt-sidecar recovery on this store floors only at the wall
|
|
191
|
+
* clock, same as pre-fix-round behaviour — not strictly above every ordinary bump published in the
|
|
192
|
+
* meantime (HIGH #4 finding).
|
|
193
|
+
* - item 5: every error-to-string conversion in this function goes through {@link safeErrorMessage},
|
|
194
|
+
* which cannot itself throw even if `err` carries a poisoned `toString` — "never throws" is
|
|
195
|
+
* absolute (MEDIUM #5 finding).
|
|
196
|
+
* - item 7: the memory sidecar's own tmp file is cleaned up on a failed write, matching the counter's
|
|
197
|
+
* existing tmp-cleanup discipline (LOW #7 finding).
|
|
150
198
|
*/
|
|
151
199
|
/** Codex round-2 (NEW HIGH): most mutators discard the bump result, so a failed bump must be
|
|
152
200
|
* VISIBLE on its own — one stderr line, written by the helper itself. Telemetry never throws. */
|
|
@@ -157,29 +205,100 @@ function reportBumpFailure(error) {
|
|
|
157
205
|
catch { /* telemetry never throws */ }
|
|
158
206
|
return { ok: false, error };
|
|
159
207
|
}
|
|
160
|
-
|
|
208
|
+
/** Fix-round 1, item 4: a bump whose COUNTER publish succeeded but whose recovery-memory sidecar
|
|
209
|
+
* ({@link recoveredMemoryFilePath}) could not be written must not swallow that fact — same
|
|
210
|
+
* stderr-report shape as {@link reportBumpFailure}, but this one never changes the return value:
|
|
211
|
+
* the counter genuinely advanced, so `{ok:true, generation}` stands. LIMITATION (documented here per
|
|
212
|
+
* the brief, item 4): until the memory sidecar is writable again, a FUTURE corrupt-sidecar recovery
|
|
213
|
+
* on this store is not guaranteed to floor above every generation an ordinary bump already published
|
|
214
|
+
* in the meantime (item 2's fix depends on the memory file being current) — it still floors above the
|
|
215
|
+
* wall clock, same as before this fix-round. */
|
|
216
|
+
function reportBumpMemoryDegraded(reason) {
|
|
217
|
+
try {
|
|
218
|
+
process.stderr.write(`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`);
|
|
219
|
+
}
|
|
220
|
+
catch { /* telemetry never throws */ }
|
|
221
|
+
}
|
|
222
|
+
/** Fix-round 1, item 5: `String(err)` itself can throw if `err` carries a poisoned `toString` (or
|
|
223
|
+
* `Error.prototype.message` getter). `bumpStoreGeneration`'s "never throws" contract (FR-4) is
|
|
224
|
+
* ABSOLUTE, so every place in this function that turns a caught error into a string goes through
|
|
225
|
+
* this ONE protected helper — never a bare `err instanceof Error ? err.message : String(err)`.
|
|
226
|
+
* Exported test-only (same convention as {@link needsRescueBump}/{@link resetAgentdbEmbedderCache}).
|
|
227
|
+
*/
|
|
228
|
+
export function safeErrorMessage(err) {
|
|
229
|
+
try {
|
|
230
|
+
return err instanceof Error ? err.message : String(err);
|
|
231
|
+
}
|
|
232
|
+
catch {
|
|
233
|
+
return '(unstringifiable error)';
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
export function bumpStoreGeneration(projectRoot, dbPath,
|
|
237
|
+
/** T2: the ONLY signature extension the plan permits — injectable wall clock, default `Date.now`,
|
|
238
|
+
* so AC-3 (a rolled-back system clock) can be reproduced without touching the real clock. */
|
|
239
|
+
now = Date.now) {
|
|
161
240
|
try {
|
|
162
241
|
const dbFile = resolveAgentdbPath(projectRoot, dbPath);
|
|
163
242
|
const genFile = generationFilePath(dbFile);
|
|
243
|
+
// Fix-round 1, item 2: computed UNCONDITIONALLY (was: only inside the corrupt-sidecar branch) —
|
|
244
|
+
// this sidecar now tracks "last PUBLISHED generation", updated on every successful bump, not just
|
|
245
|
+
// a recovery.
|
|
246
|
+
const recoveredMemoryFile = recoveredMemoryFilePath(genFile);
|
|
164
247
|
return withNamedLockSync(dirname(dbFile), 'store-generation', () => {
|
|
165
248
|
// AM-2: re-read the CURRENT value from disk while holding the lock — a value observed before
|
|
166
249
|
// acquisition may already be stale, another holder may have advanced it in the meantime.
|
|
167
250
|
let current = readStoreGeneration(projectRoot, dbPath);
|
|
168
251
|
const tmp = `${genFile}.tmp-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
|
252
|
+
const recTmp = `${recoveredMemoryFile}.tmp-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
|
169
253
|
try {
|
|
170
|
-
// Codex round-2 (AM-2 residual): a sidecar that EXISTS but is
|
|
171
|
-
// reset the counter to 1 — a value an engine-cache entry may
|
|
172
|
-
// corrupt value at
|
|
173
|
-
// count reached by bumping)
|
|
254
|
+
// Codex round-2 (AM-2 residual) / T2 (record `1d465496`): a sidecar that EXISTS but is
|
|
255
|
+
// corrupt reads as 0 and would reset the counter to 1 — a value an engine-cache entry may
|
|
256
|
+
// already be keyed on. Floor a corrupt value at max(wall-clock, lastPublished+1) instead:
|
|
257
|
+
// still monotonic (ms-since-epoch exceeds any count reached by bumping) AND strictly
|
|
258
|
+
// increasing across successive corrupt recoveries even inside the same millisecond or
|
|
259
|
+
// across a backward clock step — the bare `Date.now()` this replaces was neither. Absent
|
|
260
|
+
// file (not corrupt, simply missing) still takes the ordinary `current === 0` path below,
|
|
261
|
+
// unaffected — only a genuinely corrupt EXISTING sidecar enters this branch.
|
|
174
262
|
if (current === 0 && existsSync(genFile)) {
|
|
175
263
|
const raw = readFileSync(genFile, 'utf8').trim();
|
|
176
|
-
if (raw !== '0' && !STRICT_GENERATION.test(raw))
|
|
177
|
-
|
|
264
|
+
if (raw !== '0' && !STRICT_GENERATION.test(raw)) {
|
|
265
|
+
// NFR-1: a memory file that cannot be read (missing, or itself corrupt) degrades to 0,
|
|
266
|
+
// exactly like the counter's own read — never a throw, never a special-cased error.
|
|
267
|
+
// Fix-round 1, item 2: `lastPublished` now reflects every prior successful bump
|
|
268
|
+
// (ordinary or recovery), not only the previous recovery — see the doc comment above.
|
|
269
|
+
const lastPublished = readNonNegativeIntFile(recoveredMemoryFile);
|
|
270
|
+
current = Math.max(now(), lastPublished + 1);
|
|
271
|
+
}
|
|
178
272
|
}
|
|
179
273
|
const next = current + 1;
|
|
180
274
|
mkdirSync(dirname(genFile), { recursive: true });
|
|
275
|
+
// Fix-round 1, item 3: the memory sidecar is published BEFORE the counter sidecar — a crash
|
|
276
|
+
// between the two then leaves the memory AHEAD of the counter (safe: the next recovery
|
|
277
|
+
// floors too high, never too low). Fix-round 1, item 2: this now runs on EVERY successful
|
|
278
|
+
// bump, not only inside the corrupt-sidecar branch above.
|
|
279
|
+
let memoryError;
|
|
280
|
+
try {
|
|
281
|
+
writeFileSync(recTmp, String(next), { encoding: 'utf8', flag: 'wx' });
|
|
282
|
+
renameSync(recTmp, recoveredMemoryFile);
|
|
283
|
+
}
|
|
284
|
+
catch (memErr) {
|
|
285
|
+
// Fix-round 1, item 4: the counter publish below still goes ahead — invalidation matters
|
|
286
|
+
// more than the memory sidecar — but the degradation is reported, not swallowed (see the
|
|
287
|
+
// stderr write after the counter publish). Fix-round 1, item 7: clean up a half-written
|
|
288
|
+
// memory tmp file the same way the counter's own tmp is cleaned up on failure below.
|
|
289
|
+
try {
|
|
290
|
+
if (existsSync(recTmp))
|
|
291
|
+
unlinkSync(recTmp);
|
|
292
|
+
}
|
|
293
|
+
catch { /* best-effort only */ }
|
|
294
|
+
memoryError = safeErrorMessage(memErr);
|
|
295
|
+
}
|
|
181
296
|
writeFileSync(tmp, String(next), { encoding: 'utf8', flag: 'wx' });
|
|
182
297
|
renameSync(tmp, genFile);
|
|
298
|
+
// Fix-round 1, item 4: reported AFTER the counter publish succeeds, so the stderr line
|
|
299
|
+
// never implies the bump itself failed — it names exactly the narrower, degraded guarantee.
|
|
300
|
+
if (memoryError !== undefined)
|
|
301
|
+
reportBumpMemoryDegraded(memoryError);
|
|
183
302
|
return { ok: true, generation: next };
|
|
184
303
|
}
|
|
185
304
|
catch (err) {
|
|
@@ -190,7 +309,12 @@ export function bumpStoreGeneration(projectRoot, dbPath) {
|
|
|
190
309
|
unlinkSync(tmp);
|
|
191
310
|
}
|
|
192
311
|
catch { /* best-effort only */ }
|
|
193
|
-
|
|
312
|
+
try {
|
|
313
|
+
if (existsSync(recTmp))
|
|
314
|
+
unlinkSync(recTmp);
|
|
315
|
+
}
|
|
316
|
+
catch { /* best-effort only */ } // item 7
|
|
317
|
+
return reportBumpFailure(`store generation bump failed: ${safeErrorMessage(err)}`);
|
|
194
318
|
}
|
|
195
319
|
});
|
|
196
320
|
}
|
|
@@ -198,7 +322,7 @@ export function bumpStoreGeneration(projectRoot, dbPath) {
|
|
|
198
322
|
// AM-2/FR-4: a lock that could not be acquired by its deadline (`NamedLockTimeoutError`) — and
|
|
199
323
|
// any other failure reaching this point (an unresolvable path, AM-4) — degrades to the same
|
|
200
324
|
// honest `{ok:false, error}` shape; it never throws into the store write it accompanies.
|
|
201
|
-
return reportBumpFailure(`store generation bump failed: ${
|
|
325
|
+
return reportBumpFailure(`store generation bump failed: ${safeErrorMessage(err)}`);
|
|
202
326
|
}
|
|
203
327
|
}
|
|
204
328
|
/**
|
|
@@ -209,36 +333,27 @@ export async function indexPatternsToAgentdb(projectRoot, rows, opts = {}) {
|
|
|
209
333
|
if (rows.length === 0)
|
|
210
334
|
return { indexed: 0 };
|
|
211
335
|
let sqliteUrl;
|
|
212
|
-
let agentdbDir;
|
|
213
336
|
try {
|
|
214
337
|
const req = createRequire(join(projectRoot, 'package.json'));
|
|
215
338
|
sqliteUrl = pathToFileURL(req.resolve('better-sqlite3')).href;
|
|
216
|
-
agentdbDir = dirname(req.resolve('agentdb'));
|
|
217
339
|
}
|
|
218
340
|
catch {
|
|
219
341
|
return { indexed: 0, error: 'agentdb/better-sqlite3 not installed in project (run: dz setup --memory agentdb)' };
|
|
220
342
|
}
|
|
221
343
|
try {
|
|
222
344
|
const { default: Database } = (await import(sqliteUrl));
|
|
223
|
-
const { EmbeddingService } = (await import(pathToFileURL(join(agentdbDir, 'controllers', 'EmbeddingService.js')).href));
|
|
224
345
|
const model = resolveEmbedModel(projectRoot);
|
|
225
346
|
if ('error' in model)
|
|
226
347
|
return { indexed: 0, error: model.error };
|
|
227
|
-
const emb = new EmbeddingService({
|
|
228
|
-
model: model.model,
|
|
229
|
-
dimension: model.dim,
|
|
230
|
-
provider: 'transformers',
|
|
231
|
-
// agentdb >= 3.0.0-alpha.20 refuses UNREGISTERED models without an explicit role policy
|
|
232
|
-
// (its built-in registry knows all-MiniLM-L6-v2 but not our multilingual variant — grounded
|
|
233
|
-
// in dist/src/controllers/EmbeddingService.js:53). paraphrase-multilingual-MiniLM is a
|
|
234
|
-
// SYMMETRIC sentence-transformer (no query/passage instruction prefixes), so the policy is
|
|
235
|
-
// {kind:'symmetric'} — the same one the registry assigns its own symmetric models. On
|
|
236
|
-
// alpha.18 the extra field is ignored; without it alpha.20 threw and the vector tier fell
|
|
237
|
-
// to lexical SILENTLY (mirror writes answered {indexed:0, error} — measured 2026-08-24).
|
|
238
|
-
rolePolicy: { kind: 'symmetric' },
|
|
239
|
-
});
|
|
240
|
-
await emb.initialize();
|
|
241
348
|
const dbFile = resolveAgentdbPath(projectRoot, opts.dbPath);
|
|
349
|
+
// D1 (embed-daemon-memory, ADR-001): a single embedder per process — the SAME cached pipeline
|
|
350
|
+
// `resolveAgentdbEmbedder` hands to search/the daemon, never a private `new EmbeddingService(...)`
|
|
351
|
+
// built here. `dbFile` (not just `projectRoot`) so an EXISTING store's dtype (manifest, D2) wins
|
|
352
|
+
// over the config for an ordinary incremental index — only `reindexAgentdbRows` re-stamps the
|
|
353
|
+
// manifest first and thereby moves the dtype (see resolveStoreEmbedDtype's own doc comment).
|
|
354
|
+
const emb = await resolveAgentdbEmbedder(projectRoot, dbFile);
|
|
355
|
+
if ('error' in emb)
|
|
356
|
+
return { indexed: 0, error: emb.error };
|
|
242
357
|
const db = new Database(dbFile);
|
|
243
358
|
try {
|
|
244
359
|
db.pragma('journal_mode = WAL');
|
|
@@ -282,7 +397,22 @@ export async function indexPatternsToAgentdb(projectRoot, rows, opts = {}) {
|
|
|
282
397
|
// whichever of the two calls below throws, the generation is already correct for the rows that
|
|
283
398
|
// are already on disk.
|
|
284
399
|
const bump = bumpStoreGeneration(projectRoot, opts.dbPath);
|
|
285
|
-
|
|
400
|
+
// Fix-round 1 (CRITICAL, item 1a): `writeEmbedManifest` can throw (a directory sitting at the
|
|
401
|
+
// manifest sidecar path — see the AM-3 test). Before this fix, that throw escaped to the outer
|
|
402
|
+
// `catch` below, which returned the GENERIC `{indexed: 0, error: ...}` — discarding the two
|
|
403
|
+
// facts already true by this point: `indexed` rows are on disk, and `bump` already ran. A
|
|
404
|
+
// caller reading `indexed === 0` would conclude "nothing happened" and skip its own rescue-bump
|
|
405
|
+
// logic even though the store had genuinely changed — the under-bump C-1 forbids. Catching the
|
|
406
|
+
// throw HERE, with the real `indexed`/bump outcome already captured in scope, preserves both.
|
|
407
|
+
try {
|
|
408
|
+
writeEmbedManifest(dbFile, currentEmbedManifest(model, guard.manifest.version, 'agentdb'));
|
|
409
|
+
}
|
|
410
|
+
catch (manifestErr) {
|
|
411
|
+
const manifestError = `index failed: ${safeErrorMessage(manifestErr)}`;
|
|
412
|
+
return bump.ok
|
|
413
|
+
? { indexed, generationBumped: true, error: manifestError }
|
|
414
|
+
: { indexed, generationBumped: false, generationReason: bump.error, error: manifestError };
|
|
415
|
+
}
|
|
286
416
|
return bump.ok
|
|
287
417
|
? { indexed, generationBumped: true }
|
|
288
418
|
: { indexed, generationBumped: false, generationReason: bump.error };
|
|
@@ -292,7 +422,7 @@ export async function indexPatternsToAgentdb(projectRoot, rows, opts = {}) {
|
|
|
292
422
|
}
|
|
293
423
|
}
|
|
294
424
|
catch (err) {
|
|
295
|
-
return { indexed: 0, error: `index failed: ${
|
|
425
|
+
return { indexed: 0, error: `index failed: ${safeErrorMessage(err)}` };
|
|
296
426
|
}
|
|
297
427
|
}
|
|
298
428
|
/* ------------------------------------------------------------------ */
|
|
@@ -332,18 +462,147 @@ const DEPS_MISSING = 'agentdb/better-sqlite3 not installed in project (run: dz s
|
|
|
332
462
|
*/
|
|
333
463
|
const embedderCache = new Map();
|
|
334
464
|
let embedderCacheInitializations = 0;
|
|
465
|
+
/**
|
|
466
|
+
* Fix round 1 (F6, Codex #6): `embedderCacheInitializations` only counts calls to
|
|
467
|
+
* {@link resolveAgentdbEmbedder} that missed the cache — it proves cache REUSE, not that a real
|
|
468
|
+
* pipeline was actually constructed. This counter increments at the exact two call sites where a
|
|
469
|
+
* pipeline construction primitive actually runs: the direct `pipeline('feature-extraction', …)` call
|
|
470
|
+
* in {@link initAgentdbEmbedder} and `EmbeddingService.initialize()` in
|
|
471
|
+
* {@link initViaAgentdbEmbeddingService} (the COMPAT FALLBACK path) — never merely on entry to
|
|
472
|
+
* `initAgentdbEmbedder`, which can also return an `{error}` (dtype:'q8' with no resolvable
|
|
473
|
+
* transformers, NFR-4) without ever attempting either.
|
|
474
|
+
*/
|
|
475
|
+
let embedderCachePipelinesBuilt = 0;
|
|
335
476
|
/** Test-only (and future warm-start) reset — callers (`vector-tier.ts`, `backlog.ts`) are unaffected. */
|
|
336
477
|
export function resetAgentdbEmbedderCache() {
|
|
337
478
|
embedderCache.clear();
|
|
338
479
|
embedderCacheInitializations = 0;
|
|
480
|
+
embedderCachePipelinesBuilt = 0;
|
|
339
481
|
}
|
|
340
482
|
/** `entries` = cached keys right now — a SUCCESSFUL pipeline or an IN-FLIGHT initialization (the promise is
|
|
341
483
|
* cached before it settles, FR-4; a failed one is evicted, FR-3); `initializations` = pipelines actually
|
|
342
|
-
* started since the last reset. (Codex round-1, 2026-09-14: the earlier wording said "successful" only.)
|
|
484
|
+
* started since the last reset. (Codex round-1, 2026-09-14: the earlier wording said "successful" only.)
|
|
485
|
+
* `pipelinesBuilt` (fix round 1, F6) = the count of REAL pipeline-construction primitives that actually
|
|
486
|
+
* ran (`pipeline()` or `EmbeddingService.initialize()`), never merely the number of times the resolver
|
|
487
|
+
* was entered — see {@link embedderCachePipelinesBuilt}'s own doc comment for why the two can diverge. */
|
|
343
488
|
export function getAgentdbEmbedderCacheStats() {
|
|
344
|
-
return { entries: embedderCache.size, initializations: embedderCacheInitializations };
|
|
489
|
+
return { entries: embedderCache.size, initializations: embedderCacheInitializations, pipelinesBuilt: embedderCachePipelinesBuilt };
|
|
490
|
+
}
|
|
491
|
+
/**
|
|
492
|
+
* Walk UP from `startDir` (inclusive) looking for `<dir>/node_modules/<name>` as a real, existing
|
|
493
|
+
* path — a PLAIN FILESYSTEM CHECK, deliberately never `require.resolve()` alone. MEASURED
|
|
494
|
+
* 2026-09-16: under this package's own vitest harness, `createRequire(join(projectRoot,
|
|
495
|
+
* 'package.json')).resolve('@huggingface/transformers')` succeeds even for a deliberately isolated
|
|
496
|
+
* `/tmp` fixture that installs no such dependency at all (`agentdb-embedder-cache.test.ts`'s AC-3) —
|
|
497
|
+
* the test runner's module loader resolves more liberally than plain Node does, reaching the
|
|
498
|
+
* monorepo's real install regardless of `projectRoot`. `require.resolve` is used only AFTER this
|
|
499
|
+
* filesystem walk has already named a legitimate ancestor, so it can no longer be fooled that way.
|
|
500
|
+
* Returns the ancestor directory whose OWN `node_modules/<name>` exists, or `undefined` if none does
|
|
501
|
+
* all the way to the filesystem root (a handful of synchronous `existsSync` calls either way).
|
|
502
|
+
*/
|
|
503
|
+
function findAncestorWithModule(startDir, name) {
|
|
504
|
+
let dir = resolve(startDir);
|
|
505
|
+
for (;;) {
|
|
506
|
+
if (existsSync(join(dir, 'node_modules', name)))
|
|
507
|
+
return dir;
|
|
508
|
+
const parent = dirname(dir);
|
|
509
|
+
if (parent === dir)
|
|
510
|
+
return undefined;
|
|
511
|
+
dir = parent;
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
/**
|
|
515
|
+
* C-3 (`embed-daemon-memory`): the SAME resolution order the daemon's own `resolveDeps` uses
|
|
516
|
+
* (`.claude/helpers/dz-embed-daemon.mjs`) — project `package.json` first, then `agentdb`'s own
|
|
517
|
+
* declared dependency (possibly hoisted elsewhere) — so core and the daemon agree on which install
|
|
518
|
+
* of transformers they find, in a monorepo or a plain install alike. Each candidate root is
|
|
519
|
+
* confirmed by {@link findAncestorWithModule} BEFORE `require.resolve` is trusted (see its own doc
|
|
520
|
+
* comment for why the plain try/catch this replaced was not safe under this package's test runner).
|
|
521
|
+
*
|
|
522
|
+
* Exported (fix round 1, F4, same convention as {@link safeErrorMessage}/{@link resetAgentdbEmbedderCache}):
|
|
523
|
+
* `embedder-single-owner.test.ts`'s live-dep skip gate needs the SAME resolution order the production
|
|
524
|
+
* code uses to decide, BEFORE running, whether a live embedder failure is a dependency gap (named skip)
|
|
525
|
+
* or a real defect (must fail) — a text-matching heuristic on the error message cannot tell those apart.
|
|
526
|
+
*/
|
|
527
|
+
export function resolveTransformersModule(projectRoot) {
|
|
528
|
+
const candidates = ['@huggingface/transformers', '@xenova/transformers'];
|
|
529
|
+
for (const name of candidates) {
|
|
530
|
+
const ancestor = findAncestorWithModule(projectRoot, name);
|
|
531
|
+
if (ancestor === undefined)
|
|
532
|
+
continue;
|
|
533
|
+
try {
|
|
534
|
+
return { url: pathToFileURL(createRequire(join(ancestor, 'package.json')).resolve(name)).href };
|
|
535
|
+
}
|
|
536
|
+
catch {
|
|
537
|
+
/* an ancestor that named the directory but whose require still can't resolve it (e.g. a
|
|
538
|
+
broken symlink) — try the next candidate */
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
let agentdbDir;
|
|
542
|
+
try {
|
|
543
|
+
agentdbDir = dirname(createRequire(join(projectRoot, 'package.json')).resolve('agentdb'));
|
|
544
|
+
}
|
|
545
|
+
catch {
|
|
546
|
+
return { error: DEPS_MISSING };
|
|
547
|
+
}
|
|
548
|
+
for (const name of candidates) {
|
|
549
|
+
const ancestor = findAncestorWithModule(agentdbDir, name);
|
|
550
|
+
if (ancestor === undefined)
|
|
551
|
+
continue;
|
|
552
|
+
try {
|
|
553
|
+
return { url: pathToFileURL(createRequire(join(ancestor, 'package.json')).resolve(name)).href };
|
|
554
|
+
}
|
|
555
|
+
catch {
|
|
556
|
+
/* try the next candidate */
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
return { error: DEPS_MISSING };
|
|
560
|
+
}
|
|
561
|
+
/**
|
|
562
|
+
* D1 (ADR-001): builds the `transformers` pipeline DIRECTLY — the exact call agentdb's own
|
|
563
|
+
* `EmbeddingService.embed` makes for a symmetric model (`pipeline(text, { pooling:'mean',
|
|
564
|
+
* normalize:true })`, `EmbeddingService.js:205`), never through agentdb's wrapper class. This is
|
|
565
|
+
* the ONLY way to request `dtype:'q8'` at pipeline construction (D2) — `EmbeddingService.initialize`
|
|
566
|
+
* hardcodes `transformers.pipeline('feature-extraction', model)` with no dtype option at all, so a
|
|
567
|
+
* quantized store is unreachable through it at any dtype but fp32.
|
|
568
|
+
*
|
|
569
|
+
* COMPAT FALLBACK (deviation from the ADR's literal "иначе ядро отдаёт {error} как сегодня" —
|
|
570
|
+
* documented in `features/embed-daemon-memory/07_code_changes/change_manifest.md`): when
|
|
571
|
+
* `@huggingface/transformers`/`@xenova/transformers` cannot be resolved directly AND the requested
|
|
572
|
+
* dtype is the default `fp32`, this falls back to agentdb's `EmbeddingService` exactly as this
|
|
573
|
+
* function's pre-T2 body did. Measured (2026-09-16): seven OTHER features' test files
|
|
574
|
+
* (`agentdb-index.test.ts`, `agentdb-snapshot-{consistency,lock,rotation}.test.ts`, `brain.test.ts`,
|
|
575
|
+
* `quarantine-mirror-projection.test.ts`, `store-generation.test.ts`, `vector-tier{,-rvf}.test.ts`)
|
|
576
|
+
* fake ONLY `agentdb/controllers/EmbeddingService.js` for their offline fixtures, never a
|
|
577
|
+
* `@huggingface/transformers`/`@xenova/transformers` stub — a literal "no fallback" implementation
|
|
578
|
+
* reddens all of them (out of scope here: `.claude/rules/cross-runtime-concurrency.md` and this
|
|
579
|
+
* feature's own hard rule both forbid touching another feature's files). `dtype:'q8'` NEVER falls
|
|
580
|
+
* back (NFR-4) — an unresolvable transformers module with `dtype:'q8'` requested is a hard `{error}`
|
|
581
|
+
* naming the model and dtype, exactly as the ADR specifies; only the fp32 path is widened.
|
|
582
|
+
*/
|
|
583
|
+
async function initAgentdbEmbedder(projectRoot, agentdbDir, model, dim, dtype) {
|
|
584
|
+
const transformers = resolveTransformersModule(projectRoot);
|
|
585
|
+
if (!('error' in transformers)) {
|
|
586
|
+
try {
|
|
587
|
+
const { pipeline } = (await import(transformers.url));
|
|
588
|
+
const extractor = await pipeline('feature-extraction', model, dtype === 'q8' ? { dtype: 'q8' } : {});
|
|
589
|
+
embedderCachePipelinesBuilt += 1; // F6: the real primitive ran and returned a usable extractor
|
|
590
|
+
return { embed: async (t) => Float32Array.from((await extractor(t, { pooling: 'mean', normalize: true })).data) };
|
|
591
|
+
}
|
|
592
|
+
catch (err) {
|
|
593
|
+
return { error: `embedder init failed (model ${model}, dtype ${dtype}): ${err instanceof Error ? err.message : String(err)}` };
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
if (dtype === 'q8') {
|
|
597
|
+
// NFR-4: a quantized store must never silently downgrade to fp32 for lack of a transformers
|
|
598
|
+
// install — the caller needs to know exactly why q8 is unreachable here.
|
|
599
|
+
return { error: `embedder init failed (model ${model}, dtype ${dtype}): ${transformers.error}` };
|
|
600
|
+
}
|
|
601
|
+
return initViaAgentdbEmbeddingService(agentdbDir, model, dim);
|
|
345
602
|
}
|
|
346
|
-
|
|
603
|
+
/** The pre-T2 implementation, preserved verbatim as the fp32-only COMPAT FALLBACK documented on
|
|
604
|
+
* {@link initAgentdbEmbedder} above. */
|
|
605
|
+
async function initViaAgentdbEmbeddingService(agentdbDir, model, dim) {
|
|
347
606
|
try {
|
|
348
607
|
const { EmbeddingService } = (await import(pathToFileURL(join(agentdbDir, 'controllers', 'EmbeddingService.js')).href));
|
|
349
608
|
const emb = new EmbeddingService({
|
|
@@ -360,6 +619,7 @@ async function initAgentdbEmbedder(agentdbDir, model, dim) {
|
|
|
360
619
|
rolePolicy: { kind: 'symmetric' },
|
|
361
620
|
});
|
|
362
621
|
await emb.initialize();
|
|
622
|
+
embedderCachePipelinesBuilt += 1; // F6: the COMPAT FALLBACK's own primitive ran
|
|
363
623
|
return { embed: (t) => emb.embed(t) };
|
|
364
624
|
}
|
|
365
625
|
catch (err) {
|
|
@@ -367,11 +627,45 @@ async function initAgentdbEmbedder(agentdbDir, model, dim) {
|
|
|
367
627
|
}
|
|
368
628
|
}
|
|
369
629
|
/**
|
|
370
|
-
*
|
|
371
|
-
*
|
|
372
|
-
*
|
|
630
|
+
* D2 (`embed-daemon-memory`): the dtype a QUERY/write is embedded with is the STORE's own dtype
|
|
631
|
+
* (its manifest) when the store already exists, falling back to the CONFIGURED dtype only for a
|
|
632
|
+
* store that does not exist yet (its first-ever write picks up the config). This is the ONE place
|
|
633
|
+
* that decision is made — {@link resolveAgentdbEmbedder} calls it so every caller (search, an
|
|
634
|
+
* ordinary incremental index) agrees; `reindexAgentdbRows` is the sole exception (T2/plan): it
|
|
635
|
+
* stamps the manifest with the NEW configured dtype BEFORE it re-embeds, so by the time this
|
|
636
|
+
* function runs during a reindex the manifest already names the new dtype — config and manifest
|
|
637
|
+
* necessarily agree at that point, which is what makes reindex "the one place dtype changes".
|
|
373
638
|
*/
|
|
374
|
-
export
|
|
639
|
+
export function resolveStoreEmbedDtype(projectRoot, dbPath) {
|
|
640
|
+
const configured = resolveEmbedModel(projectRoot);
|
|
641
|
+
if ('error' in configured)
|
|
642
|
+
return { error: configured.error };
|
|
643
|
+
const manifest = readEmbedManifest(resolveAgentdbPath(projectRoot, dbPath));
|
|
644
|
+
// Fix round 1 (Codex #4): a manifest dtype that is PRESENT but unrecognized must refuse, not fall
|
|
645
|
+
// through to the configured default — the same discipline guardEmbedSpace applies, needed here too
|
|
646
|
+
// because THIS is what resolveAgentdbEmbedder actually keys its cache and pipeline construction on.
|
|
647
|
+
if (manifest?.readError !== undefined) {
|
|
648
|
+
return { error: `embedding manifest unreadable (${manifest.readError}); run dz vector reindex` };
|
|
649
|
+
}
|
|
650
|
+
if (manifest?.dtypeError !== undefined) {
|
|
651
|
+
return { error: `unknown embedding dtype "${manifest.dtypeError}" in manifest; run dz vector reindex` };
|
|
652
|
+
}
|
|
653
|
+
return manifest?.dtype ?? configured.dtype;
|
|
654
|
+
}
|
|
655
|
+
/**
|
|
656
|
+
* Resolve the shared embedder from the PROJECT (same dynamic-resolution discipline as
|
|
657
|
+
* {@link indexPatternsToAgentdb}); every dz call site uses the same resolved model/dtype so query
|
|
658
|
+
* and row vectors stay in the same space. Cached per process — see {@link embedderCache} above.
|
|
659
|
+
*
|
|
660
|
+
* Fix round 1 (F1, doc correction — the prior wording was misleading): `dbPath` is passed straight
|
|
661
|
+
* to {@link resolveStoreEmbedDtype}, which calls {@link resolveAgentdbPath}`(projectRoot, dbPath)` —
|
|
662
|
+
* and THAT function already returns the project's DEFAULT store path (`<project>/.dz/agentdb.db`,
|
|
663
|
+
* or `AGENTDB_PATH`) when `dbPath` is omitted, not "no path". So an omitted `dbPath` still reads the
|
|
664
|
+
* default store's OWN manifest when one exists; the CONFIGURED dtype is used only as the fallback
|
|
665
|
+
* for a store that has no manifest yet (i.e. does not exist, or predates this feature) — never as
|
|
666
|
+
* the default behaviour for "no dbPath given".
|
|
667
|
+
*/
|
|
668
|
+
export async function resolveAgentdbEmbedder(projectRoot, dbPath) {
|
|
375
669
|
let agentdbDir;
|
|
376
670
|
try {
|
|
377
671
|
const req = createRequire(join(projectRoot, 'package.json'));
|
|
@@ -383,12 +677,15 @@ export async function resolveAgentdbEmbedder(projectRoot) {
|
|
|
383
677
|
const model = resolveEmbedModel(projectRoot);
|
|
384
678
|
if ('error' in model)
|
|
385
679
|
return { error: model.error };
|
|
386
|
-
const
|
|
680
|
+
const dtype = resolveStoreEmbedDtype(projectRoot, dbPath);
|
|
681
|
+
if (typeof dtype === 'object' && 'error' in dtype)
|
|
682
|
+
return { error: dtype.error };
|
|
683
|
+
const key = `${agentdbDir}|${model.model}|${model.dim}|${dtype}`;
|
|
387
684
|
const hit = embedderCache.get(key);
|
|
388
685
|
if (hit !== undefined)
|
|
389
686
|
return hit;
|
|
390
687
|
embedderCacheInitializations += 1;
|
|
391
|
-
const promise = initAgentdbEmbedder(agentdbDir, model.model, model.dim);
|
|
688
|
+
const promise = initAgentdbEmbedder(projectRoot, agentdbDir, model.model, model.dim, dtype);
|
|
392
689
|
embedderCache.set(key, promise);
|
|
393
690
|
// FR-3: an init failure must not stick — evict so the next call retries instead of replaying
|
|
394
691
|
// the same {error} forever. `.catch` here only guards a rejection that slips past
|
|
@@ -525,7 +822,10 @@ export async function searchAgentdbPatterns(projectRoot, query, opts = {}) {
|
|
|
525
822
|
});
|
|
526
823
|
if (!guard.ok)
|
|
527
824
|
return { hits: [], error: guard.error };
|
|
528
|
-
|
|
825
|
+
// D2: the query is embedded with the STORE's own dtype (the guard above already proved the
|
|
826
|
+
// manifest and the config agree) — pass the resolved store path so resolveAgentdbEmbedder reads
|
|
827
|
+
// the same manifest guardEmbedSpace just read, never the config's dtype in isolation.
|
|
828
|
+
const emb = await resolveAgentdbEmbedder(projectRoot, resolveAgentdbPath(projectRoot, opts.dbPath));
|
|
529
829
|
if ('error' in emb)
|
|
530
830
|
return { hits: [], error: emb.error };
|
|
531
831
|
let qvec;
|
|
@@ -909,6 +1209,27 @@ export function bumpAgentdbUses(projectRoot, dzIds, opts = {}) {
|
|
|
909
1209
|
return { bumped: 0, error: `uses bump failed: ${err instanceof Error ? err.message : String(err)}` };
|
|
910
1210
|
}
|
|
911
1211
|
}
|
|
1212
|
+
/**
|
|
1213
|
+
* Fix-round 1 (CRITICAL, item 1b — the belt): whether {@link reindexAgentdbRows} must run its own
|
|
1214
|
+
* rescue bump, given the DELETE's own observed `changes` count and the nested
|
|
1215
|
+
* {@link indexPatternsToAgentdb} call's result. Exported test-only (same convention as
|
|
1216
|
+
* {@link resetAgentdbEmbedderCache}) so the DECISION can be exercised directly and deterministically,
|
|
1217
|
+
* independent of forcing a real concurrent bump-lock race.
|
|
1218
|
+
*
|
|
1219
|
+
* `!nestedBumped && (deleteChanges > 0 || indexed.indexed > 0 || indexed.error !== undefined)`:
|
|
1220
|
+
* - `deleteChanges > 0` — the DELETE genuinely removed rows; the store changed regardless of the
|
|
1221
|
+
* nested call's outcome.
|
|
1222
|
+
* - `indexed.indexed > 0` — the nested call committed rows itself but its OWN bump failed
|
|
1223
|
+
* (`generationBumped: false`) or was never attempted.
|
|
1224
|
+
* - `indexed.error !== undefined` — the nested call's post-write state is UNKNOWN (item 1a: an error
|
|
1225
|
+
* here may still carry accurate `indexed`/`generationBumped` facts, but a caller must not assume a
|
|
1226
|
+
* future error path will). C-1: when in doubt, bump — an extra bump only over-invalidates a cache
|
|
1227
|
+
* (safe), a missed one serves stale data (not safe).
|
|
1228
|
+
*/
|
|
1229
|
+
export function needsRescueBump(deleteChanges, indexed) {
|
|
1230
|
+
const nestedBumped = indexed.generationBumped === true;
|
|
1231
|
+
return !nestedBumped && (deleteChanges > 0 || indexed.indexed > 0 || indexed.error !== undefined);
|
|
1232
|
+
}
|
|
912
1233
|
export async function reindexAgentdbRows(projectRoot, rows, opts = {}) {
|
|
913
1234
|
const dbFile = resolveAgentdbPath(projectRoot, opts.dbPath);
|
|
914
1235
|
const ms = Date.now();
|
|
@@ -1086,6 +1407,11 @@ export async function reindexAgentdbRows(projectRoot, rows, opts = {}) {
|
|
|
1086
1407
|
}
|
|
1087
1408
|
};
|
|
1088
1409
|
let stale = [];
|
|
1410
|
+
// T1 (`store-generation-residuals`, record `3cfcec83`): the OBSERVED fact — `changes` from the
|
|
1411
|
+
// DELETE's own prepared-statement result, never inferred from `rows.length` or any other adjacent
|
|
1412
|
+
// signal (06_implementation_plan.md's T1 section names exactly that inference as the mistake to
|
|
1413
|
+
// avoid). Declared outside the `db` block so it survives to the bump decision below `db.close()`.
|
|
1414
|
+
let deleteChanges = 0;
|
|
1089
1415
|
try {
|
|
1090
1416
|
mkdirSync(dirname(dbFile), { recursive: true });
|
|
1091
1417
|
const db = new Database(dbFile);
|
|
@@ -1105,9 +1431,9 @@ export async function reindexAgentdbRows(projectRoot, rows, opts = {}) {
|
|
|
1105
1431
|
const delPat = db.prepare(`DELETE FROM reasoning_patterns WHERE task_type IN (${placeholders})`);
|
|
1106
1432
|
const tx = db.transaction(() => {
|
|
1107
1433
|
delEmb.run(...taskTypes);
|
|
1108
|
-
delPat.run(...taskTypes);
|
|
1434
|
+
return delPat.run(...taskTypes).changes;
|
|
1109
1435
|
});
|
|
1110
|
-
tx();
|
|
1436
|
+
deleteChanges = tx();
|
|
1111
1437
|
}
|
|
1112
1438
|
finally {
|
|
1113
1439
|
db.close();
|
|
@@ -1121,6 +1447,16 @@ export async function reindexAgentdbRows(projectRoot, rows, opts = {}) {
|
|
|
1121
1447
|
writeEmbedManifest(dbFile, currentEmbedManifest(model, version, 'agentdb'));
|
|
1122
1448
|
const indexed = await indexPatternsToAgentdb(projectRoot, rows, { dbPath: dbFile });
|
|
1123
1449
|
if (indexed.error !== undefined) {
|
|
1450
|
+
// Fix-round 1 (CRITICAL, item 1b — the belt): before this fix, NO bump was attempted anywhere
|
|
1451
|
+
// on this branch, regardless of `deleteChanges` — the DELETE above may have genuinely removed
|
|
1452
|
+
// rows from the store (a real change on disk) and the counter would never move to reflect it,
|
|
1453
|
+
// even though `rollback()` below may itself fail and leave that changed state in place. An
|
|
1454
|
+
// error from the nested call means the post-write state is UNKNOWN — C-1 resolves unknown in
|
|
1455
|
+
// favour of bumping (a spurious extra bump only over-invalidates a cache; a missed one serves
|
|
1456
|
+
// stale data). `needsRescueBump` is the SAME decision used on the success path below — one
|
|
1457
|
+
// rule, not two that could drift apart.
|
|
1458
|
+
if (needsRescueBump(deleteChanges, indexed))
|
|
1459
|
+
bumpStoreGeneration(projectRoot, opts.dbPath);
|
|
1124
1460
|
const rb = await rollback();
|
|
1125
1461
|
if (rb.restored === 'failed')
|
|
1126
1462
|
rollbackFailed = true; // AM-3: the `finally` below must not clear the marker
|
|
@@ -1147,13 +1483,36 @@ export async function reindexAgentdbRows(projectRoot, rows, opts = {}) {
|
|
|
1147
1483
|
throw err;
|
|
1148
1484
|
snapshots = { kept: [], removed: [], removedBytes: 0, keep: keepSnapshots, errors: [`lock busy: ${err.message}`] };
|
|
1149
1485
|
}
|
|
1150
|
-
//
|
|
1151
|
-
//
|
|
1152
|
-
//
|
|
1153
|
-
//
|
|
1154
|
-
//
|
|
1155
|
-
//
|
|
1156
|
-
|
|
1486
|
+
// T1 (`store-generation-residuals`, record `3cfcec83` — supersedes the AM-1 comment this
|
|
1487
|
+
// replaces, which documented the double-bump as "harmless" rather than fixing it). AM-1's
|
|
1488
|
+
// underlying concern stands unchanged: `reindexAgentdbRows` is ITSELF a mutator (the DELETE
|
|
1489
|
+
// above rebuilds the owned task types) and must not rely solely on `indexPatternsToAgentdb`'s
|
|
1490
|
+
// own internal bump, because that nested call is a no-op — bumps nothing, sets no
|
|
1491
|
+
// `generationBumped` — when `rows` is empty, yet the DELETE just above may have changed the
|
|
1492
|
+
// store regardless of whether there was anything to re-insert.
|
|
1493
|
+
//
|
|
1494
|
+
// The rule (now the shared {@link needsRescueBump} helper — fix-round 1, item 1b — used
|
|
1495
|
+
// identically on the error branch above) uses OBSERVED facts, never inferred from an adjacent
|
|
1496
|
+
// signal (the mistake named in the plan's T1 section, fresh from the worker-ceiling fix that
|
|
1497
|
+
// predates this one): `deleteChanges` is the DELETE's own `changes` count, read directly off the
|
|
1498
|
+
// prepared-statement result; `indexed.generationBumped` is a field `indexPatternsToAgentdb` sets
|
|
1499
|
+
// ONLY where its own bump actually ran (never guessed from `indexed.indexed > 0`, which is
|
|
1500
|
+
// itself a real fact but a DIFFERENT one — see below).
|
|
1501
|
+
//
|
|
1502
|
+
// The condition is intentionally `!nestedBumped && (deleteChanges > 0 || indexed.indexed > 0 ||
|
|
1503
|
+
// indexed.error !== undefined)`, NOT the narrower `deleteChanges > 0 && !nestedBumped` the
|
|
1504
|
+
// plan's prose formula reads as: a bare `deleteChanges > 0` gate would MISS the case where the
|
|
1505
|
+
// DELETE removed nothing (a first-ever reindex of these task types) but the nested insert then
|
|
1506
|
+
// ran and its OWN bump failed (`indexed.generationBumped === false`, e.g. a transient lock
|
|
1507
|
+
// timeout) — under the narrower gate the store would have changed on disk with no rescue bump at
|
|
1508
|
+
// all, a genuine under-bump. C-1 (`01_requirements.md`) makes correctness here non-negotiable:
|
|
1509
|
+
// "при сомнении поднимать счётчик ЛИШНИЙ раз безопаснее, чем не поднять" — so the OR-of-facts
|
|
1510
|
+
// form below is what actually ships; it satisfies every case FR-1's AC-1 enumerates AND closes
|
|
1511
|
+
// the gaps the plan's literal formula and the pre-fix-round-1 condition left open, verified by
|
|
1512
|
+
// exhaustive case analysis in `features/store-generation-residuals/07_code_changes/change_manifest.md`.
|
|
1513
|
+
if (needsRescueBump(deleteChanges, indexed)) {
|
|
1514
|
+
bumpStoreGeneration(projectRoot, opts.dbPath);
|
|
1515
|
+
}
|
|
1157
1516
|
return {
|
|
1158
1517
|
reembedded: indexed.indexed,
|
|
1159
1518
|
model: model.model,
|