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 +175 -13
- package/dist/akm +54 -1
- package/dist/akm-migrate +34 -1
- package/dist/cli.js +37 -1
- package/dist/commands/improve/locks.js +3 -2
- package/dist/commands/sources/installed-stashes.js +58 -16
- package/dist/commands/sources/stash-cli.js +17 -0
- package/dist/core/config/schema/embedding.js +41 -0
- package/dist/core/file-lock.js +49 -15
- package/dist/core/parent-watchdog.js +64 -0
- package/dist/core/run-lock.js +13 -2
- package/dist/indexer/index-rebuild-lock.js +4 -4
- package/dist/indexer/index-written-assets.js +9 -1
- package/dist/indexer/indexer.js +79 -16
- package/dist/indexer/materialize-embeddings.js +299 -33
- package/dist/indexer/search/search-source.js +23 -1
- package/dist/llm/embedders/remote.js +340 -42
- package/dist/scripts/akm-migrate-node.js +398 -77
- package/dist/scripts/akm-migrate.js +398 -77
- package/dist/storage/repositories/embedding-salvage-repository.js +184 -0
- package/dist/storage/repositories/index-schema.js +16 -0
- package/dist/tasks/run/run-native-task.js +23 -1
- package/docs/migration/release-notes/0.9.15.md +85 -4
- package/docs/migration/release-notes/README.md +3 -2
- package/docs/reference/cli.md +28 -3
- package/docs/reference/configuration.md +67 -15
- package/package.json +1 -1
- package/schemas/akm-config.json +39 -0
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.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 } }`
|
|
177
|
-
|
|
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
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
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)], {
|
|
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)], {
|
|
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
|
-
|
|
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 {
|
|
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
|
-
/**
|
|
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();
|