akm-cli 0.9.15-beta.2 → 0.9.15-beta.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +60 -1
- package/dist/cli.js +3 -3
- package/dist/commands/health/checks.js +11 -2
- package/dist/commands/health/scheduler-binary.js +120 -0
- package/dist/commands/health.js +9 -0
- package/dist/core/config/schema/embedding.js +2 -3
- package/dist/core/errors.js +1 -0
- package/dist/indexer/indexer.js +44 -3
- package/dist/indexer/materialize-embeddings.js +51 -9
- package/dist/llm/embedders/remote.js +106 -8
- package/dist/scripts/akm-migrate-node.js +61 -13
- package/dist/scripts/akm-migrate.js +61 -13
- package/docs/migration/release-notes/0.9.15.md +18 -0
- package/docs/reference/cli.md +27 -5
- package/docs/reference/configuration.md +21 -5
- package/package.json +1 -1
- package/schemas/akm-config.json +0 -12
package/CHANGELOG.md
CHANGED
|
@@ -4,7 +4,7 @@ All notable changes to this project will be documented in this file.
|
|
|
4
4
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
|
6
6
|
|
|
7
|
-
## [0.9.15-beta.
|
|
7
|
+
## [0.9.15-beta.3] - 2026-09-10
|
|
8
8
|
|
|
9
9
|
### Added
|
|
10
10
|
|
|
@@ -268,6 +268,17 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
|
|
268
268
|
apiKey`, or `none configured` — never the credential's value, so a field
|
|
269
269
|
run can compare it directly against what the gateway actually logged.
|
|
270
270
|
`--verbose` also names the config file the run loaded.
|
|
271
|
+
- **`akm health` gains a `scheduler-binary` advisory for scheduler binary
|
|
272
|
+
drift (#953).** A field report found `akm task sync`'s recorded absolute
|
|
273
|
+
akm path can go stale after upgrading through a different installer (npm
|
|
274
|
+
global to a standalone download, or vice versa), leaving a scheduled run
|
|
275
|
+
invoking the old binary indefinitely with nothing surfacing it. The new
|
|
276
|
+
`--probe`-gated advisory reads the scheduler's recorded akm invocation —
|
|
277
|
+
the same binding `task sync`/`task doctor` already read, no crontab text
|
|
278
|
+
parsing — runs it with `--version`, and `warn`s naming both versions when
|
|
279
|
+
it differs from the running CLI, pointing at `akm task sync` as the
|
|
280
|
+
remedy. `unknown` when not probed, no task is installed, or the recorded
|
|
281
|
+
binary cannot be executed.
|
|
271
282
|
|
|
272
283
|
### Changed
|
|
273
284
|
|
|
@@ -322,6 +333,17 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
|
|
322
333
|
single document that still fails this way is skipped, as
|
|
323
334
|
`context-window-exceeded`; every other failure (network error, 5xx, malformed
|
|
324
335
|
response) keeps the prior skip-the-whole-batch behavior.
|
|
336
|
+
- **The default per-request token budget is lower, and adapts mid-run after a
|
|
337
|
+
context-size rejection (#954, field report on beta.1).**
|
|
338
|
+
`embedding.maxTokens`'s default dropped from 8000 to 6000: the 4-chars-per-
|
|
339
|
+
token estimator undercounts dense technical text by 7-55%, so 8000 regularly
|
|
340
|
+
overshot a real 8192-token endpoint. On an `akm index` run's first
|
|
341
|
+
context-size rejection, the effective request budget additionally shrinks to
|
|
342
|
+
three quarters of its current value (floored at twice
|
|
343
|
+
`embedding.maxInputTokens`) for every request not yet sent, and one
|
|
344
|
+
default-level line reports the new value; it never shrinks a second time in
|
|
345
|
+
the same run. Users who set `embedding.maxTokens` explicitly keep it as the
|
|
346
|
+
starting point but still benefit from this same-run recovery.
|
|
325
347
|
- **Embedding requests are dispatched through a small in-flight window instead of
|
|
326
348
|
strictly sequentially (#954).** The window defaults to 1 request at a time for
|
|
327
349
|
a loopback endpoint and 2 for a remote one; the actual throughput knob is
|
|
@@ -432,6 +454,12 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
|
|
432
454
|
Source-cache hydration (which runs before `index.db` is even opened) now
|
|
433
455
|
reports its own progress the same way: `Hydrating source i/n: <name>` per
|
|
434
456
|
source, plus a 15s heartbeat while a sync is in flight.
|
|
457
|
+
- **`embedding.chunkSize` is retired (#954).** Nothing under `src/` ever read
|
|
458
|
+
it; it was declared in the config schema but had no effect. It is removed
|
|
459
|
+
from `EmbeddingConnectionConfigSchema` and `schemas/akm-config.json`. The
|
|
460
|
+
`embedding` object stays `.passthrough()`, so a config that still sets
|
|
461
|
+
`embedding.chunkSize` keeps loading exactly as before — the key is simply
|
|
462
|
+
ignored, not rejected or warned about.
|
|
435
463
|
|
|
436
464
|
### Fixed
|
|
437
465
|
|
|
@@ -533,6 +561,14 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
|
|
533
561
|
physical batch size", which the existing context-size pattern
|
|
534
562
|
(`exceed_context_size_error`, "context size", …) did not match, so the
|
|
535
563
|
whole batch was dropped instead of being split and retried like a 413.
|
|
564
|
+
- **The end-of-run throughput line now sums the capped text actually sent to
|
|
565
|
+
the embedding provider (#954).** `storedTokens` accumulated
|
|
566
|
+
`estimateTokenCount(entry.searchText)` — the entry's pre-cap search text —
|
|
567
|
+
while the request `embedBatch` received held the text `capEmbeddingText`
|
|
568
|
+
had already truncated to `embedding.maxInputTokens`, so the reported
|
|
569
|
+
`tokens/s` figure overstated throughput for every entry over the cap. The
|
|
570
|
+
final line now sums the estimate of the capped text the batching loop
|
|
571
|
+
already built, matching what the provider was actually asked to embed.
|
|
536
572
|
- **A `kill <launcher-pid>` no longer orphans the running `akm` process
|
|
537
573
|
(#956).** The published launcher (`scripts/node-runtime/akm`/
|
|
538
574
|
`akm-migrate`) now forwards SIGTERM/SIGINT/SIGHUP to its bun/node child
|
|
@@ -562,6 +598,29 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
|
|
562
598
|
instead of reusing them. A plain `akm index` resume after an interruption
|
|
563
599
|
now embeds only the entries still missing a vector, with no purge and no
|
|
564
600
|
canary.
|
|
601
|
+
- **A concurrent `akm index` without `--skip-if-locked` now fails with a
|
|
602
|
+
retryable-shortly exit code instead of a raw driver error (#956).**
|
|
603
|
+
Contention with another writer touching index.db (a second `akm index`, a
|
|
604
|
+
source add/update's embedding pass, the per-command background reindex)
|
|
605
|
+
used to exhaust the SQLite driver's retry window and surface as
|
|
606
|
+
`{"ok":false,"error":"database is locked"}` at exit 70
|
|
607
|
+
(internal/unclassified). It is now reclassified into a `TransientError`
|
|
608
|
+
with a dedicated `INDEX_DB_CONTENDED` code (exit 75), naming the rebuild
|
|
609
|
+
lock's live holder pid when known, mirroring `STATE_DB_CONTENDED`'s
|
|
610
|
+
precedent (#948) for state.db; the original driver text survives as
|
|
611
|
+
`cause`. `--skip-if-locked` is unaffected — it already skips gracefully
|
|
612
|
+
before ever attempting the write.
|
|
613
|
+
- **The fingerprint-rename canary embeds the exact text the stored vector was
|
|
614
|
+
generated from (#955).** `sampleEmbeddedEntriesForCanary` handed the canary
|
|
615
|
+
the entry's raw `search_text`, while the main embedding pass caps it to
|
|
616
|
+
`embedding.maxInputTokens` before ever calling the provider — so for any
|
|
617
|
+
entry whose search text exceeded the cap, the canary's freshly re-embedded
|
|
618
|
+
vector came from a different input than the one that produced the stored
|
|
619
|
+
vector, and the median cosine similarity could fall below the compatibility
|
|
620
|
+
threshold for reasons unrelated to the model, triggering a needless full
|
|
621
|
+
purge and rebuild on a same-model rename. The canary now caps each sampled
|
|
622
|
+
entry's search text the same way, through the same `capEmbeddingText`
|
|
623
|
+
helper, before requesting its vector.
|
|
565
624
|
|
|
566
625
|
## [0.9.14] - 2026-09-04
|
|
567
626
|
|
package/dist/cli.js
CHANGED
|
@@ -295,8 +295,8 @@ const healthCommand = defineCommand({
|
|
|
295
295
|
probe: {
|
|
296
296
|
type: "boolean",
|
|
297
297
|
default: true,
|
|
298
|
-
description: "Probe default-llm-engine / configured-engines reachability
|
|
299
|
-
negativeDescription: "Skip the reachability probes and the
|
|
298
|
+
description: "Probe default-llm-engine / configured-engines reachability, check for a newer akm release, and check the scheduler's recorded akm binary version (on by default).",
|
|
299
|
+
negativeDescription: "Skip the reachability probes, the update check, and the scheduler-binary version check (for an offline or air-gapped host).",
|
|
300
300
|
},
|
|
301
301
|
},
|
|
302
302
|
async run({ args }) {
|
|
@@ -556,7 +556,7 @@ export const main = defineCommand({
|
|
|
556
556
|
" 2 usage error\n" +
|
|
557
557
|
" 4 health warn (akm health only)\n" +
|
|
558
558
|
" 70 internal / unclassified error\n" +
|
|
559
|
-
" 75 transient (retry shortly — another akm process holds a lock or is writing state.db)\n" +
|
|
559
|
+
" 75 transient (retry shortly — another akm process holds a lock or is writing state.db or index.db)\n" +
|
|
560
560
|
" 78 config error",
|
|
561
561
|
},
|
|
562
562
|
args: {
|
|
@@ -1164,11 +1164,20 @@ export const HEALTH_CHECKS = [
|
|
|
1164
1164
|
run: (ctx) => ctx.versionDrift,
|
|
1165
1165
|
},
|
|
1166
1166
|
{
|
|
1167
|
-
// #950:
|
|
1168
|
-
// doc comment above). Advisory channel, `kind: "deterministic"` — same
|
|
1167
|
+
// #950: advisory channel, `kind: "deterministic"` — same
|
|
1169
1168
|
// exit-code-gating rationale as thinking-control above.
|
|
1170
1169
|
name: "engine-last-used",
|
|
1171
1170
|
channel: "advisory",
|
|
1172
1171
|
run: (ctx) => projectEngineLastUsedCheck(ctx.activeImproveStrategyEngines, ctx.engineLastUsed, ctx.improveRunsInLookbackWindow, ENGINE_LAST_USED_LOOKBACK_DAYS),
|
|
1173
1172
|
},
|
|
1173
|
+
{
|
|
1174
|
+
// #953: registered last — order is load-bearing (see the HEALTH_CHECKS
|
|
1175
|
+
// doc comment above). Best-effort scheduler-binary-drift advisory, gated
|
|
1176
|
+
// behind the same --probe/--no-probe flag as engine reachability and
|
|
1177
|
+
// cli-version. Computed once in health.ts (process-spawn IO), projected
|
|
1178
|
+
// here like versionDrift/engineProbes.
|
|
1179
|
+
name: "scheduler-binary",
|
|
1180
|
+
channel: "advisory",
|
|
1181
|
+
run: (ctx) => ctx.schedulerBinaryDrift,
|
|
1182
|
+
},
|
|
1174
1183
|
];
|
|
@@ -0,0 +1,120 @@
|
|
|
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
|
+
* `scheduler-binary` advisory for `akm health` (#953).
|
|
6
|
+
*
|
|
7
|
+
* `akm task sync` records an absolute akm invocation path in the OS
|
|
8
|
+
* scheduler (`src/tasks/resolve-akm-bin.ts`) because cron/launchd/schtasks
|
|
9
|
+
* all run jobs with a minimal PATH. A field report showed that path going
|
|
10
|
+
* stale after upgrading akm through a different installer than the one
|
|
11
|
+
* active at the last `task sync` (e.g. npm-global to a standalone binary):
|
|
12
|
+
* the schedule kept invoking a version behind, with nothing in `akm health`
|
|
13
|
+
* surfacing it, until an unrelated failure (a rejected `secret://` engine
|
|
14
|
+
* key on the stale binary) surfaced the drift.
|
|
15
|
+
*
|
|
16
|
+
* Modelled 1:1 on `version-drift.ts`'s shape: an injectable seam,
|
|
17
|
+
* best-effort, `--probe`-gated so an air-gapped host's `--no-probe` habit
|
|
18
|
+
* suppresses this too, and `unknown` on any uncertainty rather than a false
|
|
19
|
+
* `pass`/`warn`. Reads the scheduler's recorded binding via
|
|
20
|
+
* `SchedulerBackend.list()` — the same reader `akm task doctor` uses — never
|
|
21
|
+
* a second crontab/launchd/schtasks text parser.
|
|
22
|
+
*/
|
|
23
|
+
import { spawnSync } from "node:child_process";
|
|
24
|
+
import { selectBackend } from "../../tasks/backends/index.js";
|
|
25
|
+
import { pkgVersion } from "../../version.js";
|
|
26
|
+
/**
|
|
27
|
+
* Bound on the scheduler-recorded binary's `--version` probe. Matches the
|
|
28
|
+
* `--version` timeout the engine-reachability checks already use
|
|
29
|
+
* (checks.ts's `runConfiguredEngineProbe`), so a wedged or missing binary
|
|
30
|
+
* degrades this advisory to `unknown` in seconds rather than blocking
|
|
31
|
+
* `akm health --probe`.
|
|
32
|
+
*/
|
|
33
|
+
const SCHEDULER_BINARY_VERSION_PROBE_TIMEOUT_MS = 5_000;
|
|
34
|
+
/**
|
|
35
|
+
* Build the `scheduler-binary` advisory. `probe` mirrors the
|
|
36
|
+
* engine-reachability and `cli-version` checks' `--probe`/`--no-probe`
|
|
37
|
+
* gating: only inspects the scheduler and spawns a process when `true`;
|
|
38
|
+
* otherwise `unknown` with "not probed", never touching the OS scheduler.
|
|
39
|
+
*/
|
|
40
|
+
export async function collectSchedulerBinaryAdvisory(probe, deps = {}) {
|
|
41
|
+
const cliVersion = deps.cliVersion ?? pkgVersion;
|
|
42
|
+
if (!probe) {
|
|
43
|
+
return {
|
|
44
|
+
name: "scheduler-binary",
|
|
45
|
+
kind: "deterministic",
|
|
46
|
+
status: "unknown",
|
|
47
|
+
confidence: "high",
|
|
48
|
+
message: "Scheduler binary version drift was not probed.",
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
let installed;
|
|
52
|
+
try {
|
|
53
|
+
installed = await (deps.backend ?? selectBackend()).list();
|
|
54
|
+
}
|
|
55
|
+
catch (error) {
|
|
56
|
+
return {
|
|
57
|
+
name: "scheduler-binary",
|
|
58
|
+
kind: "deterministic",
|
|
59
|
+
status: "unknown",
|
|
60
|
+
confidence: "high",
|
|
61
|
+
message: `Installed scheduled tasks could not be inspected: ${error instanceof Error ? error.message : String(error)}`,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
// task sync rewrites every installed binding to the same current
|
|
65
|
+
// invocation atomically, so the first entry's binding represents the
|
|
66
|
+
// whole schedule under normal operation.
|
|
67
|
+
const [firstInstalled] = installed;
|
|
68
|
+
const [binaryPath, ...leadingArgs] = firstInstalled?.binding ?? [];
|
|
69
|
+
if (!firstInstalled || !binaryPath) {
|
|
70
|
+
return {
|
|
71
|
+
name: "scheduler-binary",
|
|
72
|
+
kind: "deterministic",
|
|
73
|
+
status: "unknown",
|
|
74
|
+
confidence: "high",
|
|
75
|
+
message: "No scheduled task is installed.",
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
const binding = firstInstalled.binding;
|
|
79
|
+
const run = deps.spawnSync ?? spawnSync;
|
|
80
|
+
let scheduledVersion;
|
|
81
|
+
try {
|
|
82
|
+
const result = run(binaryPath, [...leadingArgs, "--version"], {
|
|
83
|
+
encoding: "utf8",
|
|
84
|
+
timeout: SCHEDULER_BINARY_VERSION_PROBE_TIMEOUT_MS,
|
|
85
|
+
});
|
|
86
|
+
if ((result.status ?? 1) === 0)
|
|
87
|
+
scheduledVersion = result.stdout?.trim() || undefined;
|
|
88
|
+
}
|
|
89
|
+
catch {
|
|
90
|
+
scheduledVersion = undefined;
|
|
91
|
+
}
|
|
92
|
+
if (!scheduledVersion) {
|
|
93
|
+
return {
|
|
94
|
+
name: "scheduler-binary",
|
|
95
|
+
kind: "deterministic",
|
|
96
|
+
status: "unknown",
|
|
97
|
+
confidence: "high",
|
|
98
|
+
message: "The scheduler's recorded akm binary could not be executed.",
|
|
99
|
+
evidence: { binding },
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
if (scheduledVersion === cliVersion) {
|
|
103
|
+
return {
|
|
104
|
+
name: "scheduler-binary",
|
|
105
|
+
kind: "deterministic",
|
|
106
|
+
status: "pass",
|
|
107
|
+
confidence: "high",
|
|
108
|
+
message: `Scheduled tasks are bound to akm v${scheduledVersion}, matching the running CLI.`,
|
|
109
|
+
evidence: { binding, scheduledVersion, cliVersion },
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
return {
|
|
113
|
+
name: "scheduler-binary",
|
|
114
|
+
kind: "deterministic",
|
|
115
|
+
status: "warn",
|
|
116
|
+
confidence: "high",
|
|
117
|
+
message: `Scheduled tasks are bound to akm v${scheduledVersion}, but the running CLI is v${cliVersion} — run \`akm task sync\` to rebind the schedule.`,
|
|
118
|
+
evidence: { binding, scheduledVersion, cliVersion },
|
|
119
|
+
};
|
|
120
|
+
}
|
package/dist/commands/health.js
CHANGED
|
@@ -27,6 +27,7 @@ import { buildImproveSkipSummary, computeWallTimeStats, countAgentFailureReasons
|
|
|
27
27
|
import { emptyLlmUsageAggregate, readLlmUsageAggregate } from "./health/llm-usage.js";
|
|
28
28
|
import { computeDegradationMetrics, computeDenominatorFixedCoverage, computeEnrichmentMintingRollup, probeStateDbRoundTrip, } from "./health/metrics.js";
|
|
29
29
|
import { collectPluginStalenessAdvisories } from "./health/plugin-staleness.js";
|
|
30
|
+
import { collectSchedulerBinaryAdvisory } from "./health/scheduler-binary.js";
|
|
30
31
|
import { collectStashExposureAdvisory } from "./health/stash-exposure.js";
|
|
31
32
|
import { collectSurfacesAdvisories } from "./health/surfaces.js";
|
|
32
33
|
import { buildPerRunSummaries } from "./health/task-runs.js";
|
|
@@ -542,6 +543,12 @@ export async function akmHealth(options = {}) {
|
|
|
542
543
|
// above — started here, alongside it, and awaited later.
|
|
543
544
|
const versionDriftPromise = collectVersionDriftAdvisory(Boolean(options.probe), { cliVersion: pkgVersion });
|
|
544
545
|
versionDriftPromise.catch(() => undefined);
|
|
546
|
+
// #953: same --probe-gated, best-effort discipline as versionDriftPromise
|
|
547
|
+
// above.
|
|
548
|
+
const schedulerBinaryDriftPromise = collectSchedulerBinaryAdvisory(Boolean(options.probe), {
|
|
549
|
+
cliVersion: pkgVersion,
|
|
550
|
+
});
|
|
551
|
+
schedulerBinaryDriftPromise.catch(() => undefined);
|
|
545
552
|
const taskHistory = gatherTaskHistoryPhase(db, logsDb, since, stateDbPath, now);
|
|
546
553
|
const { tableNames, missingTables, probe } = taskHistory;
|
|
547
554
|
const { egressConfigView, thinkingOffEngines } = gatherEgressConfigPhase();
|
|
@@ -559,6 +566,7 @@ export async function akmHealth(options = {}) {
|
|
|
559
566
|
const improveRunsInLookbackWindow = countImproveRunsSince(db, engineLastUsedSinceIso);
|
|
560
567
|
const engineProbes = await engineProbesPromise;
|
|
561
568
|
const versionDrift = await versionDriftPromise;
|
|
569
|
+
const schedulerBinaryDrift = await schedulerBinaryDriftPromise;
|
|
562
570
|
// Read once, shared by the `thinking-control` check (#949) and the
|
|
563
571
|
// `metrics.llmUsage` report field below — same window, same aggregate.
|
|
564
572
|
const llmUsage = readLlmUsageAggregate(stateDbPath, since);
|
|
@@ -592,6 +600,7 @@ export async function akmHealth(options = {}) {
|
|
|
592
600
|
activeImproveStrategyEngines,
|
|
593
601
|
engineLastUsed,
|
|
594
602
|
improveRunsInLookbackWindow,
|
|
603
|
+
schedulerBinaryDrift,
|
|
595
604
|
};
|
|
596
605
|
for (const check of HEALTH_CHECKS) {
|
|
597
606
|
const result = check.run(checkContext);
|
|
@@ -45,13 +45,12 @@ export const EmbeddingConnectionConfigSchema = z
|
|
|
45
45
|
maxInputTokens: positiveInt.optional(),
|
|
46
46
|
/**
|
|
47
47
|
* Client-side per-request token budget — how many documents' estimated
|
|
48
|
-
* tokens fit in one HTTP request (default `DEFAULT_TOKEN_BUDGET` =
|
|
48
|
+
* tokens fit in one HTTP request (default `DEFAULT_TOKEN_BUDGET` = 6000
|
|
49
49
|
* in `src/llm/embedders/remote.ts`). With the 512-token `maxInputTokens`
|
|
50
|
-
* cap above, a request carries about
|
|
50
|
+
* cap above, a request carries about 11 documents by default.
|
|
51
51
|
*/
|
|
52
52
|
maxTokens: positiveInt.optional(),
|
|
53
53
|
batchSize: positiveInt.optional(),
|
|
54
|
-
chunkSize: positiveInt.optional(),
|
|
55
54
|
/**
|
|
56
55
|
* Ollama's `num_ctx` ONLY (#956) — sent verbatim as
|
|
57
56
|
* `options.num_ctx` on the native `/api/embed` request. It no longer also
|
package/dist/core/errors.js
CHANGED
|
@@ -82,6 +82,7 @@ const USAGE_HINTS = {
|
|
|
82
82
|
const TRANSIENT_HINTS = {
|
|
83
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.",
|
|
84
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.",
|
|
85
|
+
INDEX_DB_CONTENDED: "Another akm process is writing index.db; retry shortly, or pass --skip-if-locked on scheduled runs.",
|
|
85
86
|
};
|
|
86
87
|
/** Default hint for each NotFoundError code. */
|
|
87
88
|
const NOT_FOUND_HINTS = {
|
package/dist/indexer/indexer.js
CHANGED
|
@@ -7,12 +7,14 @@ import { detectAdapterId } from "../core/adapter/detect-adapter.js";
|
|
|
7
7
|
import { adapterForId } from "../core/adapter/registry.js";
|
|
8
8
|
import { isHttpUrl, toErrorMessage } from "../core/common.js";
|
|
9
9
|
import { concurrentMap } from "../core/concurrent.js";
|
|
10
|
-
import { ConfigError } from "../core/errors.js";
|
|
10
|
+
import { AkmError, ConfigError, TransientError } from "../core/errors.js";
|
|
11
|
+
import { probeLock } from "../core/file-lock.js";
|
|
11
12
|
import { defaultConcurrencyForEndpoint } from "../core/loopback.js";
|
|
12
13
|
import { classifyPathAccess, describeInaccessiblePath } from "../core/path-access.js";
|
|
13
14
|
import { getDbPath } from "../core/paths.js";
|
|
14
15
|
import { SCRIPT_EXTENSIONS } from "../core/recognition-util.js";
|
|
15
|
-
import {
|
|
16
|
+
import { formatLockHolderPid } from "../core/run-lock.js";
|
|
17
|
+
import { isSqliteContentionError, withStateDb } from "../core/state-db.js";
|
|
16
18
|
import { isVerbose, warn, warnOnce, warnVerbose } from "../core/warn.js";
|
|
17
19
|
import { disposeLoweredExecutionDispatchLease, } from "../integrations/agent/execution-lowering.js";
|
|
18
20
|
import { isLlmFeatureEnabled } from "../llm/feature-gate.js";
|
|
@@ -28,6 +30,7 @@ import { upsertUtilityScore } from "../storage/repositories/index-utility-reposi
|
|
|
28
30
|
import { getEmbeddingCount, isVecAvailable, isVecFastPathReady, warnIfVecMissing, } from "../storage/repositories/index-vec-repository.js";
|
|
29
31
|
import { assertIndexedWorkflowSourceIdentity, WorkflowSourceIdentityError } from "../workflows/source-files.js";
|
|
30
32
|
import { deleteStoredGraph } from "./db/graph-db.js";
|
|
33
|
+
import { indexRebuildLockPath } from "./index-rebuild-lock.js";
|
|
31
34
|
import { deriveEntryProvenance, deriveInstallations } from "./installations.js";
|
|
32
35
|
import { indexedPathMatchesOwner, resolveAdapterConceptOwner, } from "./lookup/adapter-concept-owner.js";
|
|
33
36
|
import { generateEmbeddingsForDb } from "./materialize-embeddings.js";
|
|
@@ -371,6 +374,44 @@ let akmIndexOverride;
|
|
|
371
374
|
export function _setAkmIndexForTests(fake) {
|
|
372
375
|
akmIndexOverride = fake;
|
|
373
376
|
}
|
|
377
|
+
/**
|
|
378
|
+
* Read-only description of the rebuild lock's current holder, appended to a
|
|
379
|
+
* reclassified index.db contention message when known (field follow-up to
|
|
380
|
+
* #956). `probeLock` only inspects the sentinel — it never acquires or
|
|
381
|
+
* mutates it — so this is safe to call from inside an error path.
|
|
382
|
+
*/
|
|
383
|
+
function describeIndexRebuildLockHolder() {
|
|
384
|
+
const probe = probeLock(indexRebuildLockPath());
|
|
385
|
+
if (probe.state !== "held")
|
|
386
|
+
return "";
|
|
387
|
+
return ` The rebuild lock is currently held by pid ${formatLockHolderPid({
|
|
388
|
+
pid: probe.holderPid,
|
|
389
|
+
launcherPid: probe.launcherPid ?? null,
|
|
390
|
+
})}.`;
|
|
391
|
+
}
|
|
392
|
+
/**
|
|
393
|
+
* Reclassify a contention-shaped error escaping the walk, index, or
|
|
394
|
+
* embedding phase into a retryable-shortly `TransientError` (field
|
|
395
|
+
* follow-up to #956, dev-team field review 2026-09-10): a concurrent writer
|
|
396
|
+
* (another `akm index`, a source-update embedding pass, the per-command
|
|
397
|
+
* background reindex) can make index.db busy, and the raw SQLite driver
|
|
398
|
+
* error ("database is locked") used to escape as exit 70
|
|
399
|
+
* (internal/unclassified) instead of the "retry shortly" contract exit 75
|
|
400
|
+
* gives a scheduler to branch on — mirroring `STATE_DB_CONTENDED`'s
|
|
401
|
+
* precedent for state.db (`core/state-db.ts`). Reuses the ONE shared
|
|
402
|
+
* classifier, `isSqliteContentionError`, rather than a second one. An error
|
|
403
|
+
* that is already a classified akm error (e.g. a `STATE_DB_CONTENDED`
|
|
404
|
+
* TransientError from an inner state.db write) is never re-wrapped — only a
|
|
405
|
+
* raw, unclassified error matching the shared contention shape is
|
|
406
|
+
* reclassified. Every other error is rethrown unchanged.
|
|
407
|
+
*/
|
|
408
|
+
export function reclassifyIndexDbContention(error) {
|
|
409
|
+
if (error instanceof AkmError || !isSqliteContentionError(error))
|
|
410
|
+
return error;
|
|
411
|
+
const contended = new TransientError(`akm's index database is busy (another akm process is writing it); retry shortly.${describeIndexRebuildLockHolder()}`, "INDEX_DB_CONTENDED");
|
|
412
|
+
contended.cause = error;
|
|
413
|
+
return contended;
|
|
414
|
+
}
|
|
374
415
|
export async function akmIndex(options) {
|
|
375
416
|
try {
|
|
376
417
|
const override = akmIndexOverride;
|
|
@@ -387,7 +428,7 @@ export async function akmIndex(options) {
|
|
|
387
428
|
// rollback before closing its borrowed unified handle.
|
|
388
429
|
}
|
|
389
430
|
}
|
|
390
|
-
throw error;
|
|
431
|
+
throw reclassifyIndexDbContention(error);
|
|
391
432
|
}
|
|
392
433
|
}
|
|
393
434
|
let indexTransactionHookForTests;
|
|
@@ -136,8 +136,14 @@ function deriveObservedEmbeddingIdentity(embedding, observedModel, observedVecto
|
|
|
136
136
|
* direct `RemoteEmbedder`) so every embedder branch — remote, local,
|
|
137
137
|
* deterministic, and test overrides via `_setEmbedderForTests` — is
|
|
138
138
|
* exercised identically to the main embedding pass.
|
|
139
|
+
*
|
|
140
|
+
* `maxInputTokens` must be the SAME cap the main pass below applies via
|
|
141
|
+
* {@link capEmbeddingText} — the stored vector for each sampled entry was
|
|
142
|
+
* produced from its capped text, so comparing against a fresh vector of the
|
|
143
|
+
* uncapped text would compare unlike inputs for any entry over the cap
|
|
144
|
+
* (#955).
|
|
139
145
|
*/
|
|
140
|
-
async function runEmbeddingCanary(db, config, signal) {
|
|
146
|
+
async function runEmbeddingCanary(db, config, signal, maxInputTokens) {
|
|
141
147
|
const samples = sampleEmbeddedEntriesForCanary(db, CANARY_SAMPLE_SIZE);
|
|
142
148
|
if (samples.length === 0) {
|
|
143
149
|
return { outcome: "keep", verified: false, viaIdentityMatch: false };
|
|
@@ -146,7 +152,15 @@ async function runEmbeddingCanary(db, config, signal) {
|
|
|
146
152
|
const skips = [];
|
|
147
153
|
let canaryVectors;
|
|
148
154
|
try {
|
|
149
|
-
canaryVectors = await embedBatch(
|
|
155
|
+
canaryVectors = await embedBatch(
|
|
156
|
+
// #955: the stored vector for each sample was produced from
|
|
157
|
+
// capEmbeddingText(searchText, maxInputTokens) — the main pass below
|
|
158
|
+
// caps every document before embedding it. The canary must re-embed
|
|
159
|
+
// the SAME capped text, or an entry over the cap compares a fresh
|
|
160
|
+
// vector of a different input against a stored vector of the capped
|
|
161
|
+
// one, and a genuine model match can read as a rebuild-worthy
|
|
162
|
+
// mismatch for reasons unrelated to the model.
|
|
163
|
+
samples.map((sample) => capEmbeddingText(sample.searchText, maxInputTokens).text), config.embedding, signal, (skip) => skips.push(skip), (_indices, _embeddings, model) => {
|
|
150
164
|
if (model)
|
|
151
165
|
observedModel = model;
|
|
152
166
|
});
|
|
@@ -252,6 +266,10 @@ export async function generateEmbeddingsForDb(db, config, onProgress, signal, en
|
|
|
252
266
|
let targetEntryIds = entryIds;
|
|
253
267
|
/** Set only on an actual rebuild, so the up-front "Re-embedding N entries" line names why. */
|
|
254
268
|
let rebuildReason;
|
|
269
|
+
// Resolved once and reused by both the canary (below) and the main pass's
|
|
270
|
+
// cap loop (further down) — the same cap must apply to both, or the canary
|
|
271
|
+
// compares a differently-capped text against the stored vector (#955).
|
|
272
|
+
const maxInputTokens = config.embedding?.maxInputTokens ?? DEFAULT_MAX_INPUT_TOKENS;
|
|
255
273
|
if (opts?.forceReembed) {
|
|
256
274
|
// `akm index --reembed`: an explicit operator override, skips the canary
|
|
257
275
|
// entirely. The new fingerprint (and identity, now stale/unknown until
|
|
@@ -272,7 +290,7 @@ export async function generateEmbeddingsForDb(db, config, onProgress, signal, en
|
|
|
272
290
|
rebuildReason = "forced by --reembed";
|
|
273
291
|
}
|
|
274
292
|
else if (storedFingerprint && storedFingerprint !== currentFingerprint) {
|
|
275
|
-
const decision = await runEmbeddingCanary(db, config, signal);
|
|
293
|
+
const decision = await runEmbeddingCanary(db, config, signal, maxInputTokens);
|
|
276
294
|
if (decision.outcome === "unverifiable") {
|
|
277
295
|
// Destroying a good index because the server happens to be down right
|
|
278
296
|
// now is worse than leaving a rename unverified until the next run —
|
|
@@ -377,12 +395,12 @@ export async function generateEmbeddingsForDb(db, config, onProgress, signal, en
|
|
|
377
395
|
return reusedCount > 0 ? { success: true, vecInsertFailures: vecFailedCount } : { success: true };
|
|
378
396
|
}
|
|
379
397
|
// Cap each document's embedded text at
|
|
380
|
-
// embedding.maxInputTokens (default DEFAULT_MAX_INPUT_TOKENS
|
|
381
|
-
//
|
|
382
|
-
//
|
|
398
|
+
// embedding.maxInputTokens (default DEFAULT_MAX_INPUT_TOKENS, resolved
|
|
399
|
+
// once above so the canary uses the identical cap) instead of ever
|
|
400
|
+
// failing a whole batch over one oversized entry — truncation keeps the
|
|
401
|
+
// head of the text, unicode-safe. A document is skipped only when its
|
|
383
402
|
// head is empty (the impossible case: nothing left to embed), never
|
|
384
403
|
// merely for being long.
|
|
385
|
-
const maxInputTokens = config.embedding?.maxInputTokens ?? DEFAULT_MAX_INPUT_TOKENS;
|
|
386
404
|
let truncatedCount = 0;
|
|
387
405
|
const texts = [];
|
|
388
406
|
const pendingEntries = [];
|
|
@@ -529,6 +547,23 @@ export async function generateEmbeddingsForDb(db, config, onProgress, signal, en
|
|
|
529
547
|
}
|
|
530
548
|
return;
|
|
531
549
|
}
|
|
550
|
+
// #954: a "budget-lowered" event is the same kind of notice as
|
|
551
|
+
// "retrying" above — the run's first context-size rejection just
|
|
552
|
+
// shrank the request budget for everything not yet dispatched, but
|
|
553
|
+
// THIS rejected batch's own indices are still being split and
|
|
554
|
+
// retried by the embedder (their real stored/failed outcome lands in
|
|
555
|
+
// a later onBatch call). Nothing here has settled, so it must never
|
|
556
|
+
// touch storage, only report the notice — one line, at most once per
|
|
557
|
+
// run.
|
|
558
|
+
if (outcome?.outcome === "budget-lowered") {
|
|
559
|
+
if (reportPerBatchLine) {
|
|
560
|
+
onProgress({
|
|
561
|
+
phase: "embeddings",
|
|
562
|
+
message: `[embed] batch ${outcome.batchIndex}/${outcome.batchCount}: ${outcome.docCount} docs, ${outcome.requestTokens.toLocaleString()} tokens → ${outcome.reason}`,
|
|
563
|
+
});
|
|
564
|
+
}
|
|
565
|
+
return;
|
|
566
|
+
}
|
|
532
567
|
if (model)
|
|
533
568
|
observedModel = model;
|
|
534
569
|
// A batch that delivered at least one real embedding proves the
|
|
@@ -542,7 +577,8 @@ export async function generateEmbeddingsForDb(db, config, onProgress, signal, en
|
|
|
542
577
|
}
|
|
543
578
|
db.transaction(() => {
|
|
544
579
|
for (let k = 0; k < indices.length; k++) {
|
|
545
|
-
const
|
|
580
|
+
const index = indices[k];
|
|
581
|
+
const entry = pendingEntries[index];
|
|
546
582
|
if (!entry)
|
|
547
583
|
continue;
|
|
548
584
|
const embedding = batchEmbeddings[k];
|
|
@@ -555,7 +591,13 @@ export async function generateEmbeddingsForDb(db, config, onProgress, signal, en
|
|
|
555
591
|
const result = upsertEmbedding(db, entry.id, embedding);
|
|
556
592
|
if (result.stored) {
|
|
557
593
|
storedCount++;
|
|
558
|
-
|
|
594
|
+
// #954: sum the estimate of the text actually sent —
|
|
595
|
+
// `texts[index]` is the capped string `embedBatch` was handed,
|
|
596
|
+
// parallel to `pendingEntries` by construction above (the
|
|
597
|
+
// `entry` guard covers both) — not `entry.searchText`, which is
|
|
598
|
+
// the pre-cap original and overstates throughput for every
|
|
599
|
+
// entry over the cap.
|
|
600
|
+
storedTokens += estimateTokenCount(texts[index]);
|
|
559
601
|
}
|
|
560
602
|
else {
|
|
561
603
|
skippedCount++;
|