akm-cli 0.9.15-beta.1 → 0.9.15-beta.2

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.
@@ -120,9 +120,36 @@ function releaseLockRaw(lockPath) {
120
120
  export function tryAcquireLockSync(lockPath, payload) {
121
121
  return withLockOperationMutex(lockPath, () => tryAcquireLockRaw(lockPath, payload));
122
122
  }
123
- /** Build a PID-bearing payload with a unique token for one acquisition attempt. */
123
+ /**
124
+ * Best-effort launcher pid from `AKM_LAUNCHER_PID` (set by
125
+ * `scripts/node-runtime/akm`/`akm-migrate`, #956) — undefined when unset,
126
+ * empty, or not a positive integer (never trust an ambient env var blindly
127
+ * into a lock message). Also the gate the parent-death watchdog
128
+ * (`core/parent-watchdog.ts`) uses to stay inert outside a launcher-managed
129
+ * run.
130
+ */
131
+ export function launcherPidFromEnv() {
132
+ const raw = process.env.AKM_LAUNCHER_PID;
133
+ if (!raw)
134
+ return undefined;
135
+ const pid = Number.parseInt(raw, 10);
136
+ return Number.isInteger(pid) && pid > 0 ? pid : undefined;
137
+ }
138
+ /**
139
+ * Build a PID-bearing payload with a unique token for one acquisition attempt.
140
+ * Adds `launcherPid` (#956) whenever this process is running under the
141
+ * published launcher, so a lock's holder can be identified by the pid every
142
+ * process listing and task log actually shows (the launcher's) as well as
143
+ * the pid that holds the lock (the bun/node child).
144
+ */
124
145
  export function createLockPayload(metadata = {}) {
125
- return JSON.stringify({ ...metadata, pid: process.pid, lockId: randomUUID() });
146
+ const launcherPid = launcherPidFromEnv();
147
+ return JSON.stringify({
148
+ ...metadata,
149
+ pid: process.pid,
150
+ ...(launcherPid !== undefined ? { launcherPid } : {}),
151
+ lockId: randomUUID(),
152
+ });
126
153
  }
127
154
  /**
128
155
  * Inspect an existing sentinel at `lockPath` without modifying it.
@@ -153,17 +180,17 @@ export function probeLock(lockPath, opts) {
153
180
  return { state: "absent" };
154
181
  const { rawContent, identity } = snapshot;
155
182
  const ageMs = Date.now() - identity.mtimeMs;
156
- const holderPid = extractHolderPid(rawContent);
183
+ const { holderPid, launcherPid } = extractLockIdentity(rawContent);
157
184
  if (holderPid === undefined) {
158
185
  return { state: "stale", reason: "invalid_pid", ageMs, rawContent, identity };
159
186
  }
160
187
  if (!isProcessAlive(holderPid)) {
161
- return { state: "stale", reason: "pid_dead", holderPid, ageMs, rawContent, identity };
188
+ return { state: "stale", reason: "pid_dead", holderPid, launcherPid, ageMs, rawContent, identity };
162
189
  }
163
190
  if (opts?.staleAfterMs !== undefined && ageMs > opts.staleAfterMs) {
164
- return { state: "stale", reason: "age_exceeded", holderPid, ageMs, rawContent, identity };
191
+ return { state: "stale", reason: "age_exceeded", holderPid, launcherPid, ageMs, rawContent, identity };
165
192
  }
166
- return { state: "held", holderPid, ageMs, rawContent, identity };
193
+ return { state: "held", holderPid, launcherPid, ageMs, rawContent, identity };
167
194
  }
168
195
  /**
169
196
  * Revalidate and quarantine the probed sentinel while holding the same operation
@@ -254,25 +281,32 @@ export function releaseLock(ownership) {
254
281
  });
255
282
  }
256
283
  /**
257
- * Extract a PID from a sentinel body. Accepts the two shapes used across
258
- * the codebase: a bare numeric string (config-io, vault, lockfile) and
259
- * a JSON object with a `pid` field (improve). Returns undefined when the
260
- * body is unparseable or yields a non-positive integer.
284
+ * Extract a holder pid, and (#956) a launcher pid when the payload recorded
285
+ * one, from a sentinel body. Accepts the two shapes used across the
286
+ * codebase: a bare numeric string (config-io, vault, lockfile never
287
+ * carries a `launcherPid`) and a JSON object with `pid`/`launcherPid` fields
288
+ * (`createLockPayload`). `holderPid` is undefined when the body is
289
+ * unparseable or yields a non-positive integer; `launcherPid` is undefined
290
+ * whenever the payload has none, independent of whether `holderPid` parsed.
261
291
  */
262
- function extractHolderPid(content) {
292
+ function extractLockIdentity(content) {
263
293
  const trimmed = content.trim();
264
294
  if (!trimmed)
265
- return undefined;
295
+ return {};
266
296
  if (trimmed.startsWith("{")) {
267
297
  try {
268
298
  const parsed = JSON.parse(trimmed);
269
299
  const pid = typeof parsed.pid === "number" ? parsed.pid : Number.NaN;
270
- return Number.isInteger(pid) && pid > 0 ? pid : undefined;
300
+ const rawLauncherPid = typeof parsed.launcherPid === "number" ? parsed.launcherPid : Number.NaN;
301
+ return {
302
+ holderPid: Number.isInteger(pid) && pid > 0 ? pid : undefined,
303
+ launcherPid: Number.isInteger(rawLauncherPid) && rawLauncherPid > 0 ? rawLauncherPid : undefined,
304
+ };
271
305
  }
272
306
  catch {
273
- return undefined;
307
+ return {};
274
308
  }
275
309
  }
276
310
  const pid = Number.parseInt(trimmed, 10);
277
- return Number.isInteger(pid) && pid > 0 ? pid : undefined;
311
+ return { holderPid: Number.isInteger(pid) && pid > 0 ? pid : undefined };
278
312
  }
@@ -0,0 +1,64 @@
1
+ // This Source Code Form is subject to the terms of the Mozilla Public
2
+ // License, v. 2.0. If a copy of the MPL was not distributed with this
3
+ // file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
+ /**
5
+ * Parent-death watchdog (#956).
6
+ *
7
+ * The published launcher (`scripts/node-runtime/akm`) now forwards SIGTERM to
8
+ * its child, so a `kill <launcher-pid>` no longer orphans it — but a launcher
9
+ * that dies WITHOUT delivering a signal (SIGKILL, an out-of-memory kill, a
10
+ * supervisor that force-removes the process) still reparents the child to
11
+ * init with nothing to catch. Field evidence: 40 orphaned
12
+ * `bun …/dist/cli.js` processes over one day, up to 10h old, some created by
13
+ * a curate hook killing its own launcher on timeout — every akm invocation
14
+ * under the launcher is exposed to this, not only `akm index`, so this
15
+ * watchdog runs for every command (wired at the CLI entry point in
16
+ * `src/cli.ts`), not just index.
17
+ *
18
+ * `process.ppid` on POSIX changes the instant the parent exits (the child is
19
+ * reparented, usually to pid 1) — polling it is the standard orphan-detection
20
+ * trick when no direct death notification exists. This module owns only the
21
+ * poll/compare mechanics; the caller's `onOrphaned` decides what "stop"
22
+ * means. `src/cli.ts` wires it to `process.kill(process.pid, "SIGTERM")`,
23
+ * reusing the same self-signal every command already has rather than a
24
+ * second, parallel abort path. Only `akm index`/`improve` register their own
25
+ * SIGTERM listener (the index command's AbortController in
26
+ * `commands/sources/stash-cli.ts`) and get a graceful, in-process shutdown
27
+ * from it. Every other command has no listener of its own, so the runtime's
28
+ * default disposition terminates it directly — `exit` handlers (lock
29
+ * release included) do NOT run — and any lock it held is cleared later by
30
+ * the next acquirer's dead-pid stale-reclaim (`file-lock.ts`), not by
31
+ * in-process release. Either way the orphaned process stops, which is this
32
+ * watchdog's actual job.
33
+ */
34
+ /** Pure comparison seam: true once the observed ppid differs from the one seen at startup. */
35
+ export function isReparented(initialPpid, currentPpid) {
36
+ return currentPpid !== initialPpid;
37
+ }
38
+ /**
39
+ * Start polling `getPpid()` every `intervalMs` and invoke `onOrphaned` once,
40
+ * the first time the observed ppid no longer matches `initialPpid`. The timer
41
+ * is unref'ed so it never keeps the process alive on its own — a command that
42
+ * finishes normally still exits promptly.
43
+ */
44
+ export function startParentDeathWatchdog(options) {
45
+ const { initialPpid, onOrphaned } = options;
46
+ const intervalMs = options.intervalMs ?? 2000;
47
+ const getPpid = options.getPpid ?? (() => process.ppid);
48
+ const setIntervalFn = options.setIntervalFn ?? setInterval;
49
+ const clearIntervalFn = options.clearIntervalFn ?? clearInterval;
50
+ let fired = false;
51
+ const timer = setIntervalFn(() => {
52
+ if (fired)
53
+ return;
54
+ if (isReparented(initialPpid, getPpid())) {
55
+ fired = true;
56
+ onOrphaned();
57
+ }
58
+ }, intervalMs);
59
+ if (typeof timer !== "number")
60
+ timer.unref?.();
61
+ return {
62
+ stop: () => clearIntervalFn(timer),
63
+ };
64
+ }
@@ -31,6 +31,17 @@ import path from "node:path";
31
31
  import { ConfigError } from "./errors.js";
32
32
  import { createLockPayload, probeLock, reclaimStaleLock, tryAcquireLockSync } from "./file-lock.js";
33
33
  import { describeInaccessiblePath } from "./path-access.js";
34
+ /**
35
+ * Render a lock holder's pid for a message: `"4242"`, or `"4242 (launcher
36
+ * 4240)"` when the holder's launcher pid is known (#956) — every process
37
+ * listing and task log shows the launcher pid, not the bun/node child's, so
38
+ * naming only the holder pid left an operator unable to connect the two.
39
+ */
40
+ export function formatLockHolderPid(holder) {
41
+ if (holder.pid === null)
42
+ return "unknown";
43
+ return holder.launcherPid !== null ? `${holder.pid} (launcher ${holder.launcherPid})` : String(holder.pid);
44
+ }
34
45
  function parseLockPayload(rawContent) {
35
46
  if (!rawContent)
36
47
  return null;
@@ -42,7 +53,7 @@ function parseLockPayload(rawContent) {
42
53
  }
43
54
  }
44
55
  function holderOf(lock) {
45
- return { pid: lock?.pid ?? null, startedAt: lock?.startedAt ?? null };
56
+ return { pid: lock?.pid ?? null, startedAt: lock?.startedAt ?? null, launcherPid: lock?.launcherPid ?? null };
46
57
  }
47
58
  /**
48
59
  * Attempt to acquire `lockPath`. Returns `{ state: "acquired" }` with an
@@ -69,7 +80,7 @@ export function tryAcquireRunLock(lockPath, options) {
69
80
  return { state: "acquired", ownership };
70
81
  // Re-grabbed by another racer in this exact window — no holder detail
71
82
  // available without re-probing (which could itself race again).
72
- return { state: "held", holder: { pid: null, startedAt: null } };
83
+ return { state: "held", holder: { pid: null, startedAt: null, launcherPid: null } };
73
84
  }
74
85
  if (probe.state === "inaccessible") {
75
86
  throw new ConfigError(`${options.label} lock exists but is not readable: ${describeInaccessiblePath(lockPath, probe.code)}.`, "DATA_DIR_UNREADABLE");
@@ -22,7 +22,7 @@
22
22
  import { releaseLock } from "../core/file-lock.js";
23
23
  import { tryWithMaintenanceStartBarrier, withMaintenanceStartBarrier } from "../core/maintenance-barrier.js";
24
24
  import { getIndexRebuildLockPath } from "../core/paths.js";
25
- import { tryAcquireRunLock } from "../core/run-lock.js";
25
+ import { formatLockHolderPid, tryAcquireRunLock } from "../core/run-lock.js";
26
26
  import { warn, warnVerbose } from "../core/warn.js";
27
27
  export function indexRebuildLockPath() {
28
28
  return getIndexRebuildLockPath();
@@ -53,18 +53,18 @@ export function tryAcquireIndexRebuildLock(skipIfLocked) {
53
53
  const result = tryWithMaintenanceStartBarrier(acquire);
54
54
  if (!result) {
55
55
  warn("[index] maintenance barrier held; skipping (--skip-if-locked)");
56
- return { state: "skipped", holder: { pid: null, startedAt: null } };
56
+ return { state: "skipped", holder: { pid: null, startedAt: null, launcherPid: null } };
57
57
  }
58
58
  if (result.state === "acquired")
59
59
  return result;
60
- warn(`[index] another index run holds the lock (PID ${result.holder.pid}, started ${result.holder.startedAt}); ` +
60
+ warn(`[index] another index run holds the lock (PID ${formatLockHolderPid(result.holder)}, started ${result.holder.startedAt}); ` +
61
61
  "skipping (--skip-if-locked)");
62
62
  return { state: "skipped", holder: result.holder };
63
63
  }
64
64
  const result = withMaintenanceStartBarrier(acquire);
65
65
  if (result.state === "acquired")
66
66
  return result;
67
- warn(`[index] another index run is active (pid ${result.holder.pid}, started ${result.holder.startedAt}); ` +
67
+ warn(`[index] another index run is active (pid ${formatLockHolderPid(result.holder)}, started ${result.holder.startedAt}); ` +
68
68
  "this run will contend with it — pass --skip-if-locked for scheduled runs");
69
69
  return { state: "contended", holder: result.holder };
70
70
  }
@@ -27,6 +27,7 @@ import { isDataDirUnreadableError } from "../core/errors.js";
27
27
  import { probeLock } from "../core/file-lock.js";
28
28
  import { isPathAbsent } from "../core/path-access.js";
29
29
  import { getDbPath, getIndexRebuildLockPath } from "../core/paths.js";
30
+ import { formatLockHolderPid } from "../core/run-lock.js";
30
31
  import { warn, warnVerbose } from "../core/warn.js";
31
32
  import { closeDatabase, openExistingDatabase } from "../storage/repositories/index-connection.js";
32
33
  import { deleteEntriesByIds, getEntryCount, upsertEntry } from "../storage/repositories/index-entries-repository.js";
@@ -75,7 +76,14 @@ export async function indexWrittenAssets(stashDir, filePaths, options = {}) {
75
76
  // rebuild in progress will pick up the change on its own.
76
77
  const rebuildProbe = probeLock(getIndexRebuildLockPath());
77
78
  if (rebuildProbe.state === "held") {
78
- warn(`index rebuild in progress (pid ${rebuildProbe.holderPid}); the next index pass will index ${filePaths.join(", ")}`);
79
+ // #956: name the launcher pid alongside the holder pid when known
80
+ // every process listing and task log shows the launcher's pid, not
81
+ // the bun/node child's.
82
+ const holderLabel = formatLockHolderPid({
83
+ pid: rebuildProbe.holderPid,
84
+ launcherPid: rebuildProbe.launcherPid ?? null,
85
+ });
86
+ warn(`index rebuild in progress (pid ${holderLabel}); the next index pass will index ${filePaths.join(", ")}`);
79
87
  return true;
80
88
  }
81
89
  const dbPath = getDbPath();
@@ -19,6 +19,7 @@ import { isLlmFeatureEnabled } from "../llm/feature-gate.js";
19
19
  import { resolveIndexPassExecution } from "../llm/index-passes.js";
20
20
  import { preflightStructuredLlmRunner } from "../llm/structured-call.js";
21
21
  import { resolveSourcesForOrigin } from "../registry/origin-resolve.js";
22
+ import { salvageEmbeddingsBeforeDiscard } from "../storage/repositories/embedding-salvage-repository.js";
22
23
  import { closeDatabase, openExistingDatabase, openIndexDatabase, openReadonlyExistingDatabase, } from "../storage/repositories/index-connection.js";
23
24
  import { deleteAllEntries, deleteEntriesByBundle, deleteEntriesByDirAndBundle, deleteEntriesByDirExceptRefs, deleteEntriesByIds, deleteUsageEventsByEntryIds, findEntryIdByRef, getAllEntries, getEmbeddableEntryCount, getEntryCount, getIndexedBundleIdsByDir, getIndexedDirPathsByBundleId, relinkUsageEvents, upsertEntry, } from "../storage/repositories/index-entries-repository.js";
24
25
  import { clearStaleCacheEntries, computeBodyHash, getLlmCacheEntry, } from "../storage/repositories/index-llm-cache-repository.js";
@@ -184,21 +185,54 @@ async function runWalkPhase(ctx) {
184
185
  });
185
186
  ctx.timing.tLlmEnd = Date.now();
186
187
  }
188
+ /**
189
+ * The ONE embedding-phase implementation (#954): generate and
190
+ * store vectors for every entry missing one, then compute the `hasEmbeddings`
191
+ * fact and the semantic-search verification off the result. `akmIndex`'s own
192
+ * (non-deferred) run calls this from {@link runEmbeddingPhase} below; `akm
193
+ * bundle update`'s coordinator calls it directly on its own connection AFTER
194
+ * its unified update transaction commits, since the ambient-transaction drift
195
+ * guard (and the whole point of per-batch commit, #954) requires `db` to have
196
+ * no ambient transaction open.
197
+ */
198
+ export async function runEmbeddingPass(params) {
199
+ const { db, config, onProgress, signal, reembed } = params;
200
+ const embeddingResult = await generateEmbeddingsForDb(db, config, onProgress, signal, undefined, {
201
+ forceReembed: reembed,
202
+ });
203
+ setMeta(db, "hasEmbeddings", embeddingResult.success ? "1" : "0");
204
+ const semanticEntryCount = getEmbeddableEntryCount(db);
205
+ onProgress({ phase: "finalize", message: "Verifying semantic search state." });
206
+ const verification = verifyIndexState(db, config, semanticEntryCount, embeddingResult);
207
+ onProgress({ phase: "verify", message: verification.message });
208
+ return { embeddingResult, verification };
209
+ }
187
210
  /**
188
211
  * Embedding phase: generate and store vector embeddings for all unembedded
189
- * entries. Writes `ctx.embeddingResult` for the finalize phase.
212
+ * entries. Writes `ctx.embeddingResult` and `ctx.verification` for the
213
+ * finalize phase / caller.
190
214
  */
191
215
  async function runEmbeddingPhase(ctx) {
192
- const { db, config, signal, onProgress, reembed } = ctx;
216
+ const { db, config, signal, onProgress, reembed, deferredUpdateTransaction } = ctx;
193
217
  throwIfAborted(signal);
218
+ if (deferredUpdateTransaction) {
219
+ // `akm bundle update`'s deferred pass (#954): the embedding
220
+ // phase runs AFTER the coordinator's own commit, on its own connection,
221
+ // via the coordinator's direct `runEmbeddingPass` call — never here,
222
+ // inside the borrowed transaction (the ambient-transaction drift guard
223
+ // would reject it anyway). `runFinalizePhase` records semantic state as
224
+ // "pending".
225
+ ctx.timing.tEmbedEnd = Date.now();
226
+ return;
227
+ }
194
228
  // Forward the signal. Without it generateEmbeddingsForDb's abort machinery was
195
229
  // inert — its throwIfAborted checks and the signal it threads into embedBatch
196
230
  // (which RemoteEmbedder passes to every fetch and LocalEmbedder honours between
197
231
  // chunks) never saw a controller. Ctrl-C and the improve budget abort could not
198
232
  // stop the embedding phase, the longest phase of an index run.
199
- ctx.embeddingResult = await generateEmbeddingsForDb(db, config, onProgress, signal, undefined, {
200
- forceReembed: reembed,
201
- });
233
+ const { embeddingResult, verification } = await runEmbeddingPass({ db, config, onProgress, signal, reembed });
234
+ ctx.embeddingResult = embeddingResult;
235
+ ctx.verification = verification;
202
236
  ctx.timing.tEmbedEnd = Date.now();
203
237
  }
204
238
  /**
@@ -206,8 +240,8 @@ async function runEmbeddingPhase(ctx) {
206
240
  * usage events, recompute utility scores, update index metadata, and emit the
207
241
  * verify event.
208
242
  */
209
- async function runFinalizePhase(ctx, deferredUpdateTransaction) {
210
- const { db, config, sources, sourceDirs, stashDir, signal, onProgress } = ctx;
243
+ async function runFinalizePhase(ctx) {
244
+ const { db, config, sources, sourceDirs, stashDir, signal, onProgress, deferredUpdateTransaction } = ctx;
211
245
  ctx.timing.tFinalizeStart = Date.now();
212
246
  // `upsertEntry` and every canonical delete own their FTS projection. This is
213
247
  // an observation point, not a second materialization pass.
@@ -249,22 +283,37 @@ async function runFinalizePhase(ctx, deferredUpdateTransaction) {
249
283
  // An incomplete run preserves the prior freshness watermark. Advancing it
250
284
  // could make a recovered source look unchanged even though this run never
251
285
  // persisted its files.
252
- const embeddingResult = ctx.embeddingResult ?? { success: false };
253
286
  if (ctx.scanComplete) {
254
287
  setMeta(db, "builtAt", new Date().toISOString());
255
288
  setMeta(db, "stashDir", stashDir);
256
289
  setMeta(db, "stashDirs", JSON.stringify(sourceDirs));
257
290
  setMeta(db, "sourceOwners", JSON.stringify(sourceOwners(sources)));
258
291
  }
259
- setMeta(db, "hasEmbeddings", embeddingResult.success ? "1" : "0");
260
292
  warnIfVecMissing(db);
261
293
  const totalEntries = getEntryCount(db);
262
- const semanticEntryCount = getEmbeddableEntryCount(db);
263
- onProgress({ phase: "finalize", message: "Verifying semantic search state." });
264
- const verification = verifyIndexState(db, config, semanticEntryCount, embeddingResult);
265
- onProgress({ phase: "verify", message: verification.message });
266
- // Store verification result and totalEntries on ctx for the caller to use
267
- ctx.verification = verification;
294
+ if (deferredUpdateTransaction) {
295
+ // #954: the embedding phase was skipped for this borrowed
296
+ // transaction record semantic state as pending, never ready, until the
297
+ // coordinator's own post-commit `runEmbeddingPass` call reports the
298
+ // truth on a fresh connection.
299
+ setMeta(db, "hasEmbeddings", "0");
300
+ const semanticEntryCount = getEmbeddableEntryCount(db);
301
+ const message = "Semantic index update deferred until after the source-update commit.";
302
+ onProgress({ phase: "verify", message });
303
+ ctx.verification = {
304
+ ok: true,
305
+ message,
306
+ semanticSearchEnabled: config.semanticSearchMode === "auto",
307
+ semanticSearchMode: config.semanticSearchMode,
308
+ semanticStatus: config.semanticSearchMode === "off" ? "disabled" : "pending",
309
+ embeddingProvider: getEmbeddingProvider(config.embedding),
310
+ entryCount: semanticEntryCount,
311
+ embeddingCount: getEmbeddingCount(db),
312
+ vecAvailable: isVecAvailable(db),
313
+ };
314
+ }
315
+ // Non-deferred: ctx.verification was already populated by runEmbeddingPhase
316
+ // (via the shared runEmbeddingPass).
268
317
  ctx.totalEntries = totalEntries;
269
318
  ctx.timing.tFinalizeEnd = Date.now();
270
319
  // suppress unused warning — sources was previously used inline
@@ -513,6 +562,11 @@ async function akmIndexReal(options) {
513
562
  force: full,
514
563
  materialize: options.hydrateSources !== false,
515
564
  secrets: storeSecretResolver,
565
+ // Same progress channel as every other phase (#954) — a
566
+ // stalled clone/fetch here runs BEFORE index.db is even opened, so
567
+ // without this it looked identical to "no database open, nothing
568
+ // written".
569
+ onProgress: (message) => onProgress({ phase: "preflight", message }),
516
570
  });
517
571
  const sourceCacheEnd = Date.now();
518
572
  const allSourceEntries = resolveSourceEntries(stashDir, config);
@@ -553,6 +607,7 @@ async function akmIndexReal(options) {
553
607
  onProgress,
554
608
  signal,
555
609
  t0,
610
+ deferredUpdateTransaction: options.deferredUpdateTransaction,
556
611
  });
557
612
  indexRunContext = ctx;
558
613
  onProgress({
@@ -592,7 +647,7 @@ async function akmIndexReal(options) {
592
647
  }
593
648
  cleanEnd = Date.now();
594
649
  await runEmbeddingPhase(ctx);
595
- await runFinalizePhase(ctx, options.deferredUpdateTransaction);
650
+ await runFinalizePhase(ctx);
596
651
  // ────────────────────────────────────────────────────────────────────────
597
652
  // runFinalizePhase always populates these before returning.
598
653
  const verification = ctx.verification;
@@ -1159,6 +1214,14 @@ function persistDirRecords(db, dirRecords, doFullDelete, warnings, sourceRoots,
1159
1214
  // transaction so delete and re-insert are atomic — a concurrent reader
1160
1215
  // never observes an empty database between the two operations.
1161
1216
  if (fullDelete) {
1217
+ // #955: copy every (search_text hash, embedding) pair about to be
1218
+ // discarded wholesale into `embedding_salvage`, tagged with the
1219
+ // fingerprint the discarded vectors were generated under, BEFORE the
1220
+ // wipe below — inside the SAME transaction so the copy and the
1221
+ // discard commit or roll back together. The embedding phase later in
1222
+ // this run hands salvaged vectors back to unchanged content instead
1223
+ // of re-embedding the whole corpus.
1224
+ salvageEmbeddingsBeforeDiscard(db);
1162
1225
  // Entries and every child materialization share one deletion authority.
1163
1226
  // Usage events live in state.db and survive so finalize can relink them
1164
1227
  // to the replacement generation's row ids.