akm-cli 0.9.15-beta.1 → 0.9.15-beta.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +234 -13
- package/dist/akm +54 -1
- package/dist/akm-migrate +34 -1
- package/dist/cli.js +40 -4
- package/dist/commands/health/checks.js +11 -2
- package/dist/commands/health/scheduler-binary.js +120 -0
- package/dist/commands/health.js +9 -0
- package/dist/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 -1
- package/dist/core/errors.js +1 -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 +123 -19
- package/dist/indexer/materialize-embeddings.js +345 -37
- package/dist/indexer/search/search-source.js +23 -1
- package/dist/llm/embedders/remote.js +443 -47
- package/dist/scripts/akm-migrate-node.js +454 -85
- package/dist/scripts/akm-migrate.js +454 -85
- 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 +103 -4
- package/docs/migration/release-notes/README.md +3 -2
- package/docs/reference/cli.md +55 -8
- package/docs/reference/configuration.md +83 -15
- package/package.json +1 -1
- package/schemas/akm-config.json +36 -9
package/CHANGELOG.md
CHANGED
|
@@ -4,7 +4,7 @@ All notable changes to this project will be documented in this file.
|
|
|
4
4
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
|
6
6
|
|
|
7
|
-
## [0.9.15-beta.
|
|
7
|
+
## [0.9.15-beta.3] - 2026-09-10
|
|
8
8
|
|
|
9
9
|
### Added
|
|
10
10
|
|
|
@@ -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,82 @@ 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.
|
|
271
|
+
- **`akm health` gains a `scheduler-binary` advisory for scheduler binary
|
|
272
|
+
drift (#953).** A field report found `akm task sync`'s recorded absolute
|
|
273
|
+
akm path can go stale after upgrading through a different installer (npm
|
|
274
|
+
global to a standalone download, or vice versa), leaving a scheduled run
|
|
275
|
+
invoking the old binary indefinitely with nothing surfacing it. The new
|
|
276
|
+
`--probe`-gated advisory reads the scheduler's recorded akm invocation —
|
|
277
|
+
the same binding `task sync`/`task doctor` already read, no crontab text
|
|
278
|
+
parsing — runs it with `--version`, and `warn`s naming both versions when
|
|
279
|
+
it differs from the running CLI, pointing at `akm task sync` as the
|
|
280
|
+
remedy. `unknown` when not probed, no task is installed, or the recorded
|
|
281
|
+
binary cannot be executed.
|
|
204
282
|
|
|
205
283
|
### Changed
|
|
206
284
|
|
|
@@ -245,7 +323,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
|
|
245
323
|
so an interruption partway through (a competing indexer collision, a killed
|
|
246
324
|
process, any thrown provider error) discarded every embedding already computed,
|
|
247
325
|
not just the ones still in flight. Each request batch now commits inside its
|
|
248
|
-
own short transaction as it lands.
|
|
326
|
+
own short transaction as it lands. This holds on every path that embeds: plain
|
|
327
|
+
`akm index`, the implicit reindex, the write path (`akm remember`/`import`/
|
|
328
|
+
`proposal accept`/`source clone`), and `akm bundle update` (see below).
|
|
249
329
|
- **A batch rejected for exceeding the endpoint's context window is split and
|
|
250
330
|
retried instead of skipped outright (#954).** `akm index`'s embedding pass now
|
|
251
331
|
recognizes HTTP 413 and known context-size error bodies and halves the failing
|
|
@@ -253,15 +333,34 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
|
|
253
333
|
single document that still fails this way is skipped, as
|
|
254
334
|
`context-window-exceeded`; every other failure (network error, 5xx, malformed
|
|
255
335
|
response) keeps the prior skip-the-whole-batch behavior.
|
|
256
|
-
- **
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
336
|
+
- **The default per-request token budget is lower, and adapts mid-run after a
|
|
337
|
+
context-size rejection (#954, field report on beta.1).**
|
|
338
|
+
`embedding.maxTokens`'s default dropped from 8000 to 6000: the 4-chars-per-
|
|
339
|
+
token estimator undercounts dense technical text by 7-55%, so 8000 regularly
|
|
340
|
+
overshot a real 8192-token endpoint. On an `akm index` run's first
|
|
341
|
+
context-size rejection, the effective request budget additionally shrinks to
|
|
342
|
+
three quarters of its current value (floored at twice
|
|
343
|
+
`embedding.maxInputTokens`) for every request not yet sent, and one
|
|
344
|
+
default-level line reports the new value; it never shrinks a second time in
|
|
345
|
+
the same run. Users who set `embedding.maxTokens` explicitly keep it as the
|
|
346
|
+
starting point but still benefit from this same-run recovery.
|
|
347
|
+
- **Embedding requests are dispatched through a small in-flight window instead of
|
|
348
|
+
strictly sequentially (#954).** The window defaults to 1 request at a time for
|
|
349
|
+
a loopback endpoint and 2 for a remote one; the actual throughput knob is
|
|
350
|
+
request size, via the existing `embedding.batchSize` (document cap) and
|
|
351
|
+
`embedding.maxTokens` (token budget), since a larger batch
|
|
352
|
+
takes about the same wall time as a single one. `embedding.concurrency`
|
|
353
|
+
(see Added, above) overrides this default for a server that genuinely serves
|
|
354
|
+
parallel requests.
|
|
355
|
+
- **`akm index` reports embedding progress and throughput in more detail as it
|
|
356
|
+
runs (#954).** A default-level line reports each provider batch as it
|
|
357
|
+
completes — document count, token count, elapsed time, and outcome
|
|
358
|
+
(`stored`/`failed: <reason>`/`retrying after <n> s`) — and a final line
|
|
359
|
+
reports total throughput (`entries/s`, `tokens/s`) plus every outcome: how
|
|
360
|
+
many embeddings were stored (and reused from a prior generation, when
|
|
361
|
+
salvage applied — see #955 below), oversized-skipped, timed out, and
|
|
362
|
+
failed, with the affected refs listed (first 20 by default, all of them
|
|
363
|
+
under `--verbose`).
|
|
265
364
|
- **A rename of `embedding.model` no longer forces a full re-embed by itself
|
|
266
365
|
(#955).** `akm index` used to purge and rebuild the entire vector index on any
|
|
267
366
|
change to the fingerprint it derives from `embedding.model`, including a pure
|
|
@@ -288,6 +387,27 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
|
|
288
387
|
The new fingerprint (and the observed identity) are now written in the same
|
|
289
388
|
transaction as the purge, before any vectors are requested; a restart then sees
|
|
290
389
|
a matching fingerprint and only re-embeds the entries still missing a vector.
|
|
390
|
+
- **`akm index --full` and an index-generation bump no longer re-embed
|
|
391
|
+
unchanged content (#955).** A full rebuild deleted every embedding
|
|
392
|
+
unconditionally and re-inserted entries under new ids, and the v22→v23
|
|
393
|
+
generation bump did the same on first open under a new binary — both
|
|
394
|
+
forced a full re-embed of the whole corpus even when nothing changed, the
|
|
395
|
+
likely cause of the multi-hour post-upgrade run reported against 0.9.14.
|
|
396
|
+
Vectors about to be discarded are now copied into a transient
|
|
397
|
+
`embedding_salvage` table (keyed by a hash of `search_text` plus the
|
|
398
|
+
fingerprint they were generated under) in the same transaction as the
|
|
399
|
+
discard — read back in bounded chunks rather than loaded wholesale, so a
|
|
400
|
+
large corpus does not spike memory, and a run with nothing to reuse costs
|
|
401
|
+
a single indexed lookup — and handed back to unchanged entries at the
|
|
402
|
+
start of the next embedding pass with zero provider calls — a progress
|
|
403
|
+
line reports the split (`Reused N embeddings from the previous
|
|
404
|
+
generation; embedding M new.`). Content that changed by even one byte, or
|
|
405
|
+
a fingerprint that no longer matches, still goes through the provider
|
|
406
|
+
normally. `akm index --reembed` and a canary "rebuild" verdict purge the
|
|
407
|
+
salvage table along with the stored embeddings; a canary "keep" verdict (a
|
|
408
|
+
fingerprint-string rename resolving to the same model) relabels it instead
|
|
409
|
+
so it stays reusable. An interrupted pass leaves the table intact for the
|
|
410
|
+
next attempt.
|
|
291
411
|
- **A write-path index update (`akm remember`, `akm import`, `akm proposal
|
|
292
412
|
accept`, `akm source clone`, extract session assets) never contends with a full
|
|
293
413
|
rebuild in progress; it skips and lets the rebuild heal the entry instead
|
|
@@ -321,6 +441,25 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
|
|
321
441
|
`improve` itself now builds (see Added, above), rather than a separate
|
|
322
442
|
re-derivation that could disagree with what a real run in the same environment
|
|
323
443
|
would do.
|
|
444
|
+
- **A failed embedding batch and `akm index`'s progress are visible without
|
|
445
|
+
`--verbose` (#954).** A failed provider batch used to log only under
|
|
446
|
+
`--verbose`; it now logs at the default `warn` level, naming the batch size
|
|
447
|
+
and reason. `akm index`'s `Embedded N/M entries.` line now fires after every
|
|
448
|
+
committed batch instead of every 500 stored entries, and the heartbeat names
|
|
449
|
+
the failed count too. In non-verbose JSON/yaml output mode, phase-start
|
|
450
|
+
messages and the heartbeat now reach stderr (via `info()`); text mode keeps
|
|
451
|
+
its spinner instead, and `--verbose` is unchanged. A silently grinding,
|
|
452
|
+
hours-long `akm index` run against a dead provider — with no output until
|
|
453
|
+
one aggregate warning at the very end — was the field report this fixes.
|
|
454
|
+
Source-cache hydration (which runs before `index.db` is even opened) now
|
|
455
|
+
reports its own progress the same way: `Hydrating source i/n: <name>` per
|
|
456
|
+
source, plus a 15s heartbeat while a sync is in flight.
|
|
457
|
+
- **`embedding.chunkSize` is retired (#954).** Nothing under `src/` ever read
|
|
458
|
+
it; it was declared in the config schema but had no effect. It is removed
|
|
459
|
+
from `EmbeddingConnectionConfigSchema` and `schemas/akm-config.json`. The
|
|
460
|
+
`embedding` object stays `.passthrough()`, so a config that still sets
|
|
461
|
+
`embedding.chunkSize` keeps loading exactly as before — the key is simply
|
|
462
|
+
ignored, not rejected or warned about.
|
|
324
463
|
|
|
325
464
|
### Fixed
|
|
326
465
|
|
|
@@ -400,6 +539,88 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
|
|
400
539
|
- **An unset or empty `$VAR` referenced by an engine's `apiKey` now warns once,
|
|
401
540
|
naming the variable (#953).** Previously it silently sent an empty
|
|
402
541
|
`Authorization` header instead of surfacing the misconfiguration.
|
|
542
|
+
- **`akm bundle update` now commits its embedding pass durably instead of
|
|
543
|
+
nesting it inside its own transaction (#954).** Its coordinator called
|
|
544
|
+
`akm index` for its embedding phase too, INSIDE the same unified
|
|
545
|
+
`BEGIN IMMEDIATE` that covers content/lock/index/state — so every per-batch
|
|
546
|
+
commit (above) nested as an unobservable SAVEPOINT, and a SIGKILL mid-run
|
|
547
|
+
lost every embedding of the run rather than just the one in flight.
|
|
548
|
+
0.9.15-beta.1 shipped claiming per-batch commits held on this path; the
|
|
549
|
+
final release makes it true: `generateEmbeddingsForDb` now refuses to run
|
|
550
|
+
against a connection that already has a transaction open (an internal
|
|
551
|
+
contract error, not a user-facing one), and `akm bundle update` runs its
|
|
552
|
+
embedding phase on a fresh connection AFTER its own commit instead. A
|
|
553
|
+
failing post-commit pass (provider down) still leaves the update itself
|
|
554
|
+
successful — content, lock, and index generation are already durably
|
|
555
|
+
committed — with the response's `index.semanticStatus` (new field) the
|
|
556
|
+
only sign semantic search fell behind (`"blocked"`), exactly like a plain
|
|
557
|
+
`akm index` run today.
|
|
558
|
+
- **A batch rejected by llama.cpp for exceeding its physical batch size is now
|
|
559
|
+
recognized as a context-size rejection (#954).** llama.cpp reports this as
|
|
560
|
+
an HTTP 500 with a body like "input is too large to process. increase the
|
|
561
|
+
physical batch size", which the existing context-size pattern
|
|
562
|
+
(`exceed_context_size_error`, "context size", …) did not match, so the
|
|
563
|
+
whole batch was dropped instead of being split and retried like a 413.
|
|
564
|
+
- **The end-of-run throughput line now sums the capped text actually sent to
|
|
565
|
+
the embedding provider (#954).** `storedTokens` accumulated
|
|
566
|
+
`estimateTokenCount(entry.searchText)` — the entry's pre-cap search text —
|
|
567
|
+
while the request `embedBatch` received held the text `capEmbeddingText`
|
|
568
|
+
had already truncated to `embedding.maxInputTokens`, so the reported
|
|
569
|
+
`tokens/s` figure overstated throughput for every entry over the cap. The
|
|
570
|
+
final line now sums the estimate of the capped text the batching loop
|
|
571
|
+
already built, matching what the provider was actually asked to embed.
|
|
572
|
+
- **A `kill <launcher-pid>` no longer orphans the running `akm` process
|
|
573
|
+
(#956).** The published launcher (`scripts/node-runtime/akm`/
|
|
574
|
+
`akm-migrate`) now forwards SIGTERM/SIGINT/SIGHUP to its bun/node child
|
|
575
|
+
and exits alongside it, instead of leaving the child running — one field
|
|
576
|
+
report found 40 orphaned `bun …/dist/cli.js` processes in a single day,
|
|
577
|
+
some hours old, still hammering the embedding endpoint and holding the
|
|
578
|
+
rebuild lock. Every command also polls for reparenting (a launcher that
|
|
579
|
+
dies without delivering a signal — SIGKILL, an out-of-memory kill) and
|
|
580
|
+
re-raises SIGTERM on itself the moment it notices, reusing the same abort
|
|
581
|
+
path a real signal already takes. Lock messages ("another index run is
|
|
582
|
+
active...", "akm improve is already running...") and `akm index
|
|
583
|
+
--skip-if-locked`'s JSON result now name the launcher pid alongside the
|
|
584
|
+
pid that actually holds the lock — `pid 4242 (launcher 4240)` — since
|
|
585
|
+
every process listing and task log shows the launcher pid, not the
|
|
586
|
+
child's.
|
|
587
|
+
- **An index run interrupted before its first embedding pass ever completes
|
|
588
|
+
could force an unnecessary full re-embed on the next `akm index --full`
|
|
589
|
+
(#956).** A fingerprint-rename rebuild already wrote `embeddingFingerprint`
|
|
590
|
+
immediately, before any provider call, so an interruption right after that
|
|
591
|
+
decision still left a consistent record — but the common
|
|
592
|
+
first-pass/unchanged-fingerprint path deferred that write to a fully
|
|
593
|
+
successful run. A per-batch commit is durable the instant it lands
|
|
594
|
+
regardless, so an interrupted first-ever pass left real, already-embedded
|
|
595
|
+
vectors with no recorded fingerprint to tag them by, and a later full
|
|
596
|
+
rebuild's salvage-before-discard step (#955, above) treated the missing
|
|
597
|
+
fingerprint as "nothing was ever verified" and re-embedded everything
|
|
598
|
+
instead of reusing them. A plain `akm index` resume after an interruption
|
|
599
|
+
now embeds only the entries still missing a vector, with no purge and no
|
|
600
|
+
canary.
|
|
601
|
+
- **A concurrent `akm index` without `--skip-if-locked` now fails with a
|
|
602
|
+
retryable-shortly exit code instead of a raw driver error (#956).**
|
|
603
|
+
Contention with another writer touching index.db (a second `akm index`, a
|
|
604
|
+
source add/update's embedding pass, the per-command background reindex)
|
|
605
|
+
used to exhaust the SQLite driver's retry window and surface as
|
|
606
|
+
`{"ok":false,"error":"database is locked"}` at exit 70
|
|
607
|
+
(internal/unclassified). It is now reclassified into a `TransientError`
|
|
608
|
+
with a dedicated `INDEX_DB_CONTENDED` code (exit 75), naming the rebuild
|
|
609
|
+
lock's live holder pid when known, mirroring `STATE_DB_CONTENDED`'s
|
|
610
|
+
precedent (#948) for state.db; the original driver text survives as
|
|
611
|
+
`cause`. `--skip-if-locked` is unaffected — it already skips gracefully
|
|
612
|
+
before ever attempting the write.
|
|
613
|
+
- **The fingerprint-rename canary embeds the exact text the stored vector was
|
|
614
|
+
generated from (#955).** `sampleEmbeddedEntriesForCanary` handed the canary
|
|
615
|
+
the entry's raw `search_text`, while the main embedding pass caps it to
|
|
616
|
+
`embedding.maxInputTokens` before ever calling the provider — so for any
|
|
617
|
+
entry whose search text exceeded the cap, the canary's freshly re-embedded
|
|
618
|
+
vector came from a different input than the one that produced the stored
|
|
619
|
+
vector, and the median cosine similarity could fall below the compatibility
|
|
620
|
+
threshold for reasons unrelated to the model, triggering a needless full
|
|
621
|
+
purge and rebuild on a same-model rename. The canary now caps each sampled
|
|
622
|
+
entry's search text the same way, through the same `capEmbeddingText`
|
|
623
|
+
helper, before requesting its vector.
|
|
403
624
|
|
|
404
625
|
## [0.9.14] - 2026-09-04
|
|
405
626
|
|
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";
|
|
@@ -293,8 +295,8 @@ const healthCommand = defineCommand({
|
|
|
293
295
|
probe: {
|
|
294
296
|
type: "boolean",
|
|
295
297
|
default: true,
|
|
296
|
-
description: "Probe default-llm-engine / configured-engines reachability
|
|
297
|
-
negativeDescription: "Skip the reachability probes and the
|
|
298
|
+
description: "Probe default-llm-engine / configured-engines reachability, check for a newer akm release, and check the scheduler's recorded akm binary version (on by default).",
|
|
299
|
+
negativeDescription: "Skip the reachability probes, the update check, and the scheduler-binary version check (for an offline or air-gapped host).",
|
|
298
300
|
},
|
|
299
301
|
},
|
|
300
302
|
async run({ args }) {
|
|
@@ -554,7 +556,7 @@ export const main = defineCommand({
|
|
|
554
556
|
" 2 usage error\n" +
|
|
555
557
|
" 4 health warn (akm health only)\n" +
|
|
556
558
|
" 70 internal / unclassified error\n" +
|
|
557
|
-
" 75 transient (retry shortly — another akm process holds a lock or is writing state.db)\n" +
|
|
559
|
+
" 75 transient (retry shortly — another akm process holds a lock or is writing state.db or index.db)\n" +
|
|
558
560
|
" 78 config error",
|
|
559
561
|
},
|
|
560
562
|
args: {
|
|
@@ -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
|
}
|
|
@@ -1164,11 +1164,20 @@ export const HEALTH_CHECKS = [
|
|
|
1164
1164
|
run: (ctx) => ctx.versionDrift,
|
|
1165
1165
|
},
|
|
1166
1166
|
{
|
|
1167
|
-
// #950:
|
|
1168
|
-
// doc comment above). Advisory channel, `kind: "deterministic"` — same
|
|
1167
|
+
// #950: advisory channel, `kind: "deterministic"` — same
|
|
1169
1168
|
// exit-code-gating rationale as thinking-control above.
|
|
1170
1169
|
name: "engine-last-used",
|
|
1171
1170
|
channel: "advisory",
|
|
1172
1171
|
run: (ctx) => projectEngineLastUsedCheck(ctx.activeImproveStrategyEngines, ctx.engineLastUsed, ctx.improveRunsInLookbackWindow, ENGINE_LAST_USED_LOOKBACK_DAYS),
|
|
1173
1172
|
},
|
|
1173
|
+
{
|
|
1174
|
+
// #953: registered last — order is load-bearing (see the HEALTH_CHECKS
|
|
1175
|
+
// doc comment above). Best-effort scheduler-binary-drift advisory, gated
|
|
1176
|
+
// behind the same --probe/--no-probe flag as engine reachability and
|
|
1177
|
+
// cli-version. Computed once in health.ts (process-spawn IO), projected
|
|
1178
|
+
// here like versionDrift/engineProbes.
|
|
1179
|
+
name: "scheduler-binary",
|
|
1180
|
+
channel: "advisory",
|
|
1181
|
+
run: (ctx) => ctx.schedulerBinaryDrift,
|
|
1182
|
+
},
|
|
1174
1183
|
];
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
// This Source Code Form is subject to the terms of the Mozilla Public
|
|
2
|
+
// License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
3
|
+
// file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
|
4
|
+
/**
|
|
5
|
+
* `scheduler-binary` advisory for `akm health` (#953).
|
|
6
|
+
*
|
|
7
|
+
* `akm task sync` records an absolute akm invocation path in the OS
|
|
8
|
+
* scheduler (`src/tasks/resolve-akm-bin.ts`) because cron/launchd/schtasks
|
|
9
|
+
* all run jobs with a minimal PATH. A field report showed that path going
|
|
10
|
+
* stale after upgrading akm through a different installer than the one
|
|
11
|
+
* active at the last `task sync` (e.g. npm-global to a standalone binary):
|
|
12
|
+
* the schedule kept invoking a version behind, with nothing in `akm health`
|
|
13
|
+
* surfacing it, until an unrelated failure (a rejected `secret://` engine
|
|
14
|
+
* key on the stale binary) surfaced the drift.
|
|
15
|
+
*
|
|
16
|
+
* Modelled 1:1 on `version-drift.ts`'s shape: an injectable seam,
|
|
17
|
+
* best-effort, `--probe`-gated so an air-gapped host's `--no-probe` habit
|
|
18
|
+
* suppresses this too, and `unknown` on any uncertainty rather than a false
|
|
19
|
+
* `pass`/`warn`. Reads the scheduler's recorded binding via
|
|
20
|
+
* `SchedulerBackend.list()` — the same reader `akm task doctor` uses — never
|
|
21
|
+
* a second crontab/launchd/schtasks text parser.
|
|
22
|
+
*/
|
|
23
|
+
import { spawnSync } from "node:child_process";
|
|
24
|
+
import { selectBackend } from "../../tasks/backends/index.js";
|
|
25
|
+
import { pkgVersion } from "../../version.js";
|
|
26
|
+
/**
|
|
27
|
+
* Bound on the scheduler-recorded binary's `--version` probe. Matches the
|
|
28
|
+
* `--version` timeout the engine-reachability checks already use
|
|
29
|
+
* (checks.ts's `runConfiguredEngineProbe`), so a wedged or missing binary
|
|
30
|
+
* degrades this advisory to `unknown` in seconds rather than blocking
|
|
31
|
+
* `akm health --probe`.
|
|
32
|
+
*/
|
|
33
|
+
const SCHEDULER_BINARY_VERSION_PROBE_TIMEOUT_MS = 5_000;
|
|
34
|
+
/**
|
|
35
|
+
* Build the `scheduler-binary` advisory. `probe` mirrors the
|
|
36
|
+
* engine-reachability and `cli-version` checks' `--probe`/`--no-probe`
|
|
37
|
+
* gating: only inspects the scheduler and spawns a process when `true`;
|
|
38
|
+
* otherwise `unknown` with "not probed", never touching the OS scheduler.
|
|
39
|
+
*/
|
|
40
|
+
export async function collectSchedulerBinaryAdvisory(probe, deps = {}) {
|
|
41
|
+
const cliVersion = deps.cliVersion ?? pkgVersion;
|
|
42
|
+
if (!probe) {
|
|
43
|
+
return {
|
|
44
|
+
name: "scheduler-binary",
|
|
45
|
+
kind: "deterministic",
|
|
46
|
+
status: "unknown",
|
|
47
|
+
confidence: "high",
|
|
48
|
+
message: "Scheduler binary version drift was not probed.",
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
let installed;
|
|
52
|
+
try {
|
|
53
|
+
installed = await (deps.backend ?? selectBackend()).list();
|
|
54
|
+
}
|
|
55
|
+
catch (error) {
|
|
56
|
+
return {
|
|
57
|
+
name: "scheduler-binary",
|
|
58
|
+
kind: "deterministic",
|
|
59
|
+
status: "unknown",
|
|
60
|
+
confidence: "high",
|
|
61
|
+
message: `Installed scheduled tasks could not be inspected: ${error instanceof Error ? error.message : String(error)}`,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
// task sync rewrites every installed binding to the same current
|
|
65
|
+
// invocation atomically, so the first entry's binding represents the
|
|
66
|
+
// whole schedule under normal operation.
|
|
67
|
+
const [firstInstalled] = installed;
|
|
68
|
+
const [binaryPath, ...leadingArgs] = firstInstalled?.binding ?? [];
|
|
69
|
+
if (!firstInstalled || !binaryPath) {
|
|
70
|
+
return {
|
|
71
|
+
name: "scheduler-binary",
|
|
72
|
+
kind: "deterministic",
|
|
73
|
+
status: "unknown",
|
|
74
|
+
confidence: "high",
|
|
75
|
+
message: "No scheduled task is installed.",
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
const binding = firstInstalled.binding;
|
|
79
|
+
const run = deps.spawnSync ?? spawnSync;
|
|
80
|
+
let scheduledVersion;
|
|
81
|
+
try {
|
|
82
|
+
const result = run(binaryPath, [...leadingArgs, "--version"], {
|
|
83
|
+
encoding: "utf8",
|
|
84
|
+
timeout: SCHEDULER_BINARY_VERSION_PROBE_TIMEOUT_MS,
|
|
85
|
+
});
|
|
86
|
+
if ((result.status ?? 1) === 0)
|
|
87
|
+
scheduledVersion = result.stdout?.trim() || undefined;
|
|
88
|
+
}
|
|
89
|
+
catch {
|
|
90
|
+
scheduledVersion = undefined;
|
|
91
|
+
}
|
|
92
|
+
if (!scheduledVersion) {
|
|
93
|
+
return {
|
|
94
|
+
name: "scheduler-binary",
|
|
95
|
+
kind: "deterministic",
|
|
96
|
+
status: "unknown",
|
|
97
|
+
confidence: "high",
|
|
98
|
+
message: "The scheduler's recorded akm binary could not be executed.",
|
|
99
|
+
evidence: { binding },
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
if (scheduledVersion === cliVersion) {
|
|
103
|
+
return {
|
|
104
|
+
name: "scheduler-binary",
|
|
105
|
+
kind: "deterministic",
|
|
106
|
+
status: "pass",
|
|
107
|
+
confidence: "high",
|
|
108
|
+
message: `Scheduled tasks are bound to akm v${scheduledVersion}, matching the running CLI.`,
|
|
109
|
+
evidence: { binding, scheduledVersion, cliVersion },
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
return {
|
|
113
|
+
name: "scheduler-binary",
|
|
114
|
+
kind: "deterministic",
|
|
115
|
+
status: "warn",
|
|
116
|
+
confidence: "high",
|
|
117
|
+
message: `Scheduled tasks are bound to akm v${scheduledVersion}, but the running CLI is v${cliVersion} — run \`akm task sync\` to rebind the schedule.`,
|
|
118
|
+
evidence: { binding, scheduledVersion, cliVersion },
|
|
119
|
+
};
|
|
120
|
+
}
|