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

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.
@@ -7,18 +7,21 @@ import { detectAdapterId } from "../core/adapter/detect-adapter.js";
7
7
  import { adapterForId } from "../core/adapter/registry.js";
8
8
  import { isHttpUrl, toErrorMessage } from "../core/common.js";
9
9
  import { concurrentMap } from "../core/concurrent.js";
10
- import { ConfigError } from "../core/errors.js";
10
+ import { AkmError, ConfigError, TransientError } from "../core/errors.js";
11
+ import { probeLock } from "../core/file-lock.js";
11
12
  import { defaultConcurrencyForEndpoint } from "../core/loopback.js";
12
13
  import { classifyPathAccess, describeInaccessiblePath } from "../core/path-access.js";
13
14
  import { getDbPath } from "../core/paths.js";
14
15
  import { SCRIPT_EXTENSIONS } from "../core/recognition-util.js";
15
- import { withStateDb } from "../core/state-db.js";
16
+ import { formatLockHolderPid } from "../core/run-lock.js";
17
+ import { isSqliteContentionError, withStateDb } from "../core/state-db.js";
16
18
  import { isVerbose, warn, warnOnce, warnVerbose } from "../core/warn.js";
17
19
  import { disposeLoweredExecutionDispatchLease, } from "../integrations/agent/execution-lowering.js";
18
20
  import { isLlmFeatureEnabled } from "../llm/feature-gate.js";
19
21
  import { resolveIndexPassExecution } from "../llm/index-passes.js";
20
22
  import { preflightStructuredLlmRunner } from "../llm/structured-call.js";
21
23
  import { resolveSourcesForOrigin } from "../registry/origin-resolve.js";
24
+ import { salvageEmbeddingsBeforeDiscard } from "../storage/repositories/embedding-salvage-repository.js";
22
25
  import { closeDatabase, openExistingDatabase, openIndexDatabase, openReadonlyExistingDatabase, } from "../storage/repositories/index-connection.js";
23
26
  import { deleteAllEntries, deleteEntriesByBundle, deleteEntriesByDirAndBundle, deleteEntriesByDirExceptRefs, deleteEntriesByIds, deleteUsageEventsByEntryIds, findEntryIdByRef, getAllEntries, getEmbeddableEntryCount, getEntryCount, getIndexedBundleIdsByDir, getIndexedDirPathsByBundleId, relinkUsageEvents, upsertEntry, } from "../storage/repositories/index-entries-repository.js";
24
27
  import { clearStaleCacheEntries, computeBodyHash, getLlmCacheEntry, } from "../storage/repositories/index-llm-cache-repository.js";
@@ -27,6 +30,7 @@ import { upsertUtilityScore } from "../storage/repositories/index-utility-reposi
27
30
  import { getEmbeddingCount, isVecAvailable, isVecFastPathReady, warnIfVecMissing, } from "../storage/repositories/index-vec-repository.js";
28
31
  import { assertIndexedWorkflowSourceIdentity, WorkflowSourceIdentityError } from "../workflows/source-files.js";
29
32
  import { deleteStoredGraph } from "./db/graph-db.js";
33
+ import { indexRebuildLockPath } from "./index-rebuild-lock.js";
30
34
  import { deriveEntryProvenance, deriveInstallations } from "./installations.js";
31
35
  import { indexedPathMatchesOwner, resolveAdapterConceptOwner, } from "./lookup/adapter-concept-owner.js";
32
36
  import { generateEmbeddingsForDb } from "./materialize-embeddings.js";
@@ -184,21 +188,54 @@ async function runWalkPhase(ctx) {
184
188
  });
185
189
  ctx.timing.tLlmEnd = Date.now();
186
190
  }
191
+ /**
192
+ * The ONE embedding-phase implementation (#954): generate and
193
+ * store vectors for every entry missing one, then compute the `hasEmbeddings`
194
+ * fact and the semantic-search verification off the result. `akmIndex`'s own
195
+ * (non-deferred) run calls this from {@link runEmbeddingPhase} below; `akm
196
+ * bundle update`'s coordinator calls it directly on its own connection AFTER
197
+ * its unified update transaction commits, since the ambient-transaction drift
198
+ * guard (and the whole point of per-batch commit, #954) requires `db` to have
199
+ * no ambient transaction open.
200
+ */
201
+ export async function runEmbeddingPass(params) {
202
+ const { db, config, onProgress, signal, reembed } = params;
203
+ const embeddingResult = await generateEmbeddingsForDb(db, config, onProgress, signal, undefined, {
204
+ forceReembed: reembed,
205
+ });
206
+ setMeta(db, "hasEmbeddings", embeddingResult.success ? "1" : "0");
207
+ const semanticEntryCount = getEmbeddableEntryCount(db);
208
+ onProgress({ phase: "finalize", message: "Verifying semantic search state." });
209
+ const verification = verifyIndexState(db, config, semanticEntryCount, embeddingResult);
210
+ onProgress({ phase: "verify", message: verification.message });
211
+ return { embeddingResult, verification };
212
+ }
187
213
  /**
188
214
  * Embedding phase: generate and store vector embeddings for all unembedded
189
- * entries. Writes `ctx.embeddingResult` for the finalize phase.
215
+ * entries. Writes `ctx.embeddingResult` and `ctx.verification` for the
216
+ * finalize phase / caller.
190
217
  */
191
218
  async function runEmbeddingPhase(ctx) {
192
- const { db, config, signal, onProgress, reembed } = ctx;
219
+ const { db, config, signal, onProgress, reembed, deferredUpdateTransaction } = ctx;
193
220
  throwIfAborted(signal);
221
+ if (deferredUpdateTransaction) {
222
+ // `akm bundle update`'s deferred pass (#954): the embedding
223
+ // phase runs AFTER the coordinator's own commit, on its own connection,
224
+ // via the coordinator's direct `runEmbeddingPass` call — never here,
225
+ // inside the borrowed transaction (the ambient-transaction drift guard
226
+ // would reject it anyway). `runFinalizePhase` records semantic state as
227
+ // "pending".
228
+ ctx.timing.tEmbedEnd = Date.now();
229
+ return;
230
+ }
194
231
  // Forward the signal. Without it generateEmbeddingsForDb's abort machinery was
195
232
  // inert — its throwIfAborted checks and the signal it threads into embedBatch
196
233
  // (which RemoteEmbedder passes to every fetch and LocalEmbedder honours between
197
234
  // chunks) never saw a controller. Ctrl-C and the improve budget abort could not
198
235
  // 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
- });
236
+ const { embeddingResult, verification } = await runEmbeddingPass({ db, config, onProgress, signal, reembed });
237
+ ctx.embeddingResult = embeddingResult;
238
+ ctx.verification = verification;
202
239
  ctx.timing.tEmbedEnd = Date.now();
203
240
  }
204
241
  /**
@@ -206,8 +243,8 @@ async function runEmbeddingPhase(ctx) {
206
243
  * usage events, recompute utility scores, update index metadata, and emit the
207
244
  * verify event.
208
245
  */
209
- async function runFinalizePhase(ctx, deferredUpdateTransaction) {
210
- const { db, config, sources, sourceDirs, stashDir, signal, onProgress } = ctx;
246
+ async function runFinalizePhase(ctx) {
247
+ const { db, config, sources, sourceDirs, stashDir, signal, onProgress, deferredUpdateTransaction } = ctx;
211
248
  ctx.timing.tFinalizeStart = Date.now();
212
249
  // `upsertEntry` and every canonical delete own their FTS projection. This is
213
250
  // an observation point, not a second materialization pass.
@@ -249,22 +286,37 @@ async function runFinalizePhase(ctx, deferredUpdateTransaction) {
249
286
  // An incomplete run preserves the prior freshness watermark. Advancing it
250
287
  // could make a recovered source look unchanged even though this run never
251
288
  // persisted its files.
252
- const embeddingResult = ctx.embeddingResult ?? { success: false };
253
289
  if (ctx.scanComplete) {
254
290
  setMeta(db, "builtAt", new Date().toISOString());
255
291
  setMeta(db, "stashDir", stashDir);
256
292
  setMeta(db, "stashDirs", JSON.stringify(sourceDirs));
257
293
  setMeta(db, "sourceOwners", JSON.stringify(sourceOwners(sources)));
258
294
  }
259
- setMeta(db, "hasEmbeddings", embeddingResult.success ? "1" : "0");
260
295
  warnIfVecMissing(db);
261
296
  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;
297
+ if (deferredUpdateTransaction) {
298
+ // #954: the embedding phase was skipped for this borrowed
299
+ // transaction — record semantic state as pending, never ready, until the
300
+ // coordinator's own post-commit `runEmbeddingPass` call reports the
301
+ // truth on a fresh connection.
302
+ setMeta(db, "hasEmbeddings", "0");
303
+ const semanticEntryCount = getEmbeddableEntryCount(db);
304
+ const message = "Semantic index update deferred until after the source-update commit.";
305
+ onProgress({ phase: "verify", message });
306
+ ctx.verification = {
307
+ ok: true,
308
+ message,
309
+ semanticSearchEnabled: config.semanticSearchMode === "auto",
310
+ semanticSearchMode: config.semanticSearchMode,
311
+ semanticStatus: config.semanticSearchMode === "off" ? "disabled" : "pending",
312
+ embeddingProvider: getEmbeddingProvider(config.embedding),
313
+ entryCount: semanticEntryCount,
314
+ embeddingCount: getEmbeddingCount(db),
315
+ vecAvailable: isVecAvailable(db),
316
+ };
317
+ }
318
+ // Non-deferred: ctx.verification was already populated by runEmbeddingPhase
319
+ // (via the shared runEmbeddingPass).
268
320
  ctx.totalEntries = totalEntries;
269
321
  ctx.timing.tFinalizeEnd = Date.now();
270
322
  // suppress unused warning — sources was previously used inline
@@ -322,6 +374,44 @@ let akmIndexOverride;
322
374
  export function _setAkmIndexForTests(fake) {
323
375
  akmIndexOverride = fake;
324
376
  }
377
+ /**
378
+ * Read-only description of the rebuild lock's current holder, appended to a
379
+ * reclassified index.db contention message when known (field follow-up to
380
+ * #956). `probeLock` only inspects the sentinel — it never acquires or
381
+ * mutates it — so this is safe to call from inside an error path.
382
+ */
383
+ function describeIndexRebuildLockHolder() {
384
+ const probe = probeLock(indexRebuildLockPath());
385
+ if (probe.state !== "held")
386
+ return "";
387
+ return ` The rebuild lock is currently held by pid ${formatLockHolderPid({
388
+ pid: probe.holderPid,
389
+ launcherPid: probe.launcherPid ?? null,
390
+ })}.`;
391
+ }
392
+ /**
393
+ * Reclassify a contention-shaped error escaping the walk, index, or
394
+ * embedding phase into a retryable-shortly `TransientError` (field
395
+ * follow-up to #956, dev-team field review 2026-09-10): a concurrent writer
396
+ * (another `akm index`, a source-update embedding pass, the per-command
397
+ * background reindex) can make index.db busy, and the raw SQLite driver
398
+ * error ("database is locked") used to escape as exit 70
399
+ * (internal/unclassified) instead of the "retry shortly" contract exit 75
400
+ * gives a scheduler to branch on — mirroring `STATE_DB_CONTENDED`'s
401
+ * precedent for state.db (`core/state-db.ts`). Reuses the ONE shared
402
+ * classifier, `isSqliteContentionError`, rather than a second one. An error
403
+ * that is already a classified akm error (e.g. a `STATE_DB_CONTENDED`
404
+ * TransientError from an inner state.db write) is never re-wrapped — only a
405
+ * raw, unclassified error matching the shared contention shape is
406
+ * reclassified. Every other error is rethrown unchanged.
407
+ */
408
+ export function reclassifyIndexDbContention(error) {
409
+ if (error instanceof AkmError || !isSqliteContentionError(error))
410
+ return error;
411
+ const contended = new TransientError(`akm's index database is busy (another akm process is writing it); retry shortly.${describeIndexRebuildLockHolder()}`, "INDEX_DB_CONTENDED");
412
+ contended.cause = error;
413
+ return contended;
414
+ }
325
415
  export async function akmIndex(options) {
326
416
  try {
327
417
  const override = akmIndexOverride;
@@ -338,7 +428,7 @@ export async function akmIndex(options) {
338
428
  // rollback before closing its borrowed unified handle.
339
429
  }
340
430
  }
341
- throw error;
431
+ throw reclassifyIndexDbContention(error);
342
432
  }
343
433
  }
344
434
  let indexTransactionHookForTests;
@@ -513,6 +603,11 @@ async function akmIndexReal(options) {
513
603
  force: full,
514
604
  materialize: options.hydrateSources !== false,
515
605
  secrets: storeSecretResolver,
606
+ // Same progress channel as every other phase (#954) — a
607
+ // stalled clone/fetch here runs BEFORE index.db is even opened, so
608
+ // without this it looked identical to "no database open, nothing
609
+ // written".
610
+ onProgress: (message) => onProgress({ phase: "preflight", message }),
516
611
  });
517
612
  const sourceCacheEnd = Date.now();
518
613
  const allSourceEntries = resolveSourceEntries(stashDir, config);
@@ -553,6 +648,7 @@ async function akmIndexReal(options) {
553
648
  onProgress,
554
649
  signal,
555
650
  t0,
651
+ deferredUpdateTransaction: options.deferredUpdateTransaction,
556
652
  });
557
653
  indexRunContext = ctx;
558
654
  onProgress({
@@ -592,7 +688,7 @@ async function akmIndexReal(options) {
592
688
  }
593
689
  cleanEnd = Date.now();
594
690
  await runEmbeddingPhase(ctx);
595
- await runFinalizePhase(ctx, options.deferredUpdateTransaction);
691
+ await runFinalizePhase(ctx);
596
692
  // ────────────────────────────────────────────────────────────────────────
597
693
  // runFinalizePhase always populates these before returning.
598
694
  const verification = ctx.verification;
@@ -1159,6 +1255,14 @@ function persistDirRecords(db, dirRecords, doFullDelete, warnings, sourceRoots,
1159
1255
  // transaction so delete and re-insert are atomic — a concurrent reader
1160
1256
  // never observes an empty database between the two operations.
1161
1257
  if (fullDelete) {
1258
+ // #955: copy every (search_text hash, embedding) pair about to be
1259
+ // discarded wholesale into `embedding_salvage`, tagged with the
1260
+ // fingerprint the discarded vectors were generated under, BEFORE the
1261
+ // wipe below — inside the SAME transaction so the copy and the
1262
+ // discard commit or roll back together. The embedding phase later in
1263
+ // this run hands salvaged vectors back to unchanged content instead
1264
+ // of re-embedding the whole corpus.
1265
+ salvageEmbeddingsBeforeDiscard(db);
1162
1266
  // Entries and every child materialization share one deletion authority.
1163
1267
  // Usage events live in state.db and survive so finalize can relink them
1164
1268
  // to the replacement generation's row ids.