akm-cli 0.9.15-beta.2 → 0.9.15-beta.4

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.
@@ -29,7 +29,7 @@ import { parseFrontmatter } from "../../core/asset/frontmatter.js";
29
29
  import { conceptIdFromTypeName, parseRefInput } from "../../core/asset/resolve-ref.js";
30
30
  import { DESCRIPTION_MAX_CHARS, requiresDescription } from "../../core/authoring-rules.js";
31
31
  import { loadConfig } from "../../core/config/config.js";
32
- import { ConfigError } from "../../core/errors.js";
32
+ import { ConfigError, UsageError } from "../../core/errors.js";
33
33
  import { appendEvent, readEvents } from "../../core/events.js";
34
34
  import { lintLessonContent } from "../../core/lesson-lint.js";
35
35
  import { parseEmbeddedJsonResponse } from "../../core/parse.js";
@@ -1371,6 +1371,92 @@ async function resolveReflectSource(options, stash, emitReflectFailed) {
1371
1371
  }
1372
1372
  return { assetContent, parsedRef };
1373
1373
  }
1374
+ /**
1375
+ * #952 — the flat REFLECT_CONTENT_CAP (12 000 chars) exists only to avoid
1376
+ * E2BIG when the prompt travels through CLI argv (agent/SDK runners). The
1377
+ * direct-LLM HTTP path never touches argv, so it can use the resolved
1378
+ * engine's own context window instead. The reserve for "the rest of the
1379
+ * prompt" is measured directly (not guessed): build the same prompt with
1380
+ * the content cap forced to zero and use its length as the overhead, so
1381
+ * feedback/standards/schema-hints/prior-draft size is accounted for
1382
+ * exactly, per this call. A reflect rewrite returns a body roughly the
1383
+ * size of the input, so the budget only spends HALF of the usable window
1384
+ * on input content and reserves the other half for the model's own
1385
+ * output — otherwise a full-context request leaves no room for a
1386
+ * response. Never drops below the flat floor.
1387
+ *
1388
+ * Shared by the real dispatch path ({@link runReflectRefineIterations}) and
1389
+ * `renderReflectPromptPreview`'s `--show-prompt` preview, so the preview
1390
+ * renders the exact prompt reflect would actually send for LLM runners
1391
+ * instead of always the flat-cap prompt.
1392
+ */
1393
+ function computeReflectContentBudgetChars(promptInput, runnerSpec) {
1394
+ return runnerIsLlm(runnerSpec) && promptInput.assetContent?.trim()
1395
+ ? Math.max(REFLECT_CONTENT_CAP, Math.floor(((runnerSpec.connection.contextLength ?? DEFAULT_CONTEXT_LENGTH_TOKENS) * CHARS_PER_TOKEN -
1396
+ buildReflectPrompt({ ...promptInput, contentBudgetChars: 0 }).prompt.length) /
1397
+ 2))
1398
+ : undefined;
1399
+ }
1400
+ /**
1401
+ * #952 — gather every read-only prompt-input source {@link buildReflectPromptInput}
1402
+ * folds into a `ReflectPromptInput`: recent feedback, schema/lint hints, related
1403
+ * lessons, previously-rejected proposals, and stash standards context.
1404
+ *
1405
+ * Shared by the real dispatch path (`akmReflect`'s step 4, via
1406
+ * {@link runReflectRefineIterations}) and `renderReflectPromptPreview`'s
1407
+ * `--show-prompt` preview, so both gather from exactly one definition instead
1408
+ * of two copies that can drift out of agreement.
1409
+ */
1410
+ async function gatherReflectPromptSources(options, stash, parsedRef, assetContent, assetCtx) {
1411
+ const feedback = readRecentFeedback(options.ref ? (options.itemRef ?? durableImproveRef(options.ref)) : undefined, options.eventsCtx);
1412
+ const schemaHints = buildSchemaHints(parsedRef?.type ?? "", assetContent);
1413
+ const relatedLessons = options.ref && parsedRef ? await readRelatedLessons(assetCtx, stash, options.ref, parsedRef, options.itemRef) : [];
1414
+ // Reflexion-style verbal-RL: inject rejected proposals so the agent avoids
1415
+ // reproducing proposals that have already been reviewed and refused.
1416
+ const rejectedProposals = readRejectedProposals(stash, options.ref, options.ctx);
1417
+ // Standards "rulebook" for this target — stash convention/meta facts; empty
1418
+ // when none fire.
1419
+ const standardsContext = resolveStandardsContext(options.ref, stash);
1420
+ return { feedback, schemaHints, relatedLessons, rejectedProposals, standardsContext };
1421
+ }
1422
+ /**
1423
+ * #952 — assemble the `ReflectPromptInput` object literal reflect actually
1424
+ * sends, from gathered sources plus the per-call values (draft path, prior
1425
+ * draft). Shared by the real dispatch path ({@link runReflectRefineIterations})
1426
+ * and `renderReflectPromptPreview`'s `--show-prompt` preview — including
1427
+ * `avoidPatterns`, which the preview previously omitted even though a live
1428
+ * improve loop passes it (recent-error context, O-5 / #378).
1429
+ */
1430
+ function buildReflectPromptInput(args) {
1431
+ const { options, parsedRef, assetContent, sources, runnerSpec, draftFilePath, priorDraft } = args;
1432
+ const { feedback, schemaHints, relatedLessons, rejectedProposals, standardsContext } = sources;
1433
+ const outputMode = runnerIsLlm(runnerSpec)
1434
+ ? wantsJsonSchemaOutput(runnerSpec.connection)
1435
+ ? "json_schema"
1436
+ : "framed_markdown"
1437
+ : undefined;
1438
+ return {
1439
+ ...(options.ref ? { ref: options.ref } : {}),
1440
+ ...(parsedRef?.type ? { type: parsedRef.type } : {}),
1441
+ ...(parsedRef?.name ? { name: parsedRef.name } : {}),
1442
+ ...(assetContent !== undefined ? { assetContent } : {}),
1443
+ ...(feedback.length > 0 ? { feedback } : {}),
1444
+ ...(schemaHints.length > 0 ? { schemaHints } : {}),
1445
+ ...(relatedLessons.length > 0 ? { relatedLessons } : {}),
1446
+ ...(options.task ? { task: options.task } : {}),
1447
+ ...(standardsContext.trim() ? { standardsContext } : {}),
1448
+ ...(options.avoidPatterns && options.avoidPatterns.length > 0 ? { avoidPatterns: options.avoidPatterns } : {}),
1449
+ ...(rejectedProposals.length > 0 ? { rejectedProposals } : {}),
1450
+ // R-1: inject prior draft as self-critique target on iterations > 0
1451
+ ...(priorDraft !== undefined ? { priorDraft } : {}),
1452
+ // Issue A (#reflect-pipeline file-write contract): when the runner can
1453
+ // touch the filesystem, instruct the agent to write the proposal body
1454
+ // to a tmp file instead of inlining it in JSON. Avoids parse failures
1455
+ // on long bodies (e.g. knowledge/systems/KOKORO_USAGE_GUIDE 8.4KB).
1456
+ ...(draftFilePath ? { draftFilePath } : {}),
1457
+ ...(outputMode ? { outputMode } : {}),
1458
+ };
1459
+ }
1374
1460
  /**
1375
1461
  * Run the agent with the optional Self-Refine loop (R-1 / #372): up to
1376
1462
  * `maxRefineIters` invocations, each injecting the prior draft as self-critique
@@ -1379,17 +1465,12 @@ async function resolveReflectSource(options, stash, emitReflectFailed) {
1379
1465
  * result + last draft path. Extracted verbatim from `akmReflect`.
1380
1466
  */
1381
1467
  async function runReflectRefineIterations(args) {
1382
- const { options, parsedRef, assetContent, feedback, schemaHints, relatedLessons, rejectedProposals, standardsContext, runnerSpec, lease, agentEnv, draftPathsToCleanup, onNotices, } = args;
1468
+ const { options, parsedRef, assetContent, sources, runnerSpec, lease, agentEnv, draftPathsToCleanup, onNotices } = args;
1383
1469
  const maxRefineIters = Math.max(1, options.maxRefineIters ?? 1);
1384
1470
  // Determine whether this dispatch can honour the file-write contract.
1385
1471
  // Agent CLI + OpenCode SDK runners both have filesystem access; the direct
1386
1472
  // LLM HTTP runner does NOT.
1387
1473
  const canRunnerWriteFile = runnerSupportsFileWrite(runnerSpec);
1388
- const outputMode = runnerIsLlm(runnerSpec)
1389
- ? wantsJsonSchemaOutput(runnerSpec.connection)
1390
- ? "json_schema"
1391
- : "framed_markdown"
1392
- : undefined;
1393
1474
  // Initialized to a sentinel; always overwritten in the first loop iteration
1394
1475
  // (maxRefineIters is clamped to >= 1 above).
1395
1476
  let result = {};
@@ -1404,44 +1485,16 @@ async function runReflectRefineIterations(args) {
1404
1485
  draftPathsToCleanup.push(iterDraftPath);
1405
1486
  lastDraftPath = iterDraftPath;
1406
1487
  }
1407
- const promptInput = {
1408
- ...(options.ref ? { ref: options.ref } : {}),
1409
- ...(parsedRef?.type ? { type: parsedRef.type } : {}),
1410
- ...(parsedRef?.name ? { name: parsedRef.name } : {}),
1411
- ...(assetContent !== undefined ? { assetContent } : {}),
1412
- ...(feedback.length > 0 ? { feedback } : {}),
1413
- ...(schemaHints.length > 0 ? { schemaHints } : {}),
1414
- ...(relatedLessons.length > 0 ? { relatedLessons } : {}),
1415
- ...(options.task ? { task: options.task } : {}),
1416
- ...(standardsContext.trim() ? { standardsContext } : {}),
1417
- ...(options.avoidPatterns && options.avoidPatterns.length > 0 ? { avoidPatterns: options.avoidPatterns } : {}),
1418
- ...(rejectedProposals.length > 0 ? { rejectedProposals } : {}),
1419
- // R-1: inject prior draft as self-critique target on iterations > 0
1420
- ...(priorDraft !== undefined ? { priorDraft } : {}),
1421
- // Issue A (#reflect-pipeline file-write contract): when the runner can
1422
- // touch the filesystem, instruct the agent to write the proposal body
1423
- // to a tmp file instead of inlining it in JSON. Avoids parse failures
1424
- // on long bodies (e.g. knowledge/systems/KOKORO_USAGE_GUIDE 8.4KB).
1425
- ...(iterDraftPath ? { draftFilePath: iterDraftPath } : {}),
1426
- ...(outputMode ? { outputMode } : {}),
1427
- };
1428
- // #952 — the flat REFLECT_CONTENT_CAP (12 000 chars) exists only to avoid
1429
- // E2BIG when the prompt travels through CLI argv (agent/SDK runners). The
1430
- // direct-LLM HTTP path never touches argv, so it can use the resolved
1431
- // engine's own context window instead. The reserve for "the rest of the
1432
- // prompt" is measured directly (not guessed): build the same prompt with
1433
- // the content cap forced to zero and use its length as the overhead, so
1434
- // feedback/standards/schema-hints/prior-draft size is accounted for
1435
- // exactly, per this call. A reflect rewrite returns a body roughly the
1436
- // size of the input, so the budget only spends HALF of the usable window
1437
- // on input content and reserves the other half for the model's own
1438
- // output — otherwise a full-context request leaves no room for a
1439
- // response. Never drops below the flat floor.
1440
- const contentBudgetChars = runnerIsLlm(runnerSpec) && assetContent?.trim()
1441
- ? Math.max(REFLECT_CONTENT_CAP, Math.floor(((runnerSpec.connection.contextLength ?? DEFAULT_CONTEXT_LENGTH_TOKENS) * CHARS_PER_TOKEN -
1442
- buildReflectPrompt({ ...promptInput, contentBudgetChars: 0 }).prompt.length) /
1443
- 2))
1444
- : undefined;
1488
+ const promptInput = buildReflectPromptInput({
1489
+ options,
1490
+ parsedRef,
1491
+ assetContent,
1492
+ sources,
1493
+ runnerSpec,
1494
+ draftFilePath: iterDraftPath,
1495
+ priorDraft,
1496
+ });
1497
+ const contentBudgetChars = computeReflectContentBudgetChars(promptInput, runnerSpec);
1445
1498
  const { prompt } = buildReflectPrompt({
1446
1499
  ...promptInput,
1447
1500
  ...(contentBudgetChars !== undefined ? { contentBudgetChars } : {}),
@@ -1459,10 +1512,10 @@ async function runReflectRefineIterations(args) {
1459
1512
  ...(options.signal ? { signal: options.signal } : {}),
1460
1513
  priorDraft,
1461
1514
  iteration: iter,
1462
- ...(outputMode === "json_schema"
1515
+ ...(promptInput.outputMode === "json_schema"
1463
1516
  ? { responseSchema: options.ref ? REFLECT_JSON_SCHEMA : REFLECT_UNSCOPED_JSON_SCHEMA }
1464
1517
  : {}),
1465
- outputMode: outputMode ?? "framed_markdown",
1518
+ outputMode: promptInput.outputMode ?? "framed_markdown",
1466
1519
  ...(options.ref ? { targetRef: options.ref } : {}),
1467
1520
  allowRepair: repairAttempts === 0,
1468
1521
  ...(options.chat ? { chat: options.chat } : {}),
@@ -1650,6 +1703,62 @@ function validateReflectPayloadRef(args) {
1650
1703
  return undefined;
1651
1704
  }
1652
1705
  }
1706
+ /**
1707
+ * #952 — render the composed reflect prompt for exactly one asset with no
1708
+ * engine dispatch. Reuses every read-only step `akmReflect` performs before
1709
+ * {@link buildReflectPrompt} (source resolution, runner resolution, feedback /
1710
+ * schema-hint / related-lesson / rejected-proposal gathering) and stops right
1711
+ * there: no dispatch lease is acquired, no request is sent, and — because the
1712
+ * `emitReflectFailed` callback passed to {@link resolveReflectSource} here is
1713
+ * a no-op — no `reflect_invoked`/`reflect_completed` event is appended either.
1714
+ *
1715
+ * `akm improve <ref> --show-prompt` (`improve-cli.ts`) is the CLI surface: a
1716
+ * field operator uses it to see the exact prompt reflect would send, in
1717
+ * seconds, without running a full improve cycle or needing a reachable
1718
+ * engine.
1719
+ */
1720
+ export async function renderReflectPromptPreview(options) {
1721
+ if (!options.ref) {
1722
+ throw new UsageError("renderReflectPromptPreview requires options.ref.", "INVALID_FLAG_VALUE");
1723
+ }
1724
+ const ref = options.ref;
1725
+ const stash = resolveRunStashDir(options.stashDir);
1726
+ const sourceResolved = await resolveReflectSource(options, stash, () => {
1727
+ // No event emitted: this is a read-only preview, not a real invocation.
1728
+ });
1729
+ if ("failure" in sourceResolved) {
1730
+ const { failure } = sourceResolved;
1731
+ throw new UsageError((!failure.ok && failure.error) || `Reflect cannot preview ref "${ref}".`, "INVALID_FLAG_VALUE");
1732
+ }
1733
+ const { assetContent, parsedRef } = sourceResolved;
1734
+ const { runnerSpec, engineName } = resolveReflectRunner(options);
1735
+ const ctx = buildReflectRunContext({ options, stash, config: options.config ?? loadConfig(), runnerSpec });
1736
+ const assetCtx = ctx.withFreshAssetMemo();
1737
+ const sources = await gatherReflectPromptSources(options, stash, parsedRef, assetContent, assetCtx);
1738
+ const canRunnerWriteFile = runnerSupportsFileWrite(runnerSpec);
1739
+ // Same tmp-path synthesis a real dispatch would use (Issue A) — never
1740
+ // written to, since this preview never runs the agent.
1741
+ const draftFilePath = canRunnerWriteFile ? synthesizeReflectDraftPath(ref) : undefined;
1742
+ const previewPromptInput = buildReflectPromptInput({
1743
+ options,
1744
+ parsedRef,
1745
+ assetContent,
1746
+ sources,
1747
+ runnerSpec,
1748
+ draftFilePath,
1749
+ priorDraft: undefined,
1750
+ });
1751
+ // #952 — mirror the real dispatch path's context-aware content budget (see
1752
+ // computeReflectContentBudgetChars) so the preview shows the exact prompt
1753
+ // reflect would send: an LLM engine with a large context window gets the
1754
+ // full asset with no truncation marker, not the flat 12 000-char cap.
1755
+ const contentBudgetChars = computeReflectContentBudgetChars(previewPromptInput, runnerSpec);
1756
+ const { prompt } = buildReflectPrompt({
1757
+ ...previewPromptInput,
1758
+ ...(contentBudgetChars !== undefined ? { contentBudgetChars } : {}),
1759
+ });
1760
+ return { ref, prompt, engine: engineName, engineKind: runnerSpec.kind };
1761
+ }
1653
1762
  export async function akmReflect(options = {}) {
1654
1763
  const stash = resolveRunStashDir(options.stashDir);
1655
1764
  // Build lazy event emitters. The invocation row is committed only after the
@@ -1695,17 +1804,7 @@ export async function akmReflect(options = {}) {
1695
1804
  // 4. Build the shared prompt inputs — feedback, hints, lessons, rejected
1696
1805
  // proposals. These are stable across refinement iterations; only the
1697
1806
  // `priorDraft` field changes per-iteration (R-1 / #372).
1698
- const feedback = readRecentFeedback(options.ref ? (options.itemRef ?? durableImproveRef(options.ref)) : undefined, options.eventsCtx);
1699
- const schemaHints = buildSchemaHints(parsedRef?.type ?? "", assetContent);
1700
- const relatedLessons = options.ref && parsedRef
1701
- ? await readRelatedLessons(assetCtx, stash, options.ref, parsedRef, options.itemRef)
1702
- : [];
1703
- // Reflexion-style verbal-RL: inject rejected proposals so the agent avoids
1704
- // reproducing proposals that have already been reviewed and refused.
1705
- const rejectedProposals = readRejectedProposals(stash, options.ref, options.ctx);
1706
- // Standards "rulebook" for this target — stash convention/meta facts; empty
1707
- // when none fire.
1708
- const standardsContext = resolveStandardsContext(options.ref, stash);
1807
+ const sources = await gatherReflectPromptSources(options, stash, parsedRef, assetContent, assetCtx);
1709
1808
  // 5. Spawn the agent — with the optional Self-Refine loop (R-1 / #372),
1710
1809
  // extracted to {@link runReflectRefineIterations}.
1711
1810
  const agentEnv = options.eventSource === "improve" ? { AKM_EVENT_SOURCE: "improve" } : {};
@@ -1725,11 +1824,7 @@ export async function akmReflect(options = {}) {
1725
1824
  options,
1726
1825
  parsedRef,
1727
1826
  assetContent,
1728
- feedback,
1729
- schemaHints,
1730
- relatedLessons,
1731
- rejectedProposals,
1732
- standardsContext,
1827
+ sources,
1733
1828
  runnerSpec,
1734
1829
  lease: generationLease,
1735
1830
  agentEnv,
@@ -1808,7 +1903,7 @@ export async function akmReflect(options = {}) {
1808
1903
  qualityGateSkippedNoJudge,
1809
1904
  qualityJudgeRunner,
1810
1905
  qualityJudgeLease,
1811
- feedback,
1906
+ feedback: sources.feedback,
1812
1907
  stash,
1813
1908
  emitReflectFailed,
1814
1909
  onNotices: collectExecutionNotices,
@@ -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` = 8000
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 16 documents by default.
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
@@ -82,6 +82,8 @@ 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.",
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.",
85
87
  };
86
88
  /** Default hint for each NotFoundError code. */
87
89
  const NOT_FOUND_HINTS = {
@@ -6,7 +6,8 @@ import { randomUUID } from "node:crypto";
6
6
  import fs from "node:fs";
7
7
  import path from "node:path";
8
8
  import { sleepSync } from "../runtime.js";
9
- import { ConfigError } from "./errors.js";
9
+ import { backoffDelay } from "./common.js";
10
+ import { ConfigError, TransientError } from "./errors.js";
10
11
  import { createLockPayload, probeLock, reclaimStaleLock, releaseLock, tryAcquireLockSync } from "./file-lock.js";
11
12
  import { getMaintenanceBarrierPath } from "./paths.js";
12
13
  const heldBarrierContext = new AsyncLocalStorage();
@@ -24,6 +25,26 @@ const heldBarrierContext = new AsyncLocalStorage();
24
25
  * (`commands/improve/extract.ts`).
25
26
  */
26
27
  const MAINTENANCE_BARRIER_STALE_AFTER_MS = 5 * 60 * 1000;
28
+ /**
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 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). Bounded short so a genuinely wedged holder
35
+ * still surfaces the busy error promptly rather than making a losing
36
+ * process hang — comfortably above the barrier's normal hold time, well
37
+ * below a length that would make this feel like the blocking lock #872
38
+ * removed. Never applies to the rebuild lock itself, which stays
39
+ * non-blocking (#872).
40
+ */
41
+ const MAINTENANCE_BARRIER_BUSY_RETRY_BOUND_MS = 1_500;
42
+ let busyRetryBoundMsForTests;
43
+ /** Test-only override for {@link MAINTENANCE_BARRIER_BUSY_RETRY_BOUND_MS}, so a unit test can exercise the
44
+ * exhausted-retry throw without a real ~1.5s wait. Restored via tests/_helpers/seams.ts's resetAllSeams(). */
45
+ export function _setMaintenanceBarrierBusyRetryBoundMsForTests(ms) {
46
+ busyRetryBoundMsForTests = ms;
47
+ }
27
48
  /**
28
49
  * Serialize the short critical section that creates each long-lived AKM lock,
29
50
  * lease, or state activity. The operation keeps its own ownership record; this
@@ -44,11 +65,19 @@ export function tryAcquireMaintenanceBarrier() {
44
65
  return undefined;
45
66
  }
46
67
  export function acquireMaintenanceBarrier() {
47
- const release = tryAcquireMaintenanceBarrier();
48
- if (release)
49
- return release;
50
- throw new ConfigError(`AKM maintenance is in progress (barrier ${getMaintenanceBarrierPath()}); retry after it completes. ` +
51
- `A sentinel older than ${MAINTENANCE_BARRIER_STALE_AFTER_MS / 60_000} minute(s) is reclaimed automatically on the next attempt.`, "INVALID_CONFIG_FILE");
68
+ const boundMs = busyRetryBoundMsForTests ?? MAINTENANCE_BARRIER_BUSY_RETRY_BOUND_MS;
69
+ const deadline = Date.now() + boundMs;
70
+ for (let attempt = 0;; attempt += 1) {
71
+ const release = tryAcquireMaintenanceBarrier();
72
+ if (release)
73
+ return release;
74
+ const remainingMs = deadline - Date.now();
75
+ if (remainingMs <= 0)
76
+ break;
77
+ sleepSync(Math.min(backoffDelay(attempt), remainingMs));
78
+ }
79
+ throw new TransientError(`AKM maintenance is in progress (barrier ${getMaintenanceBarrierPath()}); retry shortly. ` +
80
+ `A sentinel older than ${MAINTENANCE_BARRIER_STALE_AFTER_MS / 60_000} minute(s) is reclaimed automatically on the next attempt.`, "MAINTENANCE_BARRIER_BUSY");
52
81
  }
53
82
  export function withMaintenanceStartBarrier(run) {
54
83
  if (heldBarrierContext.getStore()?.active)
@@ -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 { withStateDb } from "../core/state-db.js";
16
+ import { formatLockHolderPid } from "../core/run-lock.js";
17
+ import { isSqliteContentionError, withStateDb } from "../core/state-db.js";
16
18
  import { isVerbose, warn, warnOnce, warnVerbose } from "../core/warn.js";
17
19
  import { disposeLoweredExecutionDispatchLease, } from "../integrations/agent/execution-lowering.js";
18
20
  import { isLlmFeatureEnabled } from "../llm/feature-gate.js";
@@ -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(samples.map((sample) => sample.searchText), config.embedding, signal, (skip) => skips.push(skip), (_indices, _embeddings, model) => {
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) instead of
381
- // ever failing a whole batch over one oversized entry truncation keeps
382
- // the head of the text, unicode-safe. A document is skipped only when its
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 entry = pendingEntries[indices[k]];
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
- storedTokens += estimateTokenCount(entry.searchText);
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++;
@@ -472,3 +472,21 @@ export async function probeLlmEndpoint(config, timeoutMs = 3_000) {
472
472
  return { reachable: false, error: err instanceof Error ? err.message : String(err) };
473
473
  }
474
474
  }
475
+ /**
476
+ * Endpoint-keyed probe memoization (#957): every caller that probes several
477
+ * engine connections in one pass (`akm health`'s engine checks, `akm
478
+ * improve --require-engines`) shares one in-flight probe per distinct
479
+ * endpoint (trailing slashes normalized) instead of firing a duplicate probe
480
+ * when two engines point at the same server. `cache` must be scoped to one
481
+ * invocation and never shared across calls — a stale "reachable" surviving
482
+ * past the run that produced it is the failure mode this exists to avoid.
483
+ */
484
+ export function probeEndpointOnce(connection, cache, probe) {
485
+ const key = connection.endpoint.replace(/\/+$/, "");
486
+ let pending = cache.get(key);
487
+ if (!pending) {
488
+ pending = probe(connection);
489
+ cache.set(key, pending);
490
+ }
491
+ return pending;
492
+ }