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.
- package/CHANGELOG.md +559 -0
- package/STABILITY.md +6 -3
- package/dist/akm +54 -1
- package/dist/akm-migrate +34 -1
- package/dist/assets/prompts/reflect-feedback-framing.md +1 -0
- package/dist/assets/prompts/reflect-llm-framed-contract.md +2 -0
- package/dist/assets/prompts/reflect-llm-schema-contract.md +2 -0
- package/dist/assets/tasks/core/improve.yml +1 -1
- package/dist/assets/tasks/core/index-refresh.yml +1 -1
- package/dist/assets/tasks/improve/akm-graph-refresh-weekly.yml +1 -1
- package/dist/assets/tasks/improve/akm-improve-catchup.yml +1 -1
- package/dist/assets/tasks/improve/akm-improve-consolidate.yml +1 -1
- package/dist/assets/tasks/improve/akm-improve-frequent.yml +1 -1
- package/dist/assets/tasks/improve/akm-improve-nightly.yml +1 -1
- package/dist/cli/retired-commands.js +0 -1
- package/dist/cli/shared.js +9 -0
- package/dist/cli/unknown-flags.js +1 -0
- package/dist/cli.js +40 -3
- package/dist/commands/config-cli.js +85 -3
- package/dist/commands/env/env-cli.js +1 -42
- package/dist/commands/env/env.js +1 -1
- package/dist/commands/env/secret-cli.js +1 -2
- package/dist/commands/health/checks.js +357 -63
- package/dist/commands/health/engine-usage.js +45 -0
- package/dist/commands/health/improve-metrics.js +18 -0
- package/dist/commands/health/llm-usage.js +41 -1
- package/dist/commands/health/plugin-staleness.js +7 -3
- package/dist/commands/health/version-drift.js +93 -0
- package/dist/commands/health/windows.js +3 -1
- package/dist/commands/health.js +44 -9
- package/dist/commands/improve/consolidate/chunking.js +4 -2
- package/dist/commands/improve/improve-cli.js +99 -5
- package/dist/commands/improve/improve-report.js +154 -0
- package/dist/commands/improve/improve-result-file.js +45 -33
- package/dist/commands/improve/improve-strategies.js +133 -3
- package/dist/commands/improve/improve-usage-report.js +182 -0
- package/dist/commands/improve/improve.js +40 -3
- package/dist/commands/improve/locks.js +28 -78
- package/dist/commands/improve/planner.js +1 -0
- package/dist/commands/improve/preparation.js +9 -1
- package/dist/commands/improve/reflect.js +44 -4
- package/dist/commands/models-cli.js +50 -1
- package/dist/commands/proposal/repository.js +8 -3
- package/dist/commands/proposal/validators/proposal-quality-validators.js +41 -6
- package/dist/commands/proposal/validators/proposal-validators.js +24 -0
- package/dist/commands/read/search-cli.js +38 -2
- package/dist/commands/read/show.js +103 -4
- package/dist/commands/sources/info.js +5 -1
- package/dist/commands/sources/installed-stashes.js +58 -16
- package/dist/commands/sources/self-update.js +2 -2
- package/dist/commands/sources/stash-cli.js +48 -0
- package/dist/commands/tasks/tasks-cli.js +49 -2
- package/dist/commands/workflow-cli.js +86 -12
- package/dist/core/asset/markdown-fragments.js +35 -0
- package/dist/core/config/config-schema.js +14 -0
- package/dist/core/config/config.js +302 -24
- package/dist/core/config/schema/embedding.js +41 -0
- package/dist/core/env-secret-ref.js +58 -5
- package/dist/core/errors.js +30 -0
- package/dist/core/file-lock.js +49 -15
- package/dist/core/improve-result.js +51 -0
- package/dist/core/loopback.js +17 -0
- package/dist/core/parent-watchdog.js +64 -0
- package/dist/core/paths.js +11 -0
- package/dist/core/run-lock.js +107 -0
- package/dist/core/sensitive-marker-path.js +19 -0
- package/dist/core/state-db.js +74 -14
- package/dist/indexer/index-rebuild-lock.js +73 -0
- package/dist/indexer/index-writer-lock.js +40 -1
- package/dist/indexer/index-written-assets.js +29 -1
- package/dist/indexer/indexer.js +93 -29
- package/dist/indexer/materialize-embeddings.js +564 -48
- package/dist/indexer/search/db-search.js +49 -2
- package/dist/indexer/search/search-source.js +23 -1
- package/dist/integrations/agent/engine-resolution.js +96 -6
- package/dist/integrations/agent/execution-definitions.js +6 -15
- package/dist/integrations/agent/execution-lowering.js +6 -1
- package/dist/integrations/agent/execution-preparation.js +1 -1
- package/dist/integrations/agent/model-map.js +123 -20
- package/dist/integrations/agent/prompts.js +40 -8
- package/dist/integrations/agent/runner-dispatch.js +9 -3
- package/dist/integrations/agent/runner.js +2 -0
- package/dist/llm/client.js +8 -3
- package/dist/llm/embedder.js +20 -8
- package/dist/llm/embedders/local.js +10 -2
- package/dist/llm/embedders/remote.js +497 -32
- package/dist/output/shapes/helpers.js +38 -2
- package/dist/output/shapes/models-list.js +16 -0
- package/dist/output/shapes/passthrough.js +2 -0
- package/dist/output/shapes.js +4 -0
- package/dist/output/text/command-format.js +29 -0
- package/dist/output/text/helpers.js +1 -1
- package/dist/output/text/improve-report.js +27 -0
- package/dist/{commands/env/marker-path.js → output/text/models.js} +4 -3
- package/dist/output/text/show-format.js +4 -0
- package/dist/output/text.js +4 -0
- package/dist/scripts/akm-migrate-node.js +25146 -21759
- package/dist/scripts/akm-migrate.js +24271 -20885
- package/dist/storage/repositories/embedding-salvage-repository.js +184 -0
- package/dist/storage/repositories/improve-runs-repository.js +34 -0
- package/dist/storage/repositories/index-fts-repository.js +49 -6
- package/dist/storage/repositories/index-schema.js +16 -0
- package/dist/storage/repositories/index-vec-repository.js +30 -0
- package/dist/storage/repositories/workflow-runs-repository.js +55 -18
- package/dist/tasks/backends/cron.js +14 -7
- package/dist/tasks/run/run-native-task.js +23 -1
- package/dist/tasks/run/run-workflow-task.js +16 -0
- package/dist/workflows/exec/child-workflow.js +2 -2
- package/dist/workflows/exec/dispatch-redaction.js +21 -9
- package/dist/workflows/exec/run-workflow.js +6 -5
- package/dist/workflows/runtime/runs.js +33 -5
- package/docs/migration/release-notes/0.9.15.md +133 -0
- package/docs/migration/release-notes/README.md +5 -0
- package/docs/reference/cli.md +271 -30
- package/docs/reference/configuration.md +234 -21
- package/docs/reference/data-and-telemetry.md +8 -0
- package/docs/reference/tasks.md +16 -1
- package/docs/reference/workflow-schema.md +5 -1
- package/package.json +1 -1
- package/schemas/akm-config.json +47 -0
package/dist/core/state-db.js
CHANGED
|
@@ -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
|
-
*
|
|
625
|
-
* transient
|
|
626
|
-
* retry
|
|
627
|
-
*
|
|
628
|
-
*
|
|
629
|
-
*
|
|
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
|
-
*
|
|
632
|
-
*
|
|
633
|
-
*
|
|
634
|
-
*
|
|
635
|
-
*
|
|
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
|
|
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
|
-
|
|
748
|
+
throwBeginFailure(err);
|
|
689
749
|
}
|
|
690
750
|
}
|
|
691
|
-
|
|
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
|
-
|
|
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
|
package/dist/indexer/indexer.js
CHANGED
|
@@ -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 {
|
|
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
|
-
//
|
|
58
|
-
//
|
|
59
|
-
//
|
|
60
|
-
//
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
//
|
|
64
|
-
//
|
|
65
|
-
//
|
|
66
|
-
//
|
|
67
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
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
|
|
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.
|