akm-cli 0.9.16-alpha.1 → 0.9.16

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 (147) hide show
  1. package/CHANGELOG.md +56 -132
  2. package/dist/assets/hints/cli-hints-full.md +13 -6
  3. package/dist/assets/tasks/core/index-refresh.yml +1 -1
  4. package/dist/assets/tasks/improve/akm-improve-catchup.yml +3 -6
  5. package/dist/cli/retired-commands.js +0 -4
  6. package/dist/cli/unknown-flags.js +3 -36
  7. package/dist/commands/env/env-binding.js +4 -4
  8. package/dist/commands/env/env-cli.js +3 -3
  9. package/dist/commands/improve/collapse-detector.js +2 -2
  10. package/dist/commands/improve/consolidate.js +4 -6
  11. package/dist/commands/improve/improve-cli.js +20 -15
  12. package/dist/commands/improve/reflect.js +23 -2
  13. package/dist/commands/lint/base-linter.js +9 -0
  14. package/dist/commands/lint/env-key-rules.js +2 -2
  15. package/dist/commands/proposal/propose.js +15 -1
  16. package/dist/commands/proposal/repository.js +3 -12
  17. package/dist/commands/proposal/validators/proposal-quality-validators.js +40 -3
  18. package/dist/commands/proposal/validators/proposal-validators.js +5 -4
  19. package/dist/commands/read/curate.js +44 -34
  20. package/dist/commands/read/search.js +35 -54
  21. package/dist/commands/read/show.js +21 -2
  22. package/dist/commands/registry-cli.js +5 -5
  23. package/dist/commands/sources/add-cli.js +59 -16
  24. package/dist/commands/sources/bundle-cli.js +35 -11
  25. package/dist/commands/sources/bundle-config-ops.js +30 -0
  26. package/dist/commands/sources/dangerous-env-audit.js +4 -4
  27. package/dist/commands/sources/info.js +8 -8
  28. package/dist/commands/sources/installed-stashes.js +55 -61
  29. package/dist/commands/sources/source-add.js +39 -38
  30. package/dist/commands/sources/source-manage.js +34 -12
  31. package/dist/commands/sources/stash-cli.js +111 -119
  32. package/dist/commands/sources/stash-skeleton.js +6 -3
  33. package/dist/commands/tasks/explain.js +4 -1
  34. package/dist/commands/tasks/tasks-cli.js +31 -9
  35. package/dist/commands/tasks/tasks.js +239 -194
  36. package/dist/commands/tasks/validate.js +20 -32
  37. package/dist/core/activation-policy.js +4 -4
  38. package/dist/core/adapter/adapters/akm-adapter.js +8 -35
  39. package/dist/core/adapter/adapters/akm-metadata.js +1 -11
  40. package/dist/core/adapter/execution-source.js +10 -29
  41. package/dist/core/asset/asset-placement.js +0 -35
  42. package/dist/core/config/config-schema.js +64 -8
  43. package/dist/core/config/config-sources.js +96 -2
  44. package/dist/core/config/config.js +190 -24
  45. package/dist/core/config/legacy-source-shape-shim.js +9 -0
  46. package/dist/core/config/schema/embedding.js +30 -7
  47. package/dist/core/config/schema/execution.js +23 -0
  48. package/dist/core/config/schema/experimental.js +1 -1
  49. package/dist/core/config/schema/scheduler.js +20 -0
  50. package/dist/core/config/schema/search.js +10 -12
  51. package/dist/core/config/schema/sources-bundles.js +32 -1
  52. package/dist/core/content-safety.js +52 -0
  53. package/dist/core/errors.js +2 -5
  54. package/dist/core/maintenance-barrier.js +11 -13
  55. package/dist/core/paths.js +11 -0
  56. package/dist/core/run-lock.js +2 -5
  57. package/dist/core/state/migrations.js +1 -26
  58. package/dist/core/state-db.js +27 -63
  59. package/dist/core/type-presentation.js +1 -1
  60. package/dist/core/write-source.js +13 -8
  61. package/dist/indexer/bundle-identity-guard.js +45 -8
  62. package/dist/indexer/ensure-index.js +0 -5
  63. package/dist/indexer/index-db-contention.js +56 -0
  64. package/dist/indexer/index-rebuild-lock.js +73 -0
  65. package/dist/indexer/index-written-assets.js +171 -133
  66. package/dist/indexer/indexer.js +1621 -458
  67. package/dist/indexer/lookup/adapter-concept-owner.js +5 -19
  68. package/dist/indexer/materialize-embeddings.js +785 -0
  69. package/dist/indexer/passes/dir-staleness.js +161 -0
  70. package/dist/indexer/passes/metadata.js +1 -18
  71. package/dist/indexer/scan/drain-dir.js +70 -27
  72. package/dist/indexer/search/db-search.js +89 -373
  73. package/dist/indexer/search/ranking-contributors.js +16 -21
  74. package/dist/indexer/search/ranking.js +57 -135
  75. package/dist/indexer/search/search-source.js +29 -11
  76. package/dist/integrations/agent/execution-lowering.js +3 -2
  77. package/dist/integrations/agent/execution-preparation.js +32 -1
  78. package/dist/integrations/agent/prompts.js +1 -1
  79. package/dist/integrations/agent/request-lowering.js +3 -2
  80. package/dist/llm/client.js +3 -11
  81. package/dist/llm/embedder.js +3 -10
  82. package/dist/llm/embedders/remote.js +104 -133
  83. package/dist/llm/feature-gate.js +2 -4
  84. package/dist/llm/rerank-client.js +3 -3
  85. package/dist/output/html-render.js +2 -1
  86. package/dist/output/shapes/passthrough.js +2 -1
  87. package/dist/output/stdout.js +24 -0
  88. package/dist/output/text/command-format.js +13 -19
  89. package/dist/output/text/helpers.js +1 -1
  90. package/dist/output/text/index.js +2 -5
  91. package/dist/output/text.js +4 -3
  92. package/dist/registry/resolve.js +37 -10
  93. package/dist/scripts/akm-migrate-node.js +15197 -11351
  94. package/dist/scripts/akm-migrate.js +15514 -11668
  95. package/dist/setup/semantic-assets.js +2 -2
  96. package/dist/setup/setup.js +3 -3
  97. package/dist/setup/steps/connection.js +2 -3
  98. package/dist/setup/steps/tasks.js +29 -36
  99. package/dist/sources/providers/git-install.js +17 -11
  100. package/dist/sources/providers/git-provider.js +12 -5
  101. package/dist/sources/providers/git-stash.js +38 -16
  102. package/dist/sources/snapshot-fetchers/website-ingest.js +3 -3
  103. package/dist/storage/repositories/embedding-salvage-repository.js +184 -0
  104. package/dist/storage/repositories/index-connection.js +3 -1
  105. package/dist/storage/repositories/index-entries-repository.js +68 -77
  106. package/dist/storage/repositories/index-entry-schema.js +25 -16
  107. package/dist/storage/repositories/index-fts-repository.js +263 -29
  108. package/dist/storage/repositories/index-meta-repository.js +29 -0
  109. package/dist/storage/repositories/index-schema.js +122 -115
  110. package/dist/storage/repositories/index-utility-repository.js +1 -1
  111. package/dist/storage/repositories/index-vec-repository.js +435 -22
  112. package/dist/tasks/activation-config.js +90 -0
  113. package/dist/tasks/backends/cron.js +9 -0
  114. package/dist/tasks/backends/launchd.js +1 -0
  115. package/dist/tasks/backends/schtasks.js +2 -0
  116. package/dist/tasks/embedded.js +4 -5
  117. package/dist/tasks/scheduler-binding.js +2 -2
  118. package/dist/tasks/scheduler-sync-preview.js +8 -1
  119. package/dist/tasks/scheduler-sync.js +19 -10
  120. package/dist/tasks/source/parse-task-source.js +10 -113
  121. package/dist/tasks/source/project-v4.js +2 -2
  122. package/dist/tasks/source/task-source-v4.js +4 -12
  123. package/dist/tasks/source/task-to-v3.js +4 -12
  124. package/dist/tasks/source/task-to-v4.js +40 -7
  125. package/docs/migration/README.md +1 -0
  126. package/docs/migration/release-notes/0.9.15.md +36 -34
  127. package/docs/migration/release-notes/0.9.16.md +60 -98
  128. package/docs/migration/release-notes/README.md +0 -5
  129. package/docs/migration/v0.9.1-to-v0.9.2.md +6 -9
  130. package/docs/reference/cli.md +124 -122
  131. package/docs/reference/configuration.md +137 -133
  132. package/docs/reference/data-and-telemetry.md +1 -2
  133. package/docs/reference/tasks.md +34 -29
  134. package/package.json +1 -1
  135. package/schemas/akm-config.json +170 -6
  136. package/schemas/akm-task.json +1 -2
  137. package/dist/commands/sources/index-status.js +0 -99
  138. package/dist/core/hash.js +0 -18
  139. package/dist/indexer/drain.js +0 -306
  140. package/dist/indexer/embedding-identity.js +0 -20
  141. package/dist/indexer/enrich.js +0 -260
  142. package/dist/indexer/reconcile.js +0 -890
  143. package/dist/indexer/scan/parse-file.js +0 -66
  144. package/dist/indexer/units/unit.js +0 -159
  145. package/dist/llm/embedders/provider-limits.js +0 -288
  146. package/dist/storage/repositories/files-repository.js +0 -181
  147. package/dist/storage/repositories/units-repository.js +0 -510
@@ -18,7 +18,6 @@ const CONFIG_HINTS = {
18
18
  UNKNOWN_IMPROVE_STRATEGY: "Pass one of the listed strategy names to `--strategy`, or define it under `improve.strategies`. Names are case-sensitive.",
19
19
  EXECUTION_NOT_AUTHORIZED: "Change the selected tools or update the machine/user execution policy, then retry.",
20
20
  SECRET_REFERENCE_UNRESOLVED: "Check the secret exists (`akm secret list`) and the name after `secret://` matches, or run `akm secret set <name> <value>` to store it.",
21
- EMBEDDING_VEC_UNAVAILABLE: "Install sqlite-vec for unit-level semantic search, or rely on lexical search until it is available.",
22
21
  };
23
22
  // Code-review finding: COMPOSITION_INVALID covers several unrelated causes
24
23
  // (a rejected with:, a multi-job source, a composition cycle/depth/size
@@ -83,10 +82,8 @@ const USAGE_HINTS = {
83
82
  const TRANSIENT_HINTS = {
84
83
  RUN_LEASE_HELD: "Wait for the named engine invocation to finish or for the lease to expire, then retry. `akm workflow status <id>` shows the current lease.",
85
84
  STATE_DB_CONTENDED: "Another akm process is writing state.db right now. Wait a few seconds and retry; commands that support --skip-if-locked can skip instead of failing.",
86
- INDEX_DB_CONTENDED: "Another akm process is writing index.db right now. Wait a few seconds and retry — index runs take no rebuild " +
87
- "lock, so this clears quickly; a scheduled run left alone will simply run again next time.",
88
- MAINTENANCE_BARRIER_BUSY: "Another akm process is registering a lock or lease right now. Retry shortly, or pass --skip-if-locked on " +
89
- "scheduled improve runs to skip gracefully instead — workflow run does not treat this code as skippable.",
85
+ INDEX_DB_CONTENDED: "Another akm process is writing index.db; retry shortly, or pass --skip-if-locked on scheduled runs.",
86
+ MAINTENANCE_BARRIER_BUSY: "Another akm process is registering a lock or lease right now. Retry shortly, or pass --skip-if-locked on scheduled index/improve/workflow runs.",
90
87
  IMPROVE_LOCK_HELD: "Another akm improve run holds the whole-run lock right now. Wait for it to finish and retry, or pass --skip-if-locked on scheduled runs.",
91
88
  };
92
89
  /** Default hint for each NotFoundError code. */
@@ -27,20 +27,18 @@ const heldBarrierContext = new AsyncLocalStorage();
27
27
  const MAINTENANCE_BARRIER_STALE_AFTER_MS = 5 * 60 * 1000;
28
28
  /**
29
29
  * The barrier normally holds for one lock-file write — sub-millisecond on
30
- * any real filesystem. Two akm processes racing to register a lock/lease/
31
- * activity in the very same instant (e.g. two `akm index` runs a scheduler
32
- * launched back to back, both opening canonical state.db) can still collide
33
- * on it; retrying briefly resolves that ordinary case instead of failing a
34
- * legitimate concurrent invocation outright
35
- * (field follow-up to #956, G1). Bounded short so a genuinely wedged holder
36
- * still surfaces the busy error promptly rather than making a losing
37
- * process hang — comfortably above the barrier's normal hold time, well
38
- * below a length that would make this feel like the blocking lock #872
39
- * removed. Never applies to whatever lock/lease/activity is registered
40
- * after the barrier releases — that thing's own held/skip/throw/wait
41
- * policy belongs to its caller, not to the barrier (#872).
30
+ * any real filesystem. Two akm processes racing to register a lock in the
31
+ * very same instant (e.g. two `akm index` runs a scheduler launched back to
32
+ * back) can still collide on it; retrying briefly resolves that ordinary
33
+ * case instead of failing a legitimate concurrent invocation outright
34
+ * (field follow-up to #956, G1). Five seconds matches the async and
35
+ * synchronous-activity registration paths below and tolerates a holder being
36
+ * descheduled under heavy process-shard load; the barrier's normal hold time
37
+ * remains sub-millisecond. The bound still surfaces a genuinely wedged holder
38
+ * promptly and never applies to the rebuild lock itself, which stays
39
+ * non-blocking (#872).
42
40
  */
43
- const MAINTENANCE_BARRIER_BUSY_RETRY_BOUND_MS = 1_500;
41
+ const MAINTENANCE_BARRIER_BUSY_RETRY_BOUND_MS = 5_000;
44
42
  let busyRetryBoundMsForTests;
45
43
  /** Test-only override for {@link MAINTENANCE_BARRIER_BUSY_RETRY_BOUND_MS}, so a unit test can exercise the
46
44
  * exhausted-retry throw without a real ~1.5s wait. Restored via tests/_helpers/seams.ts's resetAllSeams(). */
@@ -234,6 +234,17 @@ export function getDbPath(env = process.env) {
234
234
  export function getIndexWriterLockPath() {
235
235
  return path.join(getDataDir(), "index.db.write.lock");
236
236
  }
237
+ /**
238
+ * Path to the opt-in, PID-liveness-only rebuild lock an explicit `akm index`
239
+ * run acquires (#956). Distinct from {@link getIndexWriterLockPath}'s
240
+ * `index.db.write.lock`, which is the asset-mutation lease and unrelated to
241
+ * indexing since #872 — this lock never blocks and is never required, it
242
+ * only lets a scheduled/opportunistic `akm index --skip-if-locked` step
243
+ * aside instead of contending with a run already in progress.
244
+ */
245
+ export function getIndexRebuildLockPath() {
246
+ return path.join(getDataDir(), "index.rebuild.lock");
247
+ }
237
248
  export function getMaintenanceBarrierPath() {
238
249
  return path.join(getDataDir(), "maintenance.barrier.lock");
239
250
  }
@@ -3,11 +3,8 @@
3
3
  // file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
4
  /**
5
5
  * PID-liveness-only run lock — the shared mechanics behind `akm improve`'s
6
- * whole-run lock (`commands/improve/locks.ts`). Originally also backed the
7
- * index rebuild lock (`indexer/index-rebuild-lock.ts`, #956); index-redesign
8
- * deleted that consumer (every index write is now a short, idempotent,
9
- * content-addressed transaction, so there is no rebuild to serialize), and
10
- * `improve`'s lock is this module's only caller today.
6
+ * whole-run lock (`commands/improve/locks.ts`) and the index rebuild lock
7
+ * (`indexer/index-rebuild-lock.ts`, #956).
11
8
  *
12
9
  * No `staleAfterMs`: only a verifiably dead holder is ever reclaimed. This is
13
10
  * the #872 lesson encoded at the mechanics layer so every future caller gets
@@ -1200,33 +1200,12 @@ export function getStateMigrationSafety(migrationId) {
1200
1200
  * Delegates to the shared SQLite migration engine; state.db has no
1201
1201
  * pre-versioning bootstrap step, so no `bootstrap` hook is passed.
1202
1202
  *
1203
- * `freshDatabase` is the one exception to "single transaction per migration":
1204
- * the entire registry is locked through the final migration and applied as
1205
- * ONE transaction (mirroring the existing-unversioned prefix lock below).
1206
- * Two akm processes can race to create the same brand-new state.db
1207
- * (`openStateDatabase`'s file-reservation only decides who creates the file,
1208
- * not who finishes initializing it first); with per-migration commits, the
1209
- * loser could open its own connection between two of the winner's commits,
1210
- * see a real-but-incomplete ledger, and — correctly believing this is an
1211
- * ordinary existing database needing to catch up on pending migrations —
1212
- * refuse historical-destructive migration 018 with the same message a
1213
- * genuine legacy database gets. Applying every migration for a fresh
1214
- * database atomically removes that partially-migrated state from view
1215
- * entirely: a concurrent opener now only ever observes "nothing committed
1216
- * yet" (treated as fresh) or "fully current" (nothing left to apply), never
1217
- * the in-between. Safe to lose on a crash mid-bootstrap: a fresh database
1218
- * has no prior data to preserve, so rolling back to nothing and letting the
1219
- * next open retry from scratch is strictly fine.
1220
- *
1221
1203
  * Called automatically by `openStateDatabase()`.
1222
1204
  */
1223
1205
  export function runMigrations(db, options) {
1224
1206
  const initialMigration = STATE_MIGRATIONS[0];
1225
1207
  if (!initialMigration)
1226
1208
  throw new Error("State migration registry has no initial migration.");
1227
- const finalMigration = STATE_MIGRATIONS[STATE_MIGRATIONS.length - 1];
1228
- if (!finalMigration)
1229
- throw new Error("State migration registry has no final migration.");
1230
1209
  let existingUnversionedSnapshotPrepared = false;
1231
1210
  const prepareExistingUnversionedState = (lockedDb) => {
1232
1211
  if (existingUnversionedSnapshotPrepared)
@@ -1249,11 +1228,7 @@ export function runMigrations(db, options) {
1249
1228
  existingUnversionedSnapshotPrepared = true;
1250
1229
  };
1251
1230
  runSqliteMigrations(db, STATE_MIGRATIONS, {
1252
- lockInitialMigrationPrefixThrough: options?.existingUnversionedDatabase
1253
- ? "002-task-history-per-run"
1254
- : options?.freshDatabase
1255
- ? finalMigration.id
1256
- : undefined,
1231
+ lockInitialMigrationPrefixThrough: options?.existingUnversionedDatabase ? "002-task-history-per-run" : undefined,
1257
1232
  beforeLedgerInitializationLocked(lockedDb) {
1258
1233
  if (options?.freshDatabase)
1259
1234
  return;
@@ -442,24 +442,10 @@ export function openStateDatabase(dbPath, options) {
442
442
  });
443
443
  try {
444
444
  preflight.exec("PRAGMA busy_timeout = 30000");
445
- // Both reads below must observe ONE WAL snapshot. Each is its own
446
- // statement, and without an explicit transaction SQLite auto-commits
447
- // each separately, so they can see two different snapshots. A sibling
448
- // process's fresh bootstrap is now one all-or-nothing transaction, so
449
- // a commit landing between the two reads showed an empty ledger to the
450
- // first and the sibling's freshly created tables to the second — the
451
- // signature of a genuine legacy unversioned database, which this
452
- // preflight then refused. Pinned to one snapshot, either nothing the
453
- // sibling did is visible (empty ledger AND no tables, correctly fresh)
454
- // or all of it is (a current ledger, so the refusal is never reached).
455
- // A real legacy database, racing no one, reads exactly as before.
456
- const { ledger, hasNoOtherTables } = preflight.transaction(() => ({
457
- ledger: assertMigrationLedger(preflight, STATE_MIGRATIONS),
458
- hasNoOtherTables: unversionedDatabaseHasNoTables(preflight),
459
- }))();
445
+ const ledger = assertMigrationLedger(preflight, STATE_MIGRATIONS);
460
446
  warnNewerStateLedger(ledger);
461
447
  existingUnversionedDatabase = ledger.migrationIds.length === 0;
462
- if (existingUnversionedDatabase && hasNoOtherTables) {
448
+ if (existingUnversionedDatabase && unversionedDatabaseHasNoTables(preflight)) {
463
449
  existingUnversionedDatabase = false;
464
450
  treatUnversionedAsFresh = true;
465
451
  }
@@ -553,21 +539,7 @@ export function openStateDatabase(dbPath, options) {
553
539
  if (freshReservation)
554
540
  closeFileIdentity(freshReservation);
555
541
  releaseActivity?.();
556
- // The migration engine's own writer-lock retry (sqlite-migrations.ts's
557
- // `withImmediateWriteLock`, used by every migration this open can run,
558
- // including a from-empty first open) throws the raw driver error after
559
- // its own retry budget, not a `TransientError` — only
560
- // `beginImmediateTransaction` below does that reclassification, and nothing
561
- // upstream of it re-wraps a raw SQLITE_BUSY/LOCKED that surfaces from
562
- // deeper in the open/migrate sequence (field follow-up: a from-empty
563
- // first open racing a concurrent opener can still hit this under load).
564
- // Reclassify uniformly here so the contract this function promises --
565
- // contention is reported as `STATE_DB_CONTENDED` (exit 75), never a raw
566
- // driver message (exit 70) -- holds regardless of which internal step
567
- // the contention was observed at. Anything not contention-shaped
568
- // (a genuine legacy-database refusal, corruption, a real schema error)
569
- // rethrows exactly as raised.
570
- throwBeginFailure(error, "state");
542
+ throw error;
571
543
  }
572
544
  }
573
545
  /**
@@ -718,33 +690,6 @@ function sleepSyncMs(ms) {
718
690
  return;
719
691
  sleepSync(ms);
720
692
  }
721
- /**
722
- * Reclassify an exhausted-retry BEGIN failure that is still contention-shaped
723
- * (#948) into a `TransientError`, mirroring the RUN_LEASE_HELD precedent
724
- * (`WorkflowRunsRepository.acquireEngineLease`): the driver text is accurate
725
- * but unhelpful (`{"ok":false,"error":"database is locked"}`, exit
726
- * 70/INTERNAL) — this instead reads as a retryable-shortly signal (exit 75,
727
- * sysexits EX_TEMPFAIL — #948 addendum) with the original error preserved as
728
- * `cause` for `--verbose`/debugging. A genuinely unrelated error (not
729
- * contention-shaped) is rethrown exactly as raised, never reclassified.
730
- *
731
- * `dbKind` (field follow-up to #956) picks the reported identity: `"state"`
732
- * (default, unchanged text) yields `STATE_DB_CONTENDED`; `"index"` yields
733
- * `INDEX_DB_CONTENDED` with index.db's own message, mirroring
734
- * `reclassifyIndexDbContention`'s text (`src/indexer/indexer.ts`) so a caller
735
- * that reaches this helper directly and one that only reclassifies a raw
736
- * driver error report the same thing.
737
- */
738
- function throwBeginFailure(err, dbKind) {
739
- if (isSqliteContentionError(err)) {
740
- const contended = dbKind === "index"
741
- ? new TransientError("akm's index database is busy (another akm process is writing it); retry shortly.", "INDEX_DB_CONTENDED")
742
- : new TransientError("akm's state database is busy (another akm process is writing it); retry shortly.", "STATE_DB_CONTENDED");
743
- contended.cause = err;
744
- throw contended;
745
- }
746
- throw err;
747
- }
748
693
  /**
749
694
  * Open, but deliberately do not finish, an immediate transaction.
750
695
  *
@@ -754,7 +699,26 @@ function throwBeginFailure(err, dbKind) {
754
699
  * publication have all succeeded. The caller that asked for this split phase
755
700
  * owns the matching COMMIT/ROLLBACK.
756
701
  */
757
- export function beginImmediateTransaction(db, dbKind = "state") {
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
+ }
721
+ export function beginImmediateTransaction(db) {
758
722
  if (db.inTransaction) {
759
723
  throw new Error("beginImmediateTransaction requires a connection with no active transaction");
760
724
  }
@@ -781,12 +745,12 @@ export function beginImmediateTransaction(db, dbKind = "state") {
781
745
  sleepSyncMs(2 ** (attempt - 1));
782
746
  continue;
783
747
  }
784
- throwBeginFailure(err, dbKind);
748
+ throwBeginFailure(err);
785
749
  }
786
750
  }
787
- throwBeginFailure(lastBeginErr, dbKind);
751
+ throwBeginFailure(lastBeginErr);
788
752
  }
789
- export function withImmediateTransaction(db, fn, dbKind = "state") {
753
+ export function withImmediateTransaction(db, fn) {
790
754
  // Re-entrancy guard (issue #686): if a transaction is already open on this
791
755
  // connection (e.g. a nested withImmediateTransaction call inside an outer
792
756
  // frame's fn), join it — run fn directly with no BEGIN/COMMIT/ROLLBACK of
@@ -797,7 +761,7 @@ export function withImmediateTransaction(db, fn, dbKind = "state") {
797
761
  if (db.inTransaction) {
798
762
  return fn();
799
763
  }
800
- beginImmediateTransaction(db, dbKind);
764
+ beginImmediateTransaction(db);
801
765
  try {
802
766
  const result = fn();
803
767
  if (!db.inTransaction) {
@@ -105,7 +105,7 @@ export const TYPE_PRESENTATION = {
105
105
  task: {
106
106
  label: "Task",
107
107
  renderer: "task-yaml",
108
- action: (ref) => `akm show ${ref} -> inspect; akm task run <id> -> run now; edit the file + akm task sync -> unschedule`,
108
+ action: (ref) => `akm show ${ref} -> inspect; akm task run <id> -> run now; akm task enable|disable ${ref} -> change local scheduling`,
109
109
  fragmentRef: false,
110
110
  },
111
111
  session: {
@@ -34,7 +34,7 @@ import { assetPathForName, stashDirFor } from "./asset/asset-placement.js";
34
34
  import { conceptIdFromTypeName, displayRef } from "./asset/resolve-ref.js";
35
35
  import { deriveBundleId } from "./bundle-id.js";
36
36
  import { existingFileMode, isWithin, resolveStashDir, writeFileAtomic } from "./common.js";
37
- import { bundleContentRoot, resolveConfiguredSources } from "./config/config.js";
37
+ import { bundleKeyForContentRoot, resolveActiveConfiguredSources, resolveConfiguredSources } from "./config/config.js";
38
38
  import { ConfigError, UsageError } from "./errors.js";
39
39
  import { sanitizeCommitMessage } from "./git-message.js";
40
40
  import { warn, warnOnce } from "./warn.js";
@@ -897,12 +897,16 @@ function gitTargetNotMaterialized(target, contentRoot) {
897
897
  * see plan §6 decision 3 for the rationale.
898
898
  */
899
899
  export function resolveWriteTarget(akmConfig, explicitTarget, options = {}) {
900
- const configuredSources = resolveConfiguredSources(akmConfig);
900
+ const allConfiguredSources = resolveConfiguredSources(akmConfig);
901
+ const configuredSources = resolveActiveConfiguredSources(akmConfig);
901
902
  const requireWritable = options.requireWritable !== false;
902
903
  // 1. Explicit --target wins.
903
904
  if (explicitTarget) {
904
905
  const match = configuredSources.find((s) => s.name === explicitTarget);
905
906
  if (!match) {
907
+ if (allConfiguredSources.some((source) => source.name === explicitTarget)) {
908
+ throw new UsageError(`Bundle "${explicitTarget}" is disabled.`, "INVALID_FLAG_VALUE");
909
+ }
906
910
  throw new UsageError(`--target must reference a source name from your config. No source named "${explicitTarget}" is configured. Run \`akm bundle list\` to see available sources.`, "INVALID_FLAG_VALUE");
907
911
  }
908
912
  // Up-front writable check so an explicit --target fails fast with a
@@ -939,15 +943,13 @@ export function resolveWriteTarget(akmConfig, explicitTarget, options = {}) {
939
943
  }
940
944
  /** Resolve the implicit working stash without consulting `defaultWriteTarget`. */
941
945
  export function resolveWorkingStashTarget(akmConfig, options = {}) {
942
- const configuredSources = resolveConfiguredSources(akmConfig);
946
+ const allConfiguredSources = resolveConfiguredSources(akmConfig);
947
+ const configuredSources = resolveActiveConfiguredSources(akmConfig);
943
948
  const requireWritable = options.requireWritable !== false;
944
949
  if (process.env.AKM_BUNDLE_DIR?.trim()) {
945
950
  const stashDir = resolveStashDir();
946
- const resolvedStashDir = path.resolve(stashDir);
947
- const configured = configuredSources.find((source) => {
948
- const sourcePath = source.source.type === "filesystem" ? source.source.path : undefined;
949
- return sourcePath !== undefined && bundleContentRoot(sourcePath, source.componentRoot) === resolvedStashDir;
950
- });
951
+ const configuredBundleId = bundleKeyForContentRoot(akmConfig, stashDir);
952
+ const configured = configuredSources.find((source) => source.name === configuredBundleId);
951
953
  if (configured) {
952
954
  const target = adaptConfiguredSource(configured);
953
955
  if (requireWritable && !resolveWritable(target.config)) {
@@ -955,6 +957,9 @@ export function resolveWorkingStashTarget(akmConfig, options = {}) {
955
957
  }
956
958
  return { ...target, selector: undefined };
957
959
  }
960
+ if (allConfiguredSources.some((source) => source.name === configuredBundleId)) {
961
+ throw new ConfigError("The AKM_BUNDLE_DIR source is disabled in config.", "INVALID_CONFIG_FILE");
962
+ }
958
963
  const bundleId = deriveBundleId(undefined, stashDir, new Set(Object.keys(akmConfig.bundles ?? {})));
959
964
  return {
960
965
  source: { kind: "filesystem", name: bundleId, path: stashDir, adapterId: detectAdapterId(stashDir) },
@@ -28,6 +28,7 @@
28
28
  * comparison over a non-empty index so the steady-state cost is a boolean check.
29
29
  */
30
30
  import fs from "node:fs";
31
+ import path from "node:path";
31
32
  import { loadConfig } from "../core/config/config.js";
32
33
  import { rethrowIfTestIsolationError } from "../core/errors.js";
33
34
  import { getDbPath } from "../core/paths.js";
@@ -39,8 +40,8 @@ let guardSettled = false;
39
40
  export function resetBundleIdentityGuardForTests() {
40
41
  guardSettled = false;
41
42
  }
42
- /** Distinct non-empty `bundle_id` prefixes persisted in the index, or `undefined` when unreadable. */
43
- function indexBundlePrefixes(dbPath) {
43
+ /** Bundle/path ownership persisted in the index, or `undefined` when unreadable. */
44
+ function indexedBundlePaths(dbPath) {
44
45
  if (!fs.existsSync(dbPath))
45
46
  return undefined;
46
47
  let db;
@@ -51,8 +52,9 @@ function indexBundlePrefixes(dbPath) {
51
52
  if (!isCanonicalIndexGeneration(db))
52
53
  return undefined;
53
54
  return db
54
- .prepare("SELECT DISTINCT bundle_id AS b FROM entries WHERE bundle_id IS NOT NULL AND bundle_id != ''")
55
- .all().map((row) => row.b);
55
+ .prepare("SELECT DISTINCT bundle_id AS bundleId, file_path AS filePath FROM entries " +
56
+ "WHERE bundle_id IS NOT NULL AND bundle_id != '' AND file_path IS NOT NULL AND file_path != ''")
57
+ .all();
56
58
  }
57
59
  catch (error) {
58
60
  rethrowIfTestIsolationError(error);
@@ -80,20 +82,55 @@ export function warnOnBundleRenameDrift(config = loadConfig()) {
80
82
  const configIds = new Set(Object.keys(bundles));
81
83
  if (configIds.size === 0)
82
84
  return;
83
- const indexIds = indexBundlePrefixes(getDbPath());
84
- if (indexIds === undefined || indexIds.length === 0)
85
+ const indexedPaths = indexedBundlePaths(getDbPath());
86
+ if (indexedPaths === undefined || indexedPaths.length === 0)
85
87
  return; // nothing indexed yet — re-check later
86
88
  // A real comparison happened over a populated index: settle so the steady
87
89
  // state is one boolean check.
88
90
  guardSettled = true;
91
+ const indexIds = [...new Set(indexedPaths.map((row) => row.bundleId))];
89
92
  const indexIdSet = new Set(indexIds);
90
93
  const configuredMissingFromIndex = [...configIds].filter((id) => !indexIdSet.has(id));
91
94
  const indexedNotConfigured = indexIds.filter((id) => !configIds.has(id));
92
95
  if (configuredMissingFromIndex.length === 0 || indexedNotConfigured.length === 0)
93
96
  return;
97
+ // A mismatched id set alone is also the ordinary signature of adding a new
98
+ // bundle while stale rows from a removed bundle still exist. Treat it as a
99
+ // hand rename only when the old rows physically live beneath the newly named
100
+ // filesystem bundle's root. This keeps the guard useful without warning on
101
+ // unrelated new bundles (#971).
102
+ const renamedConfiguredIds = configuredMissingFromIndex.filter((configuredId) => {
103
+ const configuredPath = config.bundles?.[configuredId]?.path;
104
+ if (typeof configuredPath !== "string")
105
+ return false;
106
+ const root = path.resolve(configuredPath);
107
+ return indexedPaths.some((row) => {
108
+ if (!indexedNotConfigured.includes(row.bundleId))
109
+ return false;
110
+ const relative = path.relative(root, path.resolve(row.filePath));
111
+ return relative !== "" && !relative.startsWith("..") && !path.isAbsolute(relative);
112
+ });
113
+ });
114
+ if (renamedConfiguredIds.length === 0)
115
+ return;
116
+ const renamedOldIds = [
117
+ ...new Set(indexedPaths
118
+ .filter((row) => {
119
+ if (!indexedNotConfigured.includes(row.bundleId))
120
+ return false;
121
+ return renamedConfiguredIds.some((configuredId) => {
122
+ const configuredPath = config.bundles?.[configuredId]?.path;
123
+ if (typeof configuredPath !== "string")
124
+ return false;
125
+ const relative = path.relative(path.resolve(configuredPath), path.resolve(row.filePath));
126
+ return relative !== "" && !relative.startsWith("..") && !path.isAbsolute(relative);
127
+ });
128
+ })
129
+ .map((row) => row.bundleId)),
130
+ ];
94
131
  warn("WARNING: bundle identity drift detected. " +
95
- `Configured bundle(s) with no indexed content: ${configuredMissingFromIndex.map((id) => `"${id}"`).join(", ")}; ` +
96
- `indexed content under unconfigured bundle id(s): ${indexedNotConfigured.map((id) => `"${id}"`).join(", ")}. ` +
132
+ `Configured bundle(s) with no indexed content: ${renamedConfiguredIds.map((id) => `"${id}"`).join(", ")}; ` +
133
+ `indexed content under unconfigured bundle id(s): ${renamedOldIds.map((id) => `"${id}"`).join(", ")}. ` +
97
134
  "This is the signature of a hand-renamed bundle id (spec §11.5). AKM will NOT silently re-mint fresh state " +
98
135
  "under the new id. There is no rekey command in 0.9.0: either restore the previous bundle id in config.json " +
99
136
  "to reattach the existing rows, or keep the new id and run `akm index --full` to re-mint under it, accepting " +
@@ -193,11 +193,6 @@ function indexCanServeStash(stashDir) {
193
193
  }
194
194
  async function runInlineReindex(stashDir, options = {}) {
195
195
  const { akmIndex } = await import("./indexer.js");
196
- // The embedding drain on this implicit path is bounded (see
197
- // `IndexOptions.implicit`, src/indexer/indexer.ts): a read command's
198
- // inline bootstrap embeds one provider request's worth of units and
199
- // leaves the rest of the durable queue to a later drain, so a first
200
- // `search`/`show` against a fresh index never blocks on the whole corpus.
201
196
  await akmIndex({
202
197
  stashDir,
203
198
  implicit: true,
@@ -0,0 +1,56 @@
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
+ * Shared index.db contention reclassification (field follow-up to #956).
6
+ *
7
+ * Extracted out of `indexer.ts` so both `akmIndex`'s outer catch AND
8
+ * `generateEmbeddingsForDb`'s own catch (`materialize-embeddings.ts`) can
9
+ * reuse the ONE classifier instead of each building a raw
10
+ * `Semantic search verification failed: <driver message>` string. Living in
11
+ * its own module (rather than one importing the other) avoids the import
12
+ * cycle `indexer.ts` <-> `materialize-embeddings.ts` would otherwise form.
13
+ */
14
+ import { AkmError, TransientError } from "../core/errors.js";
15
+ import { probeLock } from "../core/file-lock.js";
16
+ import { formatLockHolderPid } from "../core/run-lock.js";
17
+ import { isSqliteContentionError } from "../core/state-db.js";
18
+ import { indexRebuildLockPath } from "./index-rebuild-lock.js";
19
+ /**
20
+ * Read-only description of the rebuild lock's current holder, appended to a
21
+ * reclassified index.db contention message when known (field follow-up to
22
+ * #956). `probeLock` only inspects the sentinel — it never acquires or
23
+ * mutates it — so this is safe to call from inside an error path.
24
+ */
25
+ function describeIndexRebuildLockHolder() {
26
+ const probe = probeLock(indexRebuildLockPath());
27
+ if (probe.state !== "held")
28
+ return "";
29
+ return ` The rebuild lock is currently held by pid ${formatLockHolderPid({
30
+ pid: probe.holderPid,
31
+ launcherPid: probe.launcherPid ?? null,
32
+ })}.`;
33
+ }
34
+ /**
35
+ * Reclassify a contention-shaped error escaping the walk, index, or
36
+ * embedding phase into a retryable-shortly `TransientError` (field
37
+ * follow-up to #956, dev-team field review 2026-09-10): a concurrent writer
38
+ * (another `akm index`, a source-update embedding pass, the per-command
39
+ * background reindex) can make index.db busy, and the raw SQLite driver
40
+ * error ("database is locked") used to escape as exit 70
41
+ * (internal/unclassified) instead of the "retry shortly" contract exit 75
42
+ * gives a scheduler to branch on — mirroring `STATE_DB_CONTENDED`'s
43
+ * precedent for state.db (`core/state-db.ts`). Reuses the ONE shared
44
+ * classifier, `isSqliteContentionError`, rather than a second one. An error
45
+ * that is already a classified akm error (e.g. a `STATE_DB_CONTENDED`
46
+ * TransientError from an inner state.db write) is never re-wrapped — only a
47
+ * raw, unclassified error matching the shared contention shape is
48
+ * reclassified. Every other error is rethrown unchanged.
49
+ */
50
+ export function reclassifyIndexDbContention(error) {
51
+ if (error instanceof AkmError || !isSqliteContentionError(error))
52
+ return error;
53
+ const contended = new TransientError(`akm's index database is busy (another akm process is writing it); retry shortly.${describeIndexRebuildLockHolder()}`, "INDEX_DB_CONTENDED");
54
+ contended.cause = error;
55
+ return contended;
56
+ }
@@ -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
+ }