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 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.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 } }`) 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,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
- - **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.
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)], { 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";
@@ -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 and check for a newer akm release (on by default).",
297
- negativeDescription: "Skip the reachability probes and the update check (for an offline or air-gapped host).",
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
- 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
  }
@@ -1164,11 +1164,20 @@ export const HEALTH_CHECKS = [
1164
1164
  run: (ctx) => ctx.versionDrift,
1165
1165
  },
1166
1166
  {
1167
- // #950: registered last order is load-bearing (see the HEALTH_CHECKS
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
+ }