akm-cli 0.9.14 → 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.
Files changed (120) hide show
  1. package/CHANGELOG.md +559 -0
  2. package/STABILITY.md +6 -3
  3. package/dist/akm +54 -1
  4. package/dist/akm-migrate +34 -1
  5. package/dist/assets/prompts/reflect-feedback-framing.md +1 -0
  6. package/dist/assets/prompts/reflect-llm-framed-contract.md +2 -0
  7. package/dist/assets/prompts/reflect-llm-schema-contract.md +2 -0
  8. package/dist/assets/tasks/core/improve.yml +1 -1
  9. package/dist/assets/tasks/core/index-refresh.yml +1 -1
  10. package/dist/assets/tasks/improve/akm-graph-refresh-weekly.yml +1 -1
  11. package/dist/assets/tasks/improve/akm-improve-catchup.yml +1 -1
  12. package/dist/assets/tasks/improve/akm-improve-consolidate.yml +1 -1
  13. package/dist/assets/tasks/improve/akm-improve-frequent.yml +1 -1
  14. package/dist/assets/tasks/improve/akm-improve-nightly.yml +1 -1
  15. package/dist/cli/retired-commands.js +0 -1
  16. package/dist/cli/shared.js +9 -0
  17. package/dist/cli/unknown-flags.js +1 -0
  18. package/dist/cli.js +40 -3
  19. package/dist/commands/config-cli.js +85 -3
  20. package/dist/commands/env/env-cli.js +1 -42
  21. package/dist/commands/env/env.js +1 -1
  22. package/dist/commands/env/secret-cli.js +1 -2
  23. package/dist/commands/health/checks.js +357 -63
  24. package/dist/commands/health/engine-usage.js +45 -0
  25. package/dist/commands/health/improve-metrics.js +18 -0
  26. package/dist/commands/health/llm-usage.js +41 -1
  27. package/dist/commands/health/plugin-staleness.js +7 -3
  28. package/dist/commands/health/version-drift.js +93 -0
  29. package/dist/commands/health/windows.js +3 -1
  30. package/dist/commands/health.js +44 -9
  31. package/dist/commands/improve/consolidate/chunking.js +4 -2
  32. package/dist/commands/improve/improve-cli.js +99 -5
  33. package/dist/commands/improve/improve-report.js +154 -0
  34. package/dist/commands/improve/improve-result-file.js +45 -33
  35. package/dist/commands/improve/improve-strategies.js +133 -3
  36. package/dist/commands/improve/improve-usage-report.js +182 -0
  37. package/dist/commands/improve/improve.js +40 -3
  38. package/dist/commands/improve/locks.js +28 -78
  39. package/dist/commands/improve/planner.js +1 -0
  40. package/dist/commands/improve/preparation.js +9 -1
  41. package/dist/commands/improve/reflect.js +44 -4
  42. package/dist/commands/models-cli.js +50 -1
  43. package/dist/commands/proposal/repository.js +8 -3
  44. package/dist/commands/proposal/validators/proposal-quality-validators.js +41 -6
  45. package/dist/commands/proposal/validators/proposal-validators.js +24 -0
  46. package/dist/commands/read/search-cli.js +38 -2
  47. package/dist/commands/read/show.js +103 -4
  48. package/dist/commands/sources/info.js +5 -1
  49. package/dist/commands/sources/installed-stashes.js +58 -16
  50. package/dist/commands/sources/self-update.js +2 -2
  51. package/dist/commands/sources/stash-cli.js +48 -0
  52. package/dist/commands/tasks/tasks-cli.js +49 -2
  53. package/dist/commands/workflow-cli.js +86 -12
  54. package/dist/core/asset/markdown-fragments.js +35 -0
  55. package/dist/core/config/config-schema.js +14 -0
  56. package/dist/core/config/config.js +302 -24
  57. package/dist/core/config/schema/embedding.js +41 -0
  58. package/dist/core/env-secret-ref.js +58 -5
  59. package/dist/core/errors.js +30 -0
  60. package/dist/core/file-lock.js +49 -15
  61. package/dist/core/improve-result.js +51 -0
  62. package/dist/core/loopback.js +17 -0
  63. package/dist/core/parent-watchdog.js +64 -0
  64. package/dist/core/paths.js +11 -0
  65. package/dist/core/run-lock.js +107 -0
  66. package/dist/core/sensitive-marker-path.js +19 -0
  67. package/dist/core/state-db.js +74 -14
  68. package/dist/indexer/index-rebuild-lock.js +73 -0
  69. package/dist/indexer/index-writer-lock.js +40 -1
  70. package/dist/indexer/index-written-assets.js +29 -1
  71. package/dist/indexer/indexer.js +93 -29
  72. package/dist/indexer/materialize-embeddings.js +564 -48
  73. package/dist/indexer/search/db-search.js +49 -2
  74. package/dist/indexer/search/search-source.js +23 -1
  75. package/dist/integrations/agent/engine-resolution.js +96 -6
  76. package/dist/integrations/agent/execution-definitions.js +6 -15
  77. package/dist/integrations/agent/execution-lowering.js +6 -1
  78. package/dist/integrations/agent/execution-preparation.js +1 -1
  79. package/dist/integrations/agent/model-map.js +123 -20
  80. package/dist/integrations/agent/prompts.js +40 -8
  81. package/dist/integrations/agent/runner-dispatch.js +9 -3
  82. package/dist/integrations/agent/runner.js +2 -0
  83. package/dist/llm/client.js +8 -3
  84. package/dist/llm/embedder.js +20 -8
  85. package/dist/llm/embedders/local.js +10 -2
  86. package/dist/llm/embedders/remote.js +497 -32
  87. package/dist/output/shapes/helpers.js +38 -2
  88. package/dist/output/shapes/models-list.js +16 -0
  89. package/dist/output/shapes/passthrough.js +2 -0
  90. package/dist/output/shapes.js +4 -0
  91. package/dist/output/text/command-format.js +29 -0
  92. package/dist/output/text/helpers.js +1 -1
  93. package/dist/output/text/improve-report.js +27 -0
  94. package/dist/{commands/env/marker-path.js → output/text/models.js} +4 -3
  95. package/dist/output/text/show-format.js +4 -0
  96. package/dist/output/text.js +4 -0
  97. package/dist/scripts/akm-migrate-node.js +25146 -21759
  98. package/dist/scripts/akm-migrate.js +24271 -20885
  99. package/dist/storage/repositories/embedding-salvage-repository.js +184 -0
  100. package/dist/storage/repositories/improve-runs-repository.js +34 -0
  101. package/dist/storage/repositories/index-fts-repository.js +49 -6
  102. package/dist/storage/repositories/index-schema.js +16 -0
  103. package/dist/storage/repositories/index-vec-repository.js +30 -0
  104. package/dist/storage/repositories/workflow-runs-repository.js +55 -18
  105. package/dist/tasks/backends/cron.js +14 -7
  106. package/dist/tasks/run/run-native-task.js +23 -1
  107. package/dist/tasks/run/run-workflow-task.js +16 -0
  108. package/dist/workflows/exec/child-workflow.js +2 -2
  109. package/dist/workflows/exec/dispatch-redaction.js +21 -9
  110. package/dist/workflows/exec/run-workflow.js +6 -5
  111. package/dist/workflows/runtime/runs.js +33 -5
  112. package/docs/migration/release-notes/0.9.15.md +133 -0
  113. package/docs/migration/release-notes/README.md +5 -0
  114. package/docs/reference/cli.md +271 -30
  115. package/docs/reference/configuration.md +234 -21
  116. package/docs/reference/data-and-telemetry.md +8 -0
  117. package/docs/reference/tasks.md +16 -1
  118. package/docs/reference/workflow-schema.md +5 -1
  119. package/package.json +1 -1
  120. package/schemas/akm-config.json +47 -0
@@ -60,6 +60,22 @@
60
60
  * of events.jsonl is replaced by WAL-mode serialised writes — acceptable because
61
61
  * CLI commands are almost always single-writer.
62
62
  *
63
+ * ## Writer contention (#948)
64
+ *
65
+ * Every open already carries a 30s `busy_timeout` (`sqlite-pragmas.ts`), so a
66
+ * single blocked statement waits before failing. `withImmediateTransaction`
67
+ * additionally retries `BEGIN IMMEDIATE` itself
68
+ * ({@link WITH_IMMEDIATE_TX_MAX_ATTEMPTS} attempts) for the rarer case of two
69
+ * writers racing the BEGIN statement back-to-back. If every attempt is still
70
+ * contention-shaped ({@link isSqliteContentionError} — SQLITE_BUSY/LOCKED, the
71
+ * matching message text, or the phantom-BEGIN marker), the exhaustion throw is
72
+ * reclassified into `TransientError("STATE_DB_CONTENDED")` (exit 75, #948
73
+ * addendum) instead of surfacing the raw driver text: another akm process (an
74
+ * unrelated `improve`, `workflow run`, or task run — not necessarily
75
+ * contending for the same row) is writing state.db right now. A genuinely
76
+ * unrelated error (real corruption, a body-thrown failure) is never
77
+ * reclassified and rethrows exactly as raised.
78
+ *
63
79
  * @module state-db
64
80
  */
65
81
  import { randomUUID } from "node:crypto";
@@ -70,6 +86,7 @@ import { openDatabase } from "../storage/database.js";
70
86
  import { assertMigrationLedger } from "../storage/engines/sqlite-migrations.js";
71
87
  import { openManagedDatabase, withManagedDb } from "../storage/managed-db.js";
72
88
  import { pkgVersion } from "../version.js";
89
+ import { TransientError } from "./errors.js";
73
90
  import { acquireMaintenanceActivitySync } from "./maintenance-barrier.js";
74
91
  import { getDataDir } from "./paths.js";
75
92
  import { runMigrations, STATE_MIGRATIONS } from "./state/migrations.js";
@@ -621,20 +638,30 @@ export function withStateDbTelemetry(fn, busyTimeoutMs = 250) {
621
638
  * the live queue state rather than clobbering each other.
622
639
  */
623
640
  /**
624
- * Errors `BEGIN IMMEDIATE` can throw under concurrent-writer contention that are
625
- * transient (the statement did NOT start a usable transaction) and safe to
626
- * retry:
627
- * - "database is locked" / SQLITE_BUSY another writer holds the lock.
628
- * These are start-of-transaction failures only; an error thrown by `fn` is a
629
- * real failure and is NEVER retried.
641
+ * Whether `err` is one of the SQLite conditions a concurrent-writer race can
642
+ * throw that are transient the statement did NOT corrupt anything, another
643
+ * writer just holds the lock right now — and therefore safe to retry or
644
+ * reclassify as ordinary contention rather than a genuine failure:
645
+ * - `SQLITE_BUSY` / `SQLITE_LOCKED` (either driver's `.code`).
646
+ * - "database is locked" / "database table is locked" message text.
647
+ * - the phantom-BEGIN marker synthesized below when `BEGIN IMMEDIATE`
648
+ * returns without actually opening a transaction.
630
649
  *
631
- * "cannot start a transaction within a transaction" is deliberately NOT
632
- * retryable: it means a transaction is already open on this connection (a
633
- * re-entrant call handled by the entry guard in withImmediateTransaction),
634
- * and "retrying" it with a ROLLBACK would destroy the caller's transaction
635
- * (issue #686).
650
+ * This is the single shared classifier for "is this ordinary state.db
651
+ * contention" (#948) `isRetryableBeginError` below delegates to it, and so
652
+ * does {@link WorkflowRunsRepository}'s lease-contention classifier (which
653
+ * additionally matches a couple of corruption-shaped texts specific to its
654
+ * own narrower cross-process race and keeps its own live-lease confirmation
655
+ * before reclassifying). Matching this set alone is never sufficient to
656
+ * declare something DEFINITELY contention when a caller needs corroborating
657
+ * evidence (see the lease path); for `beginImmediateTransaction`'s own
658
+ * exhaustion case there is no such evidence available, so the five 30s
659
+ * `busy_timeout` waits already spent stand as the evidence instead.
636
660
  */
637
- function isRetryableBeginError(err) {
661
+ export function isSqliteContentionError(err) {
662
+ const code = err?.code;
663
+ if (code === "SQLITE_BUSY" || code === "SQLITE_LOCKED")
664
+ return true;
638
665
  const msg = (err instanceof Error ? err.message : String(err)).toLowerCase();
639
666
  return (msg.includes("database is locked") ||
640
667
  msg.includes("database table is locked") ||
@@ -642,6 +669,20 @@ function isRetryableBeginError(err) {
642
669
  // without opening a transaction. Safe to retry: fn() has not run.
643
670
  msg.includes("did not open a transaction"));
644
671
  }
672
+ /**
673
+ * Errors `BEGIN IMMEDIATE` can throw under concurrent-writer contention that
674
+ * are transient (the statement did NOT start a usable transaction) and safe
675
+ * to retry. An error thrown by `fn` is a real failure and is NEVER retried.
676
+ *
677
+ * "cannot start a transaction within a transaction" is deliberately NOT
678
+ * retryable: it means a transaction is already open on this connection (a
679
+ * re-entrant call — handled by the entry guard in withImmediateTransaction),
680
+ * and "retrying" it with a ROLLBACK would destroy the caller's transaction
681
+ * (issue #686).
682
+ */
683
+ function isRetryableBeginError(err) {
684
+ return isSqliteContentionError(err);
685
+ }
645
686
  const WITH_IMMEDIATE_TX_MAX_ATTEMPTS = 5;
646
687
  /** Portable synchronous sleep (works under both Bun and Node). Delegates to the runtime boundary's `sleepSync`. */
647
688
  function sleepSyncMs(ms) {
@@ -658,6 +699,25 @@ function sleepSyncMs(ms) {
658
699
  * publication have all succeeded. The caller that asked for this split phase
659
700
  * owns the matching COMMIT/ROLLBACK.
660
701
  */
702
+ /**
703
+ * Reclassify an exhausted-retry BEGIN failure that is still contention-shaped
704
+ * (#948) into a `TransientError("STATE_DB_CONTENDED")`, mirroring the
705
+ * RUN_LEASE_HELD precedent (`WorkflowRunsRepository.acquireEngineLease`): the
706
+ * driver text is accurate but unhelpful (`{"ok":false,"error":"database is
707
+ * locked"}`, exit 70/INTERNAL) — this instead reads as a retryable-shortly
708
+ * signal (exit 75, sysexits EX_TEMPFAIL — #948 addendum) with the original
709
+ * error preserved as `cause` for `--verbose`/debugging. A genuinely unrelated
710
+ * error (not contention-shaped) is rethrown exactly as raised, never
711
+ * reclassified.
712
+ */
713
+ function throwBeginFailure(err) {
714
+ if (isSqliteContentionError(err)) {
715
+ const contended = new TransientError("akm's state database is busy (another akm process is writing it); retry shortly.", "STATE_DB_CONTENDED");
716
+ contended.cause = err;
717
+ throw contended;
718
+ }
719
+ throw err;
720
+ }
661
721
  export function beginImmediateTransaction(db) {
662
722
  if (db.inTransaction) {
663
723
  throw new Error("beginImmediateTransaction requires a connection with no active transaction");
@@ -685,10 +745,10 @@ export function beginImmediateTransaction(db) {
685
745
  sleepSyncMs(2 ** (attempt - 1));
686
746
  continue;
687
747
  }
688
- throw err;
748
+ throwBeginFailure(err);
689
749
  }
690
750
  }
691
- throw lastBeginErr;
751
+ throwBeginFailure(lastBeginErr);
692
752
  }
693
753
  export function withImmediateTransaction(db, fn) {
694
754
  // Re-entrancy guard (issue #686): if a transaction is already open on this
@@ -0,0 +1,73 @@
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
+ * Opt-in, non-blocking rebuild lock for `akm index` (#956).
6
+ *
7
+ * #872 removed the blocking index-rebuild lease: the index is a regenerable
8
+ * cache, so a concurrent rebuild only wastes work rather than corrupts
9
+ * anything, and a live-but-wedged holder passed a PID-liveness check forever
10
+ * — only an age-based clock could ever free it, which is exactly the hazard
11
+ * #872 deleted. This module does not reinstate that lock. It adds a
12
+ * PID-liveness-only sentinel an explicit `akm index` run acquires and
13
+ * releases on exit purely so a *scheduled or opportunistic* run
14
+ * (`--skip-if-locked`) can step aside instead of piling up behind a rebuild
15
+ * already in progress. A human-typed `akm index` with no flag is never
16
+ * gated: it warns and proceeds exactly as it did before this lock existed.
17
+ *
18
+ * Built on the shared PID-liveness mechanics in `core/run-lock.ts` (the same
19
+ * ones `akm improve`'s whole-run lock uses) — see that module's doc for the
20
+ * no-stale-age-window rationale.
21
+ */
22
+ import { releaseLock } from "../core/file-lock.js";
23
+ import { tryWithMaintenanceStartBarrier, withMaintenanceStartBarrier } from "../core/maintenance-barrier.js";
24
+ import { getIndexRebuildLockPath } from "../core/paths.js";
25
+ import { formatLockHolderPid, tryAcquireRunLock } from "../core/run-lock.js";
26
+ import { warn, warnVerbose } from "../core/warn.js";
27
+ export function indexRebuildLockPath() {
28
+ return getIndexRebuildLockPath();
29
+ }
30
+ /**
31
+ * Acquire the rebuild lock for the duration of one `akm index` run.
32
+ *
33
+ * - Free: always returns `"acquired"`.
34
+ * - Held, `skipIfLocked`: warns once (naming the holder) and returns
35
+ * `"skipped"` — the caller must not run `akmIndex()` at all.
36
+ * - Held, no flag: warns once and returns `"contended"` — the caller runs
37
+ * `akmIndex()` unlocked, exactly as every `akm index` did before #956.
38
+ *
39
+ * A dead holder's lease is reclaimed silently (verbose-only log line, never
40
+ * a user-facing warning) — the operator did nothing wrong and nothing here
41
+ * requires their attention.
42
+ */
43
+ export function tryAcquireIndexRebuildLock(skipIfLocked) {
44
+ const lockPath = indexRebuildLockPath();
45
+ const acquire = () => tryAcquireRunLock(lockPath, {
46
+ label: "index rebuild",
47
+ onReclaimed: (info) => {
48
+ warnVerbose(`[index] reclaimed a rebuild lock left by pid ${info.holderPid ?? "unknown"} ` +
49
+ `(${info.reason}); that process is no longer running.`);
50
+ },
51
+ });
52
+ if (skipIfLocked) {
53
+ const result = tryWithMaintenanceStartBarrier(acquire);
54
+ if (!result) {
55
+ warn("[index] maintenance barrier held; skipping (--skip-if-locked)");
56
+ return { state: "skipped", holder: { pid: null, startedAt: null, launcherPid: null } };
57
+ }
58
+ if (result.state === "acquired")
59
+ return result;
60
+ warn(`[index] another index run holds the lock (PID ${formatLockHolderPid(result.holder)}, started ${result.holder.startedAt}); ` +
61
+ "skipping (--skip-if-locked)");
62
+ return { state: "skipped", holder: result.holder };
63
+ }
64
+ const result = withMaintenanceStartBarrier(acquire);
65
+ if (result.state === "acquired")
66
+ return result;
67
+ warn(`[index] another index run is active (pid ${formatLockHolderPid(result.holder)}, started ${result.holder.startedAt}); ` +
68
+ "this run will contend with it — pass --skip-if-locked for scheduled runs");
69
+ return { state: "contended", holder: result.holder };
70
+ }
71
+ export function releaseIndexRebuildLock(ownership) {
72
+ releaseLock(ownership);
73
+ }
@@ -28,9 +28,17 @@ import path from "node:path";
28
28
  import { createLockPayload, probeLock, reclaimStaleLock, releaseLock, tryAcquireLockSync, } from "../core/file-lock.js";
29
29
  import { tryAcquireMaintenanceBarrier } from "../core/maintenance-barrier.js";
30
30
  import { getDbPath, getIndexWriterLockPath } from "../core/paths.js";
31
+ import { warn } from "../core/warn.js";
31
32
  import { sleepSync } from "../runtime.js";
32
33
  const ASSET_MUTATION_WAIT_MS = 100;
33
34
  const DEFAULT_ASSET_MUTATION_MAX_WAIT_MS = 10 * 60 * 1000;
35
+ /** How often a blocked sync waiter re-announces that it is still waiting. Mirrors the async path's cadence. */
36
+ const ASSET_MUTATION_WAIT_NOTICE_INTERVAL_MS = 15_000;
37
+ /** TEST-ONLY. Shrinks the sync path's max-wait/notice-cadence so a test doesn't block for real minutes/seconds. */
38
+ let syncTimingOverridesForTests;
39
+ export function _setAssetMutationLeaseSyncTimingForTests(overrides) {
40
+ syncTimingOverridesForTests = overrides;
41
+ }
34
42
  const leaseContext = new AsyncLocalStorage();
35
43
  function buildPayload(purpose) {
36
44
  return createLockPayload({
@@ -42,6 +50,26 @@ function buildPayload(purpose) {
42
50
  function delay(ms) {
43
51
  return new Promise((resolve) => setTimeout(resolve, ms));
44
52
  }
53
+ /**
54
+ * Describe who currently holds `lockPath`, from `probeLock`'s own payload
55
+ * (the same `purpose`/`pid`/`startedAt` fields `buildPayload` writes) —
56
+ * never the waiting caller's own `purpose`, which names what SELF is trying
57
+ * to do, not who is in the way.
58
+ */
59
+ function describeAssetMutationHolder(lockPath) {
60
+ const probe = probeLock(lockPath);
61
+ const rawContent = probe.state === "held" || probe.state === "stale" ? probe.rawContent : undefined;
62
+ if (!rawContent)
63
+ return "an asset write";
64
+ try {
65
+ const payload = JSON.parse(rawContent);
66
+ const who = payload.purpose ?? "an asset write";
67
+ return `${who} (pid ${payload.pid ?? "?"}, started ${payload.startedAt ?? "?"})`;
68
+ }
69
+ catch {
70
+ return "an asset write";
71
+ }
72
+ }
45
73
  function throwIfAborted(signal) {
46
74
  if (!signal?.aborted)
47
75
  return;
@@ -142,14 +170,25 @@ export function withAssetMutationLeaseSync(purpose, run) {
142
170
  const context = inherited ?? new Set();
143
171
  const execute = () => {
144
172
  const startedAt = Date.now();
173
+ const maxWaitMs = syncTimingOverridesForTests?.maxWaitMs ?? DEFAULT_ASSET_MUTATION_MAX_WAIT_MS;
174
+ const waitNoticeIntervalMs = syncTimingOverridesForTests?.waitNoticeIntervalMs ?? ASSET_MUTATION_WAIT_NOTICE_INTERVAL_MS;
145
175
  fs.mkdirSync(path.dirname(lockPath), { recursive: true });
146
176
  let lease;
177
+ let lastWaitNoticeMs = 0;
147
178
  while (!lease) {
148
179
  lease = tryAcquireAssetMutationLease(lockPath, purpose);
149
180
  if (!lease) {
150
- if (Date.now() - startedAt >= DEFAULT_ASSET_MUTATION_MAX_WAIT_MS) {
181
+ const waitedMs = Date.now() - startedAt;
182
+ if (waitedMs >= maxWaitMs) {
151
183
  throw new Error(`timed out waiting for asset mutation lease for ${purpose}`);
152
184
  }
185
+ // #956: the sync path used to wait up to 10 minutes with zero
186
+ // progress feedback. Mirror the async path's 15s onWait cadence, but
187
+ // actually warn by default — no caller wires onWait on this path.
188
+ if (waitedMs - lastWaitNoticeMs >= waitNoticeIntervalMs) {
189
+ warn(`waiting for ${describeAssetMutationHolder(lockPath)} — ${Math.round(waitedMs / 1000)}s`);
190
+ lastWaitNoticeMs = waitedMs;
191
+ }
153
192
  sleepSync(ASSET_MUTATION_WAIT_MS);
154
193
  }
155
194
  }
@@ -24,8 +24,10 @@ import path from "node:path";
24
24
  import { akmAdapter } from "../core/adapter/adapters/akm-adapter.js";
25
25
  import { loadConfig } from "../core/config/config.js";
26
26
  import { isDataDirUnreadableError } from "../core/errors.js";
27
+ import { probeLock } from "../core/file-lock.js";
27
28
  import { isPathAbsent } from "../core/path-access.js";
28
- import { getDbPath } from "../core/paths.js";
29
+ import { getDbPath, getIndexRebuildLockPath } from "../core/paths.js";
30
+ import { formatLockHolderPid } from "../core/run-lock.js";
29
31
  import { warn, warnVerbose } from "../core/warn.js";
30
32
  import { closeDatabase, openExistingDatabase } from "../storage/repositories/index-connection.js";
31
33
  import { deleteEntriesByIds, getEntryCount, upsertEntry } from "../storage/repositories/index-entries-repository.js";
@@ -54,10 +56,36 @@ export const WRITE_PATH_INDEX_BUSY_TIMEOUT_MS = 5_000;
54
56
  * An absent or empty index is skipped on purpose — bootstrap belongs to the
55
57
  * first read (`ensureIndex`) or an explicit `akm index`, which also cover
56
58
  * embeddings and the other passes this fast path skips.
59
+ *
60
+ * A live rebuild holding the rebuild lock is the SAME kind of skip, not a
61
+ * failure (#956 fix): a live `akm index` run owns bringing the index to the
62
+ * expected state on its own, so this call returns `true` on that skip
63
+ * exactly like the absent-index and empty-index cases above. Callers that
64
+ * gate their own success on this boolean (`acceptProposal`, `source clone`)
65
+ * must never fail or warn just because a concurrent rebuild is in progress.
57
66
  */
58
67
  export async function indexWrittenAssets(stashDir, filePaths, options = {}) {
59
68
  try {
60
69
  return await (async () => {
70
+ // #956: a live `akm index` rebuild holds index.db under one long
71
+ // transaction (persistDirRecords), which this fast path's own 5s
72
+ // busy_timeout would just contend with pointlessly. Interactive
73
+ // commands (remember, import, extract session assets) must never wait
74
+ // on it — skip the inline upsert/embedding entirely; the write itself
75
+ // (file + commit) has already succeeded by the time this runs, and the
76
+ // rebuild in progress will pick up the change on its own.
77
+ const rebuildProbe = probeLock(getIndexRebuildLockPath());
78
+ if (rebuildProbe.state === "held") {
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(", ")}`);
87
+ return true;
88
+ }
61
89
  const dbPath = getDbPath();
62
90
  // `true` here means "the index is in the state the caller expects" — and
63
91
  // `acceptProposal` advances its journal to `index-finalized` on the
@@ -8,7 +8,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
10
  import { ConfigError } from "../core/errors.js";
11
- import { isLoopbackEndpoint } from "../core/loopback.js";
11
+ import { defaultConcurrencyForEndpoint } from "../core/loopback.js";
12
12
  import { classifyPathAccess, describeInaccessiblePath } from "../core/path-access.js";
13
13
  import { getDbPath } from "../core/paths.js";
14
14
  import { SCRIPT_EXTENSIONS } from "../core/recognition-util.js";
@@ -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";
@@ -54,20 +55,17 @@ function throwIfAborted(signal) {
54
55
  export function getDefaultLlmConcurrency(llmConfig) {
55
56
  if (typeof llmConfig?.concurrency === "number")
56
57
  return llmConfig.concurrency;
57
- // Local model servers stay at 1 (single loaded model; parallel requests
58
- // trigger reload thrash); an absent or unparseable endpoint fails safe as
59
- // local. ONE classifier decides what "local" means (`core/loopback.ts`,
60
- // shared with the workflow engine's frozen concurrency default).
61
- if (isLoopbackEndpoint(llmConfig?.endpoint))
62
- return 1;
63
- // Remote endpoints default to a modest 2-wide pool (owner ruling 2026-07-21):
64
- // enough to overlap request latency without hammering rate-limited APIs.
65
- // The explicit-override branch above only fires for
66
- // callers that put `concurrency` on the connection themselves —
67
- // `engines.<name>.concurrency` is a valid schema field but `resolveLlmEngineUse`
68
- // does NOT copy it into the resolved connection, so on the enrichment path the
69
- // auto-derived 1/2 is what runs (see docs/architecture/internals/indexing.md).
70
- return 2;
58
+ // ONE classifier decides the local-vs-remote default (`core/loopback.ts`'s
59
+ // `defaultConcurrencyForEndpoint`), shared with the embedding pool
60
+ // (`resolveEmbeddingConcurrency`, `src/llm/embedders/remote.ts`) and the
61
+ // workflow engine's frozen concurrency default.
62
+ //
63
+ // The explicit-override branch above only fires for callers that put
64
+ // `concurrency` on the connection themselves `engines.<name>.concurrency`
65
+ // is a valid schema field but `resolveLlmEngineUse` does NOT copy it into
66
+ // the resolved connection, so on the enrichment path the auto-derived 1/2
67
+ // is what runs (see docs/architecture/internals/indexing.md).
68
+ return defaultConcurrencyForEndpoint(llmConfig?.endpoint);
71
69
  }
72
70
  function sourceOwners(sources) {
73
71
  const installations = deriveInstallations([...sources]);
@@ -187,19 +185,54 @@ async function runWalkPhase(ctx) {
187
185
  });
188
186
  ctx.timing.tLlmEnd = Date.now();
189
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
+ }
190
210
  /**
191
211
  * Embedding phase: generate and store vector embeddings for all unembedded
192
- * entries. Writes `ctx.embeddingResult` for the finalize phase.
212
+ * entries. Writes `ctx.embeddingResult` and `ctx.verification` for the
213
+ * finalize phase / caller.
193
214
  */
194
215
  async function runEmbeddingPhase(ctx) {
195
- const { db, config, signal, onProgress } = ctx;
216
+ const { db, config, signal, onProgress, reembed, deferredUpdateTransaction } = ctx;
196
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
+ }
197
228
  // Forward the signal. Without it generateEmbeddingsForDb's abort machinery was
198
229
  // inert — its throwIfAborted checks and the signal it threads into embedBatch
199
230
  // (which RemoteEmbedder passes to every fetch and LocalEmbedder honours between
200
231
  // chunks) never saw a controller. Ctrl-C and the improve budget abort could not
201
232
  // stop the embedding phase, the longest phase of an index run.
202
- ctx.embeddingResult = await generateEmbeddingsForDb(db, config, onProgress, signal);
233
+ const { embeddingResult, verification } = await runEmbeddingPass({ db, config, onProgress, signal, reembed });
234
+ ctx.embeddingResult = embeddingResult;
235
+ ctx.verification = verification;
203
236
  ctx.timing.tEmbedEnd = Date.now();
204
237
  }
205
238
  /**
@@ -207,8 +240,8 @@ async function runEmbeddingPhase(ctx) {
207
240
  * usage events, recompute utility scores, update index metadata, and emit the
208
241
  * verify event.
209
242
  */
210
- async function runFinalizePhase(ctx, deferredUpdateTransaction) {
211
- 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;
212
245
  ctx.timing.tFinalizeStart = Date.now();
213
246
  // `upsertEntry` and every canonical delete own their FTS projection. This is
214
247
  // an observation point, not a second materialization pass.
@@ -250,22 +283,37 @@ async function runFinalizePhase(ctx, deferredUpdateTransaction) {
250
283
  // An incomplete run preserves the prior freshness watermark. Advancing it
251
284
  // could make a recovered source look unchanged even though this run never
252
285
  // persisted its files.
253
- const embeddingResult = ctx.embeddingResult ?? { success: false };
254
286
  if (ctx.scanComplete) {
255
287
  setMeta(db, "builtAt", new Date().toISOString());
256
288
  setMeta(db, "stashDir", stashDir);
257
289
  setMeta(db, "stashDirs", JSON.stringify(sourceDirs));
258
290
  setMeta(db, "sourceOwners", JSON.stringify(sourceOwners(sources)));
259
291
  }
260
- setMeta(db, "hasEmbeddings", embeddingResult.success ? "1" : "0");
261
292
  warnIfVecMissing(db);
262
293
  const totalEntries = getEntryCount(db);
263
- const semanticEntryCount = getEmbeddableEntryCount(db);
264
- onProgress({ phase: "finalize", message: "Verifying semantic search state." });
265
- const verification = verifyIndexState(db, config, semanticEntryCount, embeddingResult);
266
- onProgress({ phase: "verify", message: verification.message });
267
- // Store verification result and totalEntries on ctx for the caller to use
268
- 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).
269
317
  ctx.totalEntries = totalEntries;
270
318
  ctx.timing.tFinalizeEnd = Date.now();
271
319
  // suppress unused warning — sources was previously used inline
@@ -489,6 +537,7 @@ async function akmIndexReal(options) {
489
537
  const full = options?.full === true;
490
538
  const clean = options?.clean === true;
491
539
  const dryRun = options?.dryRun === true;
540
+ const reembed = options?.reembed === true;
492
541
  // Load config and resolve all stash sources
493
542
  const { loadConfig, mutateConfig } = await import("../core/config/config.js");
494
543
  let config = loadConfig();
@@ -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);
@@ -548,10 +602,12 @@ async function akmIndexReal(options) {
548
602
  sourceDirs: allSourceDirs,
549
603
  full,
550
604
  clean,
605
+ reembed,
551
606
  stashDir,
552
607
  onProgress,
553
608
  signal,
554
609
  t0,
610
+ deferredUpdateTransaction: options.deferredUpdateTransaction,
555
611
  });
556
612
  indexRunContext = ctx;
557
613
  onProgress({
@@ -591,7 +647,7 @@ async function akmIndexReal(options) {
591
647
  }
592
648
  cleanEnd = Date.now();
593
649
  await runEmbeddingPhase(ctx);
594
- await runFinalizePhase(ctx, options.deferredUpdateTransaction);
650
+ await runFinalizePhase(ctx);
595
651
  // ────────────────────────────────────────────────────────────────────────
596
652
  // runFinalizePhase always populates these before returning.
597
653
  const verification = ctx.verification;
@@ -1158,6 +1214,14 @@ function persistDirRecords(db, dirRecords, doFullDelete, warnings, sourceRoots,
1158
1214
  // transaction so delete and re-insert are atomic — a concurrent reader
1159
1215
  // never observes an empty database between the two operations.
1160
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);
1161
1225
  // Entries and every child materialization share one deletion authority.
1162
1226
  // Usage events live in state.db and survive so finalize can relink them
1163
1227
  // to the replacement generation's row ids.