akm-cli 0.9.15-beta.1 → 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 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.1] - 2026-09-09
7
+ ## [0.9.15-beta.2] - 2026-09-09
8
8
 
9
9
  ### Added
10
10
 
@@ -173,8 +173,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
173
173
  held lock only warns and the run proceeds unlocked, exactly as before.
174
174
  `--skip-if-locked` mirrors `akm improve --skip-if-locked`: when the lock is
175
175
  already held by a live process it skips gracefully (exit 0, `{ ok: true,
176
- skipped: { reason: "lock-held", pid, startedAt } }`) instead of piling up
177
- behind the other run. The shipped `index-refresh` scheduled task now passes it.
176
+ skipped: { reason: "lock-held", pid, launcherPid, startedAt } }`
177
+ `launcherPid` is the holder's launcher pid when known, `null` otherwise,
178
+ #956) instead of piling up behind the other run. The shipped
179
+ `index-refresh` scheduled task now passes it.
178
180
  - **`akm improve` reports which processes it skipped for an unavailable engine,
179
181
  instead of dispatching with a doomed credential (#957).** A process whose
180
182
  engine was configured but whose credential could not be resolved in this
@@ -201,6 +203,71 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
201
203
  reference — recommended alongside `--skip-if-locked` for scheduled runs, since
202
204
  the operator's own shell can pass config validation while a scheduler's
203
205
  stripped-down environment cannot.
206
+ - **`embedding.timeoutMs` configures the per-request embedding timeout
207
+ (#954).** The prior fixed 30s timeout cut off a slow local model server on
208
+ a large token-budget-bounded batch mid-response. Default 120s, used by both
209
+ the single-text and batch embedding request paths; it scales down for a
210
+ smaller-than-budget request (`clamp(timeoutMs × requestTokens /
211
+ tokenBudget, 30s, timeoutMs)`), so a dead endpoint is detected in seconds
212
+ on the common case of small documents. A request timeout no longer drops
213
+ its batch immediately: field confirmation showed the endpoint keeps
214
+ computing an abandoned request regardless, so akm now backs off (5s,
215
+ doubling, capped at 60s) and retries the SAME request once before ever
216
+ splitting or skipping it; a second timeout splits the batch in half (like
217
+ a context-size rejection) and retries each half the same way, down to
218
+ individual documents, and a single document that times out twice is
219
+ finally skipped.
220
+ - **`embedding.concurrency` overrides the fixed in-flight embedding request
221
+ window (#954).** Bounded 1-16; unset behavior is unchanged (1 for a
222
+ loopback endpoint, 2 for a remote one). 0.9.15-beta.1 shipped with no
223
+ config override for this window; the final release adds one after field
224
+ evidence that a multi-slot local server (llama.cpp `--parallel N`, vLLM)
225
+ sat idle behind the fixed default. Request size — `embedding.batchSize`
226
+ and `embedding.maxTokens` — remains the first throughput
227
+ lever; set this only for an endpoint that genuinely serves parallel
228
+ requests.
229
+ - **The embedding phase stops after 3 consecutive transport failures instead
230
+ of grinding through every remaining batch (#954).** A dead or hung
231
+ provider used to burn hours on a large stash, one request timeout at a
232
+ time, with no signal until a single aggregate warning at the end and
233
+ `ok: true`, exit 0. The pass now stops dispatching further requests and
234
+ reports failure after 3 consecutive failures at single-document size
235
+ (timeout or network error — a multi-document timeout is retried and split
236
+ smaller before it can ever count, so it is not by itself evidence the
237
+ endpoint is dead) or 3 consecutive network errors at any size (never
238
+ retried, so trusted immediately); a `context-window-exceeded` skip never
239
+ counts and resets both streaks. The failure message names how many
240
+ embeddings were stored before it gave up. Batches already committed are
241
+ kept.
242
+ - **`embedding.maxInputTokens` caps a single document's embedded text
243
+ instead of letting it fail a whole batch (#956).** llama.cpp rejects a
244
+ single sequence longer than its physical batch (`--ubatch-size`, default
245
+ 512) with HTTP 500 "input is too large to process," and the only
246
+ per-entry cap before this was 1,000,000 characters. `akm index` now
247
+ truncates a document's embedded text to `embedding.maxInputTokens`
248
+ (default 512, head only, unicode-safe) before batching rather than
249
+ skipping it; a document is skipped only when its truncated head is empty.
250
+ `embedding.contextLength` is Ollama's `num_ctx` only now — it used to also
251
+ silently set the per-request token budget (`embedding.maxTokens`), so
252
+ setting it for the server's context window changed request batching too.
253
+ The request budget is `embedding.maxTokens` (default 8000), so a request
254
+ carries about 16 documents alongside the new per-document cap by default.
255
+ - **`akm index` reports where its embedding credential came from, before the
256
+ first provider request (#953).** A field report suspected a gateway was
257
+ receiving unauthenticated embedding requests despite `embedding.apiKey`
258
+ being set to a `secret://` reference. Auditing and reproducing every path
259
+ that reaches `RemoteEmbedder` — plain `akm index`, the CLI as a real child
260
+ process, an `extends`-inherited config with adapter detection persisting
261
+ mid-run (#945), `akm bundle update`'s post-commit embedding pass, and the
262
+ `akm remember` write path's targeted re-embed — found every one already
263
+ resolves `secret://` through the same store lookup, now pinned by
264
+ integration and contract tests so a future config-flow change cannot drop
265
+ `apiKey` unnoticed. `akm index` now prints one default-level line before
266
+ its first provider request naming the endpoint, model, and credential
267
+ SOURCE — `secret://lab-api-key (store)`, `$LAB_API_KEY (env)`, `literal
268
+ apiKey`, or `none configured` — never the credential's value, so a field
269
+ run can compare it directly against what the gateway actually logged.
270
+ `--verbose` also names the config file the run loaded.
204
271
 
205
272
  ### Changed
206
273
 
@@ -245,7 +312,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
245
312
  so an interruption partway through (a competing indexer collision, a killed
246
313
  process, any thrown provider error) discarded every embedding already computed,
247
314
  not just the ones still in flight. Each request batch now commits inside its
248
- own short transaction as it lands.
315
+ own short transaction as it lands. This holds on every path that embeds: plain
316
+ `akm index`, the implicit reindex, the write path (`akm remember`/`import`/
317
+ `proposal accept`/`source clone`), and `akm bundle update` (see below).
249
318
  - **A batch rejected for exceeding the endpoint's context window is split and
250
319
  retried instead of skipped outright (#954).** `akm index`'s embedding pass now
251
320
  recognizes HTTP 413 and known context-size error bodies and halves the failing
@@ -253,15 +322,23 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
253
322
  single document that still fails this way is skipped, as
254
323
  `context-window-exceeded`; every other failure (network error, 5xx, malformed
255
324
  response) keeps the prior skip-the-whole-batch behavior.
256
- - **Embedding requests are now dispatched through a small, fixed in-flight window
257
- instead of strictly sequentially (#954).** The window is 1 request at a time
258
- for a loopback endpoint and 2 for a remote one, and is not configurable — the
259
- actual throughput knob is request size, via the existing `embedding.batchSize`
260
- (document cap) and `embedding.maxTokens`/`contextLength` (token budget), since
261
- a larger batch takes about the same wall time as a single one.
262
- - **`akm index` reports embedding progress and throughput as it runs (#954).** A
263
- progress line is printed every 500 stored entries, and a final line reports
264
- throughput (`entries/s`, `tokens/s`) once the embedding pass completes.
325
+ - **Embedding requests are dispatched through a small in-flight window instead of
326
+ strictly sequentially (#954).** The window defaults to 1 request at a time for
327
+ a loopback endpoint and 2 for a remote one; the actual throughput knob is
328
+ request size, via the existing `embedding.batchSize` (document cap) and
329
+ `embedding.maxTokens` (token budget), since a larger batch
330
+ takes about the same wall time as a single one. `embedding.concurrency`
331
+ (see Added, above) overrides this default for a server that genuinely serves
332
+ parallel requests.
333
+ - **`akm index` reports embedding progress and throughput in more detail as it
334
+ runs (#954).** A default-level line reports each provider batch as it
335
+ completes — document count, token count, elapsed time, and outcome
336
+ (`stored`/`failed: <reason>`/`retrying after <n> s`) — and a final line
337
+ reports total throughput (`entries/s`, `tokens/s`) plus every outcome: how
338
+ many embeddings were stored (and reused from a prior generation, when
339
+ salvage applied — see #955 below), oversized-skipped, timed out, and
340
+ failed, with the affected refs listed (first 20 by default, all of them
341
+ under `--verbose`).
265
342
  - **A rename of `embedding.model` no longer forces a full re-embed by itself
266
343
  (#955).** `akm index` used to purge and rebuild the entire vector index on any
267
344
  change to the fingerprint it derives from `embedding.model`, including a pure
@@ -288,6 +365,27 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
288
365
  The new fingerprint (and the observed identity) are now written in the same
289
366
  transaction as the purge, before any vectors are requested; a restart then sees
290
367
  a matching fingerprint and only re-embeds the entries still missing a vector.
368
+ - **`akm index --full` and an index-generation bump no longer re-embed
369
+ unchanged content (#955).** A full rebuild deleted every embedding
370
+ unconditionally and re-inserted entries under new ids, and the v22→v23
371
+ generation bump did the same on first open under a new binary — both
372
+ forced a full re-embed of the whole corpus even when nothing changed, the
373
+ likely cause of the multi-hour post-upgrade run reported against 0.9.14.
374
+ Vectors about to be discarded are now copied into a transient
375
+ `embedding_salvage` table (keyed by a hash of `search_text` plus the
376
+ fingerprint they were generated under) in the same transaction as the
377
+ discard — read back in bounded chunks rather than loaded wholesale, so a
378
+ large corpus does not spike memory, and a run with nothing to reuse costs
379
+ a single indexed lookup — and handed back to unchanged entries at the
380
+ start of the next embedding pass with zero provider calls — a progress
381
+ line reports the split (`Reused N embeddings from the previous
382
+ generation; embedding M new.`). Content that changed by even one byte, or
383
+ a fingerprint that no longer matches, still goes through the provider
384
+ normally. `akm index --reembed` and a canary "rebuild" verdict purge the
385
+ salvage table along with the stored embeddings; a canary "keep" verdict (a
386
+ fingerprint-string rename resolving to the same model) relabels it instead
387
+ so it stays reusable. An interrupted pass leaves the table intact for the
388
+ next attempt.
291
389
  - **A write-path index update (`akm remember`, `akm import`, `akm proposal
292
390
  accept`, `akm source clone`, extract session assets) never contends with a full
293
391
  rebuild in progress; it skips and lets the rebuild heal the entry instead
@@ -321,6 +419,19 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
321
419
  `improve` itself now builds (see Added, above), rather than a separate
322
420
  re-derivation that could disagree with what a real run in the same environment
323
421
  would do.
422
+ - **A failed embedding batch and `akm index`'s progress are visible without
423
+ `--verbose` (#954).** A failed provider batch used to log only under
424
+ `--verbose`; it now logs at the default `warn` level, naming the batch size
425
+ and reason. `akm index`'s `Embedded N/M entries.` line now fires after every
426
+ committed batch instead of every 500 stored entries, and the heartbeat names
427
+ the failed count too. In non-verbose JSON/yaml output mode, phase-start
428
+ messages and the heartbeat now reach stderr (via `info()`); text mode keeps
429
+ its spinner instead, and `--verbose` is unchanged. A silently grinding,
430
+ hours-long `akm index` run against a dead provider — with no output until
431
+ one aggregate warning at the very end — was the field report this fixes.
432
+ Source-cache hydration (which runs before `index.db` is even opened) now
433
+ reports its own progress the same way: `Hydrating source i/n: <name>` per
434
+ source, plus a 15s heartbeat while a sync is in flight.
324
435
 
325
436
  ### Fixed
326
437
 
@@ -400,6 +511,57 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
400
511
  - **An unset or empty `$VAR` referenced by an engine's `apiKey` now warns once,
401
512
  naming the variable (#953).** Previously it silently sent an empty
402
513
  `Authorization` header instead of surfacing the misconfiguration.
514
+ - **`akm bundle update` now commits its embedding pass durably instead of
515
+ nesting it inside its own transaction (#954).** Its coordinator called
516
+ `akm index` for its embedding phase too, INSIDE the same unified
517
+ `BEGIN IMMEDIATE` that covers content/lock/index/state — so every per-batch
518
+ commit (above) nested as an unobservable SAVEPOINT, and a SIGKILL mid-run
519
+ lost every embedding of the run rather than just the one in flight.
520
+ 0.9.15-beta.1 shipped claiming per-batch commits held on this path; the
521
+ final release makes it true: `generateEmbeddingsForDb` now refuses to run
522
+ against a connection that already has a transaction open (an internal
523
+ contract error, not a user-facing one), and `akm bundle update` runs its
524
+ embedding phase on a fresh connection AFTER its own commit instead. A
525
+ failing post-commit pass (provider down) still leaves the update itself
526
+ successful — content, lock, and index generation are already durably
527
+ committed — with the response's `index.semanticStatus` (new field) the
528
+ only sign semantic search fell behind (`"blocked"`), exactly like a plain
529
+ `akm index` run today.
530
+ - **A batch rejected by llama.cpp for exceeding its physical batch size is now
531
+ recognized as a context-size rejection (#954).** llama.cpp reports this as
532
+ an HTTP 500 with a body like "input is too large to process. increase the
533
+ physical batch size", which the existing context-size pattern
534
+ (`exceed_context_size_error`, "context size", …) did not match, so the
535
+ whole batch was dropped instead of being split and retried like a 413.
536
+ - **A `kill <launcher-pid>` no longer orphans the running `akm` process
537
+ (#956).** The published launcher (`scripts/node-runtime/akm`/
538
+ `akm-migrate`) now forwards SIGTERM/SIGINT/SIGHUP to its bun/node child
539
+ and exits alongside it, instead of leaving the child running — one field
540
+ report found 40 orphaned `bun …/dist/cli.js` processes in a single day,
541
+ some hours old, still hammering the embedding endpoint and holding the
542
+ rebuild lock. Every command also polls for reparenting (a launcher that
543
+ dies without delivering a signal — SIGKILL, an out-of-memory kill) and
544
+ re-raises SIGTERM on itself the moment it notices, reusing the same abort
545
+ path a real signal already takes. Lock messages ("another index run is
546
+ active...", "akm improve is already running...") and `akm index
547
+ --skip-if-locked`'s JSON result now name the launcher pid alongside the
548
+ pid that actually holds the lock — `pid 4242 (launcher 4240)` — since
549
+ every process listing and task log shows the launcher pid, not the
550
+ child's.
551
+ - **An index run interrupted before its first embedding pass ever completes
552
+ could force an unnecessary full re-embed on the next `akm index --full`
553
+ (#956).** A fingerprint-rename rebuild already wrote `embeddingFingerprint`
554
+ immediately, before any provider call, so an interruption right after that
555
+ decision still left a consistent record — but the common
556
+ first-pass/unchanged-fingerprint path deferred that write to a fully
557
+ successful run. A per-batch commit is durable the instant it lands
558
+ regardless, so an interrupted first-ever pass left real, already-embedded
559
+ vectors with no recorded fingerprint to tag them by, and a later full
560
+ rebuild's salvage-before-discard step (#955, above) treated the missing
561
+ fingerprint as "nothing was ever verified" and re-embedded everything
562
+ instead of reusing them. A plain `akm index` resume after an interruption
563
+ now embeds only the entries still missing a vector, with no purge and no
564
+ canary.
403
565
 
404
566
  ## [0.9.14] - 2026-09-04
405
567
 
package/dist/akm CHANGED
@@ -117,6 +117,10 @@ if (contextIndex !== -1) {
117
117
  if (contextValid) {
118
118
  process.env.AKM_LAUNCHER_NODE = process.execPath;
119
119
  process.env.AKM_LAUNCHER_PATH = fileURLToPath(import.meta.url);
120
+ // #956: lets the child (and its lock payloads) name the launcher pid
121
+ // alongside its own, and lets the child's parent-death watchdog tell a
122
+ // real launcher-managed run apart from a direct `bun src/cli.ts` run.
123
+ process.env.AKM_LAUNCHER_PID = String(process.pid);
120
124
 
121
125
  if (!process.versions.bun) {
122
126
  const [major = 0, minor = 0] = process.versions.node.split(".").map(Number);
@@ -145,7 +149,56 @@ if (contextValid) {
145
149
  const entry = useBun ? bunEntry : nodeEntry;
146
150
  const runtime = useBun ? "Bun" : "Node.js";
147
151
  const result = await new Promise((resolve) => {
148
- const child = spawn(command, [entry, ...process.argv.slice(2)], { stdio: "inherit", env: process.env });
152
+ const child = spawn(command, [entry, ...process.argv.slice(2)], {
153
+ stdio: "inherit",
154
+ env: process.env,
155
+ // #956: give the child its OWN process group on POSIX (`setsid()` —
156
+ // does not touch its inherited stdio; see `spawnsOwnProcessGroup` in
157
+ // src/core/subprocess.ts for the Windows caveat that rules this out
158
+ // there). Without this, a spawned child shares the launcher's
159
+ // process group by default, so a signal delivered to that GROUP (a
160
+ // terminal's Ctrl-C, or `kill -SIGINT -<pgid>`) reaches the child
161
+ // directly AND the launcher, which then also forwards it — a real
162
+ // second signal lands microseconds after the first. The child's
163
+ // `process.once(signal, ...)` handler has already unregistered
164
+ // itself for its first (direct) copy, so the second falls through
165
+ // to the runtime's default disposition (immediate termination) and
166
+ // the child's graceful shutdown (lock release, in-flight abort)
167
+ // never runs. Detaching the child's process group makes the
168
+ // launcher's forward below the SOLE delivery path to the child, so
169
+ // it is always exactly one signal.
170
+ detached: process.platform !== "win32",
171
+ });
172
+ // A `kill <launcher-pid>` (a scheduler timeout, a supervisor, an
173
+ // operator) used to end only this wrapper, orphaning the bun child —
174
+ // it kept running (and holding locks) for as long as its own work took.
175
+ // Forward the same signal so the child dies with its parent. Never
176
+ // forward once the child has already exited: forwarding to a
177
+ // dead/replaced pid would be at best a no-op and at worst a signal to
178
+ // an unrelated process that reused the pid.
179
+ let childExited = false;
180
+ child.once("exit", () => {
181
+ childExited = true;
182
+ });
183
+ const forwardSignal = (signal) => {
184
+ if (childExited) return;
185
+ try {
186
+ child.kill(signal);
187
+ } catch {
188
+ // Child exited in the race between the check above and here.
189
+ }
190
+ };
191
+ // `.once`, not `.on`: Node/Bun suppress a signal's default
192
+ // (process-terminating) disposition for as long as ANY listener stays
193
+ // registered for it. A persistent `.on` listener would still be
194
+ // registered when the `process.kill(process.pid, result.signal)`
195
+ // re-raise below runs at shutdown, swallowing it and leaving this
196
+ // launcher exiting 0 instead of reflecting the child's signal. `.once`
197
+ // consumes only the externally-delivered signal that triggers the
198
+ // forward, so the re-raise correctly falls through to the OS default.
199
+ for (const signal of ["SIGTERM", "SIGINT", "SIGHUP"]) {
200
+ process.once(signal, () => forwardSignal(signal));
201
+ }
149
202
  child.once("error", (error) => resolve({ error }));
150
203
  child.once("exit", (code, signal) => resolve({ code, signal }));
151
204
  });
package/dist/akm-migrate CHANGED
@@ -27,10 +27,43 @@ const bunEntry = fileURLToPath(new URL("./scripts/akm-migrate.js", import.meta.u
27
27
  const nodeEntry = fileURLToPath(new URL("./scripts/akm-migrate-node.js", import.meta.url));
28
28
 
29
29
  {
30
+ // #956: same shape as scripts/node-runtime/akm — the child names the
31
+ // launcher pid, and a `kill <launcher-pid>` forwards to the child instead
32
+ // of orphaning it.
33
+ const env = { ...process.env, AKM_LAUNCHER_PID: String(process.pid) };
30
34
  const command = process.versions.bun ? process.execPath : useBun ? "bun" : process.execPath;
31
35
  const entry = process.versions.bun || useBun ? bunEntry : nodeEntry;
32
36
  const result = await new Promise((resolve) => {
33
- const child = spawn(command, [entry, ...process.argv.slice(2)], { stdio: "inherit" });
37
+ const child = spawn(command, [entry, ...process.argv.slice(2)], {
38
+ stdio: "inherit",
39
+ env,
40
+ // #956: own process group on POSIX so the launcher's forward below is
41
+ // the SOLE delivery path to the child instead of a redundant second
42
+ // copy landing on top of a group-wide broadcast the child already got
43
+ // directly — see the matching comment in scripts/node-runtime/akm.
44
+ detached: process.platform !== "win32",
45
+ });
46
+ let childExited = false;
47
+ child.once("exit", () => {
48
+ childExited = true;
49
+ });
50
+ const forwardSignal = (signal) => {
51
+ if (childExited) return;
52
+ try {
53
+ child.kill(signal);
54
+ } catch {
55
+ // Child exited in the race between the check above and here.
56
+ }
57
+ };
58
+ // `.once`, not `.on`: see the matching comment in
59
+ // scripts/node-runtime/akm — a persistent `.on` listener would still be
60
+ // registered when the `process.kill(process.pid, result.signal)`
61
+ // re-raise below runs, suppressing the OS default disposition and
62
+ // leaving this launcher exiting 0 instead of reflecting the child's
63
+ // signal.
64
+ for (const signal of ["SIGTERM", "SIGINT", "SIGHUP"]) {
65
+ process.once(signal, () => forwardSignal(signal));
66
+ }
34
67
  child.once("error", (error) => resolve({ error }));
35
68
  child.once("exit", (code, signal) => resolve({ code, signal }));
36
69
  });
package/dist/cli.js CHANGED
@@ -87,6 +87,8 @@ import { taskCommand } from "./commands/tasks/tasks-cli.js";
87
87
  import { workflowCommand } from "./commands/workflow-cli.js";
88
88
  import { DEFAULT_CONFIG, loadConfig } from "./core/config/config.js";
89
89
  import { UsageError } from "./core/errors.js";
90
+ import { launcherPidFromEnv } from "./core/file-lock.js";
91
+ import { startParentDeathWatchdog } from "./core/parent-watchdog.js";
90
92
  import { getConfigPath } from "./core/paths.js";
91
93
  import { DURATION_UNITS, parseDuration } from "./core/time.js";
92
94
  import { plainize } from "./core/tty.js";
@@ -1078,5 +1080,39 @@ async function runCli() {
1078
1080
  // sets `AKM_STANDALONE_ENTRY=1` before importing this file. The test harness
1079
1081
  // sets neither, so importing cli.ts under Bun stays inert as before.
1080
1082
  if (import.meta.main || process.env.AKM_NODE_ENTRY === "1" || process.env.AKM_STANDALONE_ENTRY === "1") {
1081
- await runCli();
1083
+ // #956: a launcher that dies WITHOUT delivering a signal
1084
+ // (SIGKILL, an OOM kill, a supervisor that force-removes the process)
1085
+ // still reparents this process to init with nothing to catch — the field
1086
+ // evidence was 40 orphaned `bun …/dist/cli.js` processes in one day, some
1087
+ // from a curate hook killing its own launcher on timeout. Active for
1088
+ // EVERY command (not only `index`), since a hook-invoked `curate`/`search`
1089
+ // child is orphaned the same way. Inert when there is no launcher
1090
+ // (AKM_LAUNCHER_PID unset — a direct `bun src/cli.ts` run, or any test
1091
+ // harness that imports this module without setting it): only a real
1092
+ // launcher-managed run has a parent worth watching.
1093
+ const parentWatchdog = launcherPidFromEnv()
1094
+ ? startParentDeathWatchdog({
1095
+ initialPpid: process.ppid,
1096
+ onOrphaned: () => {
1097
+ // Self-delivers the same signal a real SIGTERM would be. `akm
1098
+ // index`'s own AbortController listens for it
1099
+ // (commands/sources/stash-cli.ts) and gets a graceful, in-process
1100
+ // shutdown. Every other command has no SIGTERM listener of its
1101
+ // own, so this self-signal terminates it directly via the
1102
+ // runtime's default disposition WITHOUT running `exit` handlers
1103
+ // (lock release included) — it stops the orphaned process, which
1104
+ // is the goal here, but any lock it held is left for the next
1105
+ // acquirer's dead-pid stale-reclaim (file-lock.ts) to clear, not
1106
+ // released in-process. No second, parallel abort path to keep in
1107
+ // sync either way.
1108
+ process.kill(process.pid, "SIGTERM");
1109
+ },
1110
+ })
1111
+ : undefined;
1112
+ try {
1113
+ await runCli();
1114
+ }
1115
+ finally {
1116
+ parentWatchdog?.stop();
1117
+ }
1082
1118
  }
@@ -6,7 +6,7 @@ import { ConfigError } from "../../core/errors.js";
6
6
  import { appendEvent } from "../../core/events.js";
7
7
  import { releaseLock } from "../../core/file-lock.js";
8
8
  import { tryWithMaintenanceStartBarrier, withMaintenanceStartBarrier } from "../../core/maintenance-barrier.js";
9
- import { tryAcquireRunLock } from "../../core/run-lock.js";
9
+ import { formatLockHolderPid, tryAcquireRunLock } from "../../core/run-lock.js";
10
10
  import { warn } from "../../core/warn.js";
11
11
  export function improveLockPath(lockBaseDir) {
12
12
  return path.join(lockBaseDir, "improve.lock");
@@ -59,7 +59,8 @@ function tryAcquireImproveLockUnlocked(lockPath, skipIfLocked, onRecovered) {
59
59
  if (result.state === "acquired") {
60
60
  return { state: "acquired", ownership: result.ownership };
61
61
  }
62
- const { pid, startedAt } = result.holder;
62
+ const { startedAt } = result.holder;
63
+ const pid = formatLockHolderPid(result.holder);
63
64
  if (skipIfLocked) {
64
65
  warn(`[improve] another improve run holds the lock (PID ${pid}, started ${startedAt}); skipping (--skip-if-locked)`);
65
66
  return { state: "skipped" };
@@ -26,7 +26,7 @@ import { beginImmediateTransaction, getStateDbPath, openStateDatabase } from "..
26
26
  import { warn } from "../../core/warn.js";
27
27
  import { resolveGitContentRoot } from "../../core/write-source.js";
28
28
  import { withAssetMutationLease } from "../../indexer/index-writer-lock.js";
29
- import { akmIndex } from "../../indexer/indexer.js";
29
+ import { akmIndex, runEmbeddingPass } from "../../indexer/indexer.js";
30
30
  import { compareAndSwapLockfileSnapshot, publishLockfileUpdate, readLockfile, readLockfileForUpdate, } from "../../integrations/lockfile.js";
31
31
  import { parseRegistryRef } from "../../registry/resolve.js";
32
32
  import { sha256Hex } from "../../runtime.js";
@@ -350,11 +350,19 @@ export async function akmRemove(input) {
350
350
  },
351
351
  };
352
352
  }
353
- /** Read the current index generation without creating or hydrating anything. */
353
+ /**
354
+ * Read the current index generation without creating or hydrating anything.
355
+ * This path never ran an embedding pass (#954, field-report follow-up), so it has no
356
+ * `verification` to report — {@link buildUpdateResponse} falls back to the
357
+ * two facts it can actually know (whether semantic search is configured on
358
+ * at all) rather than fabricating verification numbers (`entryCount: 0`,
359
+ * `ok: true`) for a run that never verified anything.
360
+ */
354
361
  function readCurrentIndexSummary() {
355
362
  const db = openReadonlyExistingDatabase(getDbPath());
356
- if (!db)
363
+ if (!db) {
357
364
  return { mode: "incremental", totalEntries: 0, directoriesScanned: 0, directoriesSkipped: 0 };
365
+ }
358
366
  try {
359
367
  return {
360
368
  mode: "incremental",
@@ -388,6 +396,12 @@ function buildUpdateResponse(stashDir, target, all, processed, opts) {
388
396
  directoriesScanned: index.directoriesScanned,
389
397
  directoriesSkipped: index.directoriesSkipped,
390
398
  ...(index.scanComplete !== undefined ? { scanComplete: index.scanComplete } : {}),
399
+ // A real embedding pass (`akmIndex`/`runEmbeddingPass`) reports its own
400
+ // verified `semanticStatus`. When no pass ran this update (the
401
+ // no-op/nothing-configured fallback) the only two facts known without
402
+ // fabricating a verification are whether semantic search is off at all
403
+ // or, if not, that its state is simply unverified this run.
404
+ semanticStatus: index.verification?.semanticStatus ?? (finalConfig.semanticSearchMode === "off" ? "disabled" : "pending"),
391
405
  },
392
406
  };
393
407
  }
@@ -499,6 +513,45 @@ function closeUnifiedUpdateTransaction(transaction, committed) {
499
513
  : `[akm bundle update] rolled back, but closing its database handles failed: ${String(closeError)}`);
500
514
  }
501
515
  }
516
+ /**
517
+ * Run the embedding phase AFTER the coordinator's atomic commit, on its own
518
+ * fresh connection (#954). Before this, `akmUpdate` called
519
+ * `akmIndex()` for its embedding phase too, INSIDE this same unified
520
+ * `BEGIN IMMEDIATE` — so every per-batch commit the materializer opened
521
+ * nested as an unobservable SAVEPOINT, and a SIGKILL mid-run lost every
522
+ * embedding of the run rather than just the one in flight. The
523
+ * ambient-transaction drift guard (#954) now rejects that outright, so
524
+ * `akmIndex`'s own embedding phase is skipped for a deferred update
525
+ * transaction and this runs instead, once content/lock/index/state are
526
+ * already durably committed.
527
+ *
528
+ * A failing pass (provider down, timeout) does NOT fail the update: the
529
+ * bundle content and index are already committed successfully, exactly like
530
+ * a plain `akm index` whose embedding phase fails — only the reported
531
+ * `verification` reflects the shortfall (`semanticStatus: "blocked"`).
532
+ */
533
+ async function runPostCommitEmbeddingPass(index) {
534
+ const config = loadConfig();
535
+ let db;
536
+ try {
537
+ const embeddingDim = config.embedding?.dimension;
538
+ db = openIndexDatabase(getDbPath(), embeddingDim ? { embeddingDim } : undefined);
539
+ const { verification } = await runEmbeddingPass({ db, config, onProgress: () => { } });
540
+ return { ...index, verification };
541
+ }
542
+ catch (error) {
543
+ const message = error instanceof Error ? error.message : String(error);
544
+ warn(`[akm bundle update] post-commit embedding pass failed: ${message}`);
545
+ return {
546
+ ...index,
547
+ verification: { ...index.verification, ok: false, semanticStatus: "blocked", message },
548
+ };
549
+ }
550
+ finally {
551
+ if (db)
552
+ closeDatabase(db);
553
+ }
554
+ }
502
555
  function pathAtOrBelow(candidate, root) {
503
556
  const relative = path.relative(path.resolve(root), path.resolve(candidate));
504
557
  return relative === "" || (relative !== ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative));
@@ -779,14 +832,8 @@ async function publishPreparedPlainUpdate(id, ref, prepared, stashDir, allowInse
779
832
  updateTransactionHook("before-commit", id, { db: transaction.db });
780
833
  commitUnifiedUpdateTransaction(transaction);
781
834
  committed = true;
782
- try {
783
- transaction.deferred.afterCommit?.();
784
- }
785
- catch (error) {
786
- warn(`[akm bundle update] committed, but semantic status refresh failed: ${String(error)}`);
787
- }
788
835
  prepared.publication?.commit();
789
- return index;
836
+ return await runPostCommitEmbeddingPass(index);
790
837
  }
791
838
  catch (error) {
792
839
  let recoveryError = transaction ? rollbackUnifiedUpdateTransaction(transaction) : undefined;
@@ -1093,12 +1140,6 @@ async function updateManagedInstall(managed, force, yes, stashDir, allowInsecure
1093
1140
  updateTransactionHook("before-commit", managed.installId, { db: transaction.db });
1094
1141
  commitUnifiedUpdateTransaction(transaction);
1095
1142
  committed = true;
1096
- try {
1097
- transaction.deferred.afterCommit?.();
1098
- }
1099
- catch (error) {
1100
- warn(`[akm bundle update] committed, but semantic status refresh failed: ${String(error)}`);
1101
- }
1102
1143
  }
1103
1144
  catch (error) {
1104
1145
  let recoveryError = transaction ? rollbackUnifiedUpdateTransaction(transaction) : undefined;
@@ -1146,6 +1187,7 @@ async function updateManagedInstall(managed, force, yes, stashDir, allowInsecure
1146
1187
  }
1147
1188
  }
1148
1189
  prepared.publication?.commit();
1190
+ index = await runPostCommitEmbeddingPass(index);
1149
1191
  if (movedRoot) {
1150
1192
  const currentConfig = loadConfig();
1151
1193
  const currentLocks = readLockfile();
@@ -47,6 +47,8 @@ import { akmIndex } from "../../indexer/indexer.js";
47
47
  import { getHyphenatedBoolean, getOutputMode } from "../../output/context.js";
48
48
  import { inferAssetName, mergeXrefsIntoContent, readKnowledgeInput, resolveSupersedesForWrite, resolveSupersedesWriteTarget, resolveXrefsForWrite, writeMarkdownAsset, } from "../read/knowledge.js";
49
49
  import { assembleInfo } from "./info.js";
50
+ /** Matches the high-frequency per-committed-batch progress line (#954), excluded from non-verbose JSON-mode stderr. */
51
+ const EMBEDDED_BATCH_PROGRESS_PATTERN = /^Embedded \d+\/\d+ entries\.$/;
50
52
  export const indexCommand = defineCommand({
51
53
  meta: { name: "index", description: "Build search index (incremental by default; --full forces full reindex)" },
52
54
  args: {
@@ -98,6 +100,10 @@ export const indexCommand = defineCommand({
98
100
  skipped: {
99
101
  reason: "lock-held",
100
102
  pid: lockAcquisition.holder.pid,
103
+ // #956: the launcher pid (when known) alongside the pid that
104
+ // actually holds the lock — every process listing and task log
105
+ // shows the launcher pid, not the bun/node child's.
106
+ launcherPid: lockAcquisition.holder.launcherPid,
101
107
  startedAt: lockAcquisition.holder.startedAt,
102
108
  },
103
109
  });
@@ -137,6 +143,17 @@ export const indexCommand = defineCommand({
137
143
  spin.stop(`${progressPrefix}${message}`);
138
144
  spin.start(`${progressPrefix}${message}`);
139
145
  }
146
+ else if (!EMBEDDED_BATCH_PROGRESS_PATTERN.test(message)) {
147
+ // Non-verbose, non-text (JSON/yaml/etc) mode: silence used to be
148
+ // total until the run finished (#954) — a stalled
149
+ // run looked identical to "nothing written". Phase-start
150
+ // messages and the embedding heartbeat now reach stderr here
151
+ // too; the high-frequency per-batch `Embedded N/M entries.`
152
+ // line (emitted after every committed batch)
153
+ // is deliberately excluded — that would be spam, not a
154
+ // heartbeat.
155
+ info(`[index:${phase}] ${progressPrefix}${message}`);
156
+ }
140
157
  },
141
158
  signal: controller.signal,
142
159
  });
@@ -33,10 +33,51 @@ export const EmbeddingConnectionConfigSchema = z
33
33
  // `akm index` when ensureSchema rejects it (§24.2 "Semantic" gate).
34
34
  dimension: positiveInt.max(4096).optional(),
35
35
  localModel: z.string().min(1).optional(),
36
+ /**
37
+ * Per-document token cap applied BEFORE batching (default 512,
38
+ * `DEFAULT_MAX_INPUT_TOKENS` in `src/llm/embedders/remote.ts`, #956).
39
+ * The materializer truncates a document's embedded text to
40
+ * this cap (head only, unicode-safe) instead of skipping it outright, so
41
+ * one oversized entry can no longer fail a whole batch. Distinct from
42
+ * `maxTokens` below, which bounds a whole HTTP REQUEST (many documents);
43
+ * this bounds one DOCUMENT.
44
+ */
45
+ maxInputTokens: positiveInt.optional(),
46
+ /**
47
+ * Client-side per-request token budget — how many documents' estimated
48
+ * tokens fit in one HTTP request (default `DEFAULT_TOKEN_BUDGET` = 8000
49
+ * in `src/llm/embedders/remote.ts`). With the 512-token `maxInputTokens`
50
+ * cap above, a request carries about 16 documents by default.
51
+ */
36
52
  maxTokens: positiveInt.optional(),
37
53
  batchSize: positiveInt.optional(),
38
54
  chunkSize: positiveInt.optional(),
55
+ /**
56
+ * Ollama's `num_ctx` ONLY (#956) — sent verbatim as
57
+ * `options.num_ctx` on the native `/api/embed` request. It no longer also
58
+ * feeds the client-side request token budget (`maxTokens` above): the two
59
+ * used to share this one field, so setting it for the server's context
60
+ * window silently changed request batching too.
61
+ */
39
62
  contextLength: positiveInt.optional(),
40
63
  ollamaOptions: EmbeddingOllamaOptionsSchema.optional(),
64
+ /**
65
+ * Per-request timeout in milliseconds for a remote embedding request
66
+ * (default 120_000, `DEFAULT_EMBEDDING_TIMEOUT_MS` in
67
+ * `src/llm/embedders/remote.ts`). The prior fixed 30s cut off a slow
68
+ * local model server on a large token-bounded batch mid-response, with
69
+ * no retry — every batch that hit it was silently dropped (#954).
70
+ */
71
+ timeoutMs: positiveInt.optional(),
72
+ /**
73
+ * Overrides the fixed in-flight request window (#954, added after field
74
+ * evidence from multi-slot local servers). Bounded 1-16. Unset keeps
75
+ * today's default: 1 for a loopback endpoint, 2 for a remote one
76
+ * (`resolveEmbeddingConcurrency`, `src/llm/embedders/remote.ts`). Set it
77
+ * only for an endpoint that genuinely serves parallel requests (llama.cpp
78
+ * `--parallel N`, vLLM) — request SIZE (`batchSize`, `maxTokens`/
79
+ * `contextLength`) remains the first throughput lever.
80
+ */
81
+ concurrency: positiveInt.max(16).optional(),
41
82
  })
42
83
  .passthrough();