Migration notes for akm v0.9.15

`RUN_LEASE_HELD` (a workflow run-lease refusal) and the new
`STATE_DB_CONTENDED` (state.db write contention) now exit 75 (`EX_TEMPFAIL`)
instead of exit 2 and exit 70 respectively. Both are unrelated to a bad command
line — they mean "another akm process is using this database or lease right
now, retry shortly." If a script or scheduler wrapper special-cases exit 2 to
detect a held lease, switch it to exit 75, or read the JSON envelope's `code`
field instead.

A concurrent `akm index` now also exits 75 instead of exit 78 or exit 70. A
2026-09-10 field report found a second `akm index` (no `--skip-if-locked`)
colliding on the short internal barrier that registers the opt-in rebuild
lock could fail with `{"code":"INVALID_CONFIG_FILE"}` at exit 78 — a
config-error exit that told a supervisor to stop retrying ordinary
contention between two legitimate runs. That barrier now retries briefly
before giving up, and a busy barrier is reclassified as `TransientError`
code `MAINTENANCE_BARRIER_BUSY` (exit 75) instead. Contention on index.db
itself was already reclassified from a raw `database is locked` error (exit
70) to `TransientError` code `INDEX_DB_CONTENDED` (exit 75). Either way, the
fix for a scheduled or opportunistic index run is the same:
`akm index --skip-if-locked` steps aside (exit 0) instead of contending at
all.

Two more contention paths collided on the same "config error instead of
transient" anti-pattern (#948 field follow-up, 2026-09-10). `akm improve`'s
whole-run lock (only one `improve` runs at a time) used to fail a losing
contender with `{"code":"INVALID_CONFIG_FILE"}` at exit 78 when no
`--skip-if-locked` was passed — a config-error exit for ordinary contention
between two legitimate `improve` invocations. It is now `TransientError`
code `IMPROVE_LOCK_HELD` at exit 75, naming the current holder's pid and
start time; `--skip-if-locked` is unchanged (still exit 0). Separately, the
index rebuild's embedding-verification step (`[index:verify] Semantic search
verification failed: ...`) could still surface a raw, unclassified
`database is locked` message even though the acquisition path was already
fixed — that message is now built through the same `INDEX_DB_CONTENDED`
reclassification as the rest of the index path. This step does not throw, so
it does not change `akm index`'s own exit code; only the message text
changed.

The six shipped scheduled `improve` task templates now run with
`--require-engines`, which aborts (exit 78) before any index work when a
process's engine or credential cannot be resolved in the task's own
environment, instead of silently skipping that process. This only affects new
installs and new `akm setup` task seeding — an existing install's
already-materialized task files are not rewritten. To get the same protection
on an existing scheduled task, add `--require-engines` to its `run:` command
yourself, then run `akm task sync`.

`--require-engines` now also runs a bounded reachability probe against each
distinct engine endpoint (the same probe `akm health` already uses), not
only a config/credential check. A field re-test found the flag let a run
through to a fully dead endpoint, which then sat silent for minutes making
no progress and no exit — the flag's exit-78 abort now catches that case up
front, naming the unreachable engine and endpoint, before any index work.
If a scheduled `--require-engines` run starts failing at exit 78 after
upgrading, check that the engine's endpoint actually answers — this is the
flag doing its documented job on a condition it previously missed, not a
new failure mode. Separately, `--timeout-ms` and an engine's own configured
timeout already aborted an in-flight request correctly, and SIGTERM/SIGINT
already ended a run within its documented grace period — both confirmed,
not changed, by this investigation. A live run now also prints one
default-level line if it waits more than a few seconds on its first engine
response, so a slow-but-alive run and a dead one are never indistinguishable
from silence alone.

`akm health --no-probe` now also skips the `cli-version` update check (a GitHub
release lookup), alongside the engine-reachability checks it already skipped.
An air-gapped or offline host's existing `--no-probe` habit now suppresses both
network calls with no config change needed.

Thinking-control wire forms (`chat_template_kwargs.enable_thinking` and
`enable_thinking`) are now sent for every provider whenever `enableThinking`
resolves to a value, not only when `provider: "vllm"` is set. If your engine
sits behind Bifrost and the gateway does not honor either form, also set
`reasoningEffort: "none"` on that engine. If your engine talks to a strict
hosted API that rejects unknown request keys, leave `enableThinking` unset on
that engine so neither wire form is sent.

An `embedding.model` rename no longer forces a full re-embed by itself: `akm
index` re-embeds a small sample first and keeps the existing vectors when they
still verify against the endpoint. `akm index --reembed` forces a full re-embed
when you don't trust that verdict. `akm index --skip-if-locked` lets a
scheduled or opportunistic run step aside instead of contending with a rebuild
already in progress; the shipped `index-refresh` scheduled task already passes
it.

`embedding.maxTokens`'s default (the per-request token budget) is now 6000,
down from 8000: a field report on an 8192-token llama.cpp embedder showed the
4-chars-per-token estimator undercounts dense technical text by 7-55%, so the
old default regularly overshot the endpoint's real context window. If you
already set `embedding.maxTokens` explicitly, this default change does not
affect you — your configured value is unchanged. `akm index` also now
recovers automatically within a run: on the first request rejected for
exceeding the endpoint's context window, it lowers its effective budget for
the rest of that run (reported with one line) rather than continuing to hit
the same wall on every following batch.

`embedding.concurrency` (positive integer, 1-16) overrides the number of
embedding requests kept in flight at once, which otherwise defaults to 1 for
a loopback endpoint and 2 for a remote one. Set it only for an endpoint that
genuinely serves parallel requests — a local model server started with a
multi-slot flag (llama.cpp's `--parallel N`, vLLM) — since the default
already protects an ordinary single-slot server from reload-thrash.
Embedding throughput is still tuned first by `embedding.batchSize`
(documents per request) and `embedding.maxTokens` (token
budget per request); the concurrency override is a second lever for a
server that can actually use it.

`embedding.timeoutMs` bounds each embedding request (default 120s, up from a
prior fixed 30s that cut off a slow local model server mid-response). It is
the budget for a request at the full token budget — a smaller request gets a
proportionally smaller timeout, so a dead endpoint is still detected in
seconds on the common case of small documents. A request TIMEOUT no longer
drops its batch immediately: akm now backs off (5s, doubling, capped at 60s)
and retries the same request once, since field evidence showed the endpoint
keeps computing an abandoned request regardless of the client giving up; a
second timeout splits the batch in half and retries each half the same way,
down to individual documents, and a single document that still times out is
finally skipped. After 3 consecutive failures at single-document size
(timeout or network error), or 3 consecutive network errors at any size —
never a batch rejected only for exceeding the endpoint's context window —
`akm index`'s embedding phase stops dispatching further requests and reports
failure instead of grinding through every remaining batch against a dead
endpoint — batches already committed are kept, and a rerun picks up where it
left off.

`akm bundle update` now durably commits its embedding pass instead of
nesting it inside its own transaction: earlier releases ran the embedding
phase inside the same transaction as content/lock/index/state, so every
per-batch commit landed as an unobservable SAVEPOINT and a SIGKILL mid-run
lost every embedding of the update, not just the one in flight. The
embedding phase now runs on its own connection after the update's own
commit; a failing pass (provider down) still leaves the update itself
successful, with the new `index.semanticStatus` field on `akm bundle
update`'s response the only sign semantic search fell behind.

The published `akm`/`akm-migrate` launchers now forward SIGTERM/SIGINT/
SIGHUP to their child and exit alongside it, instead of leaving the child
running as an orphan when only the launcher is signaled. No action needed —
this is a drop-in fix for anyone running `akm` under a scheduler,
supervisor, or hook that can time out or kill the launcher process
directly.

`embedding.maxInputTokens` (default 512) now caps how much of a single
document's text is sent to the embedding provider, truncating to the head
instead of ever failing a whole batch over one oversized document.

- An existing install's already-stored vectors are untouched and stay
  valid — this only changes what happens for entries embedded *after*
  upgrading.
- New embeddings (any entry indexed for the first time, or re-indexed after
  a content change) go through the new 512-token cap by default. If you
  were relying on documents longer than ~2000 characters being embedded in
  full, set `embedding.maxInputTokens` higher in `config.json`.
- `akm index --reembed` re-embeds every entry under the new cap — run it if
  you want your entire existing index rebuilt against the new default (or a
  custom `embedding.maxInputTokens` you've set).
- `embedding.contextLength` is Ollama's `num_ctx` only now; it no longer
  also sets the per-request token budget (`embedding.maxTokens`). If you had
  set `contextLength` specifically to control request batching (not your
  Ollama server's context window), set `embedding.maxTokens` instead.

**Which token knob fixed the original 8k-context overflow.** A 0.9.15-beta
field report described documents estimated under the request budget that
still tokenized to 8.5k-12.4k real tokens against an 8192-token endpoint,
because the 4-chars-per-token estimator undercounts dense technical text.
`maxInputTokens`, `maxTokens`, and `contextLength` are easy to confuse, and
only one of them makes that overflow structurally unreachable:

- `embedding.maxInputTokens` (default `512`) is the per-DOCUMENT cap. It
  truncates a document's embedded text to its head before the document is
  ever counted toward a request, so no single document can contribute more
  than 512 estimated tokens. This is the fix: it makes the original
  single-document overflow structurally unreachable, independent of the
  other two knobs.
- `embedding.maxTokens` (default `6000`) is the per-REQUEST budget — how
  many already-capped documents fit in one HTTP request — plus a same-run
  adaptive shrink on the first context-size rejection. It reduces how often
  a request lands near an endpoint's real limit, but a request-level budget
  alone cannot stop one oversized document from overflowing a request.
- `embedding.contextLength` sets Ollama's `num_ctx` only. It no longer feeds
  the request token budget the way it used to (the two fields used to share
  this one value), and it has no effect at all against a non-Ollama
  endpoint.

The field's exact 0.9.15-beta config — `contextLength: 8192` and
`maxTokens: 8000` — produces no 400s on 0.9.15. `maxTokens` now defaults
lower anyway (6000), but that is not why the overflow stopped: every
document is truncated to `maxInputTokens` (512 tokens) before it is counted
toward any request, so the 8.5k-12.4k-token documents that used to overflow
an 8192-token endpoint can no longer reach the request budget in the first
place. Set `embedding.maxInputTokens` higher only if you need documents
longer than ~2000 characters embedded in full — for a corpus with such
documents, size `embedding.maxTokens` to still fit the worst case, or the
overflow risk returns.

`akm index --full` and an index-generation bump no longer re-embed
unchanged content: vectors about to be discarded are salvaged and handed
back to unchanged entries at the start of the next embedding pass instead
of every upgrade re-embedding the whole corpus once. No action needed —
this is automatic; `akm index --reembed` still forces a full re-embed when
you don't trust the salvaged vectors.

A field report suspected `akm index` was sending embedding requests with no
`Authorization` header despite `embedding.apiKey` being set to a
`secret://` reference. Auditing every path that builds an embedding request
found all of them already resolve `secret://` through the same store
lookup, now pinned by integration and contract tests — this was not a bug
in the code as it stands. `akm index` now prints one line before its first
provider request naming the endpoint, model, and credential SOURCE (never
the value), e.g. `[embed] endpoint http://.../v1/embeddings, model
nomic-embed; credential: secret://lab-api-key (store)`. If you run an
embedding gateway that enforces auth and still see unauthenticated requests
after upgrading, compare this line's endpoint and credential source against
what the gateway's own request log shows for the same request — a
mismatch there (not in this line) is the next place to look.

A config file can now inherit a shared base via `extends: <path|bundle//path>`,
deep-merging under the local file so local keys always win. `akm config diff
<path|bundle//path>` prints every leaf that differs between this instance's
effective config and another config file or bundle-relative file. `akm config
unset` now refuses to unset a key whose value comes only from an
`extends`-inherited base, naming the source, since there would be nothing local
to remove.

The scheduler runs the binary path `akm task sync` recorded at sync time, not
whichever akm your shell now resolves to. After upgrading akm through a
different installer than the one active at your last `task sync` (e.g.
npm-global to a standalone download), run `akm task sync` again so the
schedule points at the new binary; `akm health --probe` now warns via a new
`scheduler-binary` advisory when the two diverge.
