akm-cli 0.9.14 → 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.
Files changed (120) hide show
  1. package/CHANGELOG.md +559 -0
  2. package/STABILITY.md +6 -3
  3. package/dist/akm +54 -1
  4. package/dist/akm-migrate +34 -1
  5. package/dist/assets/prompts/reflect-feedback-framing.md +1 -0
  6. package/dist/assets/prompts/reflect-llm-framed-contract.md +2 -0
  7. package/dist/assets/prompts/reflect-llm-schema-contract.md +2 -0
  8. package/dist/assets/tasks/core/improve.yml +1 -1
  9. package/dist/assets/tasks/core/index-refresh.yml +1 -1
  10. package/dist/assets/tasks/improve/akm-graph-refresh-weekly.yml +1 -1
  11. package/dist/assets/tasks/improve/akm-improve-catchup.yml +1 -1
  12. package/dist/assets/tasks/improve/akm-improve-consolidate.yml +1 -1
  13. package/dist/assets/tasks/improve/akm-improve-frequent.yml +1 -1
  14. package/dist/assets/tasks/improve/akm-improve-nightly.yml +1 -1
  15. package/dist/cli/retired-commands.js +0 -1
  16. package/dist/cli/shared.js +9 -0
  17. package/dist/cli/unknown-flags.js +1 -0
  18. package/dist/cli.js +40 -3
  19. package/dist/commands/config-cli.js +85 -3
  20. package/dist/commands/env/env-cli.js +1 -42
  21. package/dist/commands/env/env.js +1 -1
  22. package/dist/commands/env/secret-cli.js +1 -2
  23. package/dist/commands/health/checks.js +357 -63
  24. package/dist/commands/health/engine-usage.js +45 -0
  25. package/dist/commands/health/improve-metrics.js +18 -0
  26. package/dist/commands/health/llm-usage.js +41 -1
  27. package/dist/commands/health/plugin-staleness.js +7 -3
  28. package/dist/commands/health/version-drift.js +93 -0
  29. package/dist/commands/health/windows.js +3 -1
  30. package/dist/commands/health.js +44 -9
  31. package/dist/commands/improve/consolidate/chunking.js +4 -2
  32. package/dist/commands/improve/improve-cli.js +99 -5
  33. package/dist/commands/improve/improve-report.js +154 -0
  34. package/dist/commands/improve/improve-result-file.js +45 -33
  35. package/dist/commands/improve/improve-strategies.js +133 -3
  36. package/dist/commands/improve/improve-usage-report.js +182 -0
  37. package/dist/commands/improve/improve.js +40 -3
  38. package/dist/commands/improve/locks.js +28 -78
  39. package/dist/commands/improve/planner.js +1 -0
  40. package/dist/commands/improve/preparation.js +9 -1
  41. package/dist/commands/improve/reflect.js +44 -4
  42. package/dist/commands/models-cli.js +50 -1
  43. package/dist/commands/proposal/repository.js +8 -3
  44. package/dist/commands/proposal/validators/proposal-quality-validators.js +41 -6
  45. package/dist/commands/proposal/validators/proposal-validators.js +24 -0
  46. package/dist/commands/read/search-cli.js +38 -2
  47. package/dist/commands/read/show.js +103 -4
  48. package/dist/commands/sources/info.js +5 -1
  49. package/dist/commands/sources/installed-stashes.js +58 -16
  50. package/dist/commands/sources/self-update.js +2 -2
  51. package/dist/commands/sources/stash-cli.js +48 -0
  52. package/dist/commands/tasks/tasks-cli.js +49 -2
  53. package/dist/commands/workflow-cli.js +86 -12
  54. package/dist/core/asset/markdown-fragments.js +35 -0
  55. package/dist/core/config/config-schema.js +14 -0
  56. package/dist/core/config/config.js +302 -24
  57. package/dist/core/config/schema/embedding.js +41 -0
  58. package/dist/core/env-secret-ref.js +58 -5
  59. package/dist/core/errors.js +30 -0
  60. package/dist/core/file-lock.js +49 -15
  61. package/dist/core/improve-result.js +51 -0
  62. package/dist/core/loopback.js +17 -0
  63. package/dist/core/parent-watchdog.js +64 -0
  64. package/dist/core/paths.js +11 -0
  65. package/dist/core/run-lock.js +107 -0
  66. package/dist/core/sensitive-marker-path.js +19 -0
  67. package/dist/core/state-db.js +74 -14
  68. package/dist/indexer/index-rebuild-lock.js +73 -0
  69. package/dist/indexer/index-writer-lock.js +40 -1
  70. package/dist/indexer/index-written-assets.js +29 -1
  71. package/dist/indexer/indexer.js +93 -29
  72. package/dist/indexer/materialize-embeddings.js +564 -48
  73. package/dist/indexer/search/db-search.js +49 -2
  74. package/dist/indexer/search/search-source.js +23 -1
  75. package/dist/integrations/agent/engine-resolution.js +96 -6
  76. package/dist/integrations/agent/execution-definitions.js +6 -15
  77. package/dist/integrations/agent/execution-lowering.js +6 -1
  78. package/dist/integrations/agent/execution-preparation.js +1 -1
  79. package/dist/integrations/agent/model-map.js +123 -20
  80. package/dist/integrations/agent/prompts.js +40 -8
  81. package/dist/integrations/agent/runner-dispatch.js +9 -3
  82. package/dist/integrations/agent/runner.js +2 -0
  83. package/dist/llm/client.js +8 -3
  84. package/dist/llm/embedder.js +20 -8
  85. package/dist/llm/embedders/local.js +10 -2
  86. package/dist/llm/embedders/remote.js +497 -32
  87. package/dist/output/shapes/helpers.js +38 -2
  88. package/dist/output/shapes/models-list.js +16 -0
  89. package/dist/output/shapes/passthrough.js +2 -0
  90. package/dist/output/shapes.js +4 -0
  91. package/dist/output/text/command-format.js +29 -0
  92. package/dist/output/text/helpers.js +1 -1
  93. package/dist/output/text/improve-report.js +27 -0
  94. package/dist/{commands/env/marker-path.js → output/text/models.js} +4 -3
  95. package/dist/output/text/show-format.js +4 -0
  96. package/dist/output/text.js +4 -0
  97. package/dist/scripts/akm-migrate-node.js +25146 -21759
  98. package/dist/scripts/akm-migrate.js +24271 -20885
  99. package/dist/storage/repositories/embedding-salvage-repository.js +184 -0
  100. package/dist/storage/repositories/improve-runs-repository.js +34 -0
  101. package/dist/storage/repositories/index-fts-repository.js +49 -6
  102. package/dist/storage/repositories/index-schema.js +16 -0
  103. package/dist/storage/repositories/index-vec-repository.js +30 -0
  104. package/dist/storage/repositories/workflow-runs-repository.js +55 -18
  105. package/dist/tasks/backends/cron.js +14 -7
  106. package/dist/tasks/run/run-native-task.js +23 -1
  107. package/dist/tasks/run/run-workflow-task.js +16 -0
  108. package/dist/workflows/exec/child-workflow.js +2 -2
  109. package/dist/workflows/exec/dispatch-redaction.js +21 -9
  110. package/dist/workflows/exec/run-workflow.js +6 -5
  111. package/dist/workflows/runtime/runs.js +33 -5
  112. package/docs/migration/release-notes/0.9.15.md +133 -0
  113. package/docs/migration/release-notes/README.md +5 -0
  114. package/docs/reference/cli.md +271 -30
  115. package/docs/reference/configuration.md +234 -21
  116. package/docs/reference/data-and-telemetry.md +8 -0
  117. package/docs/reference/tasks.md +16 -1
  118. package/docs/reference/workflow-schema.md +5 -1
  119. package/package.json +1 -1
  120. package/schemas/akm-config.json +47 -0
@@ -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();
@@ -16,13 +16,60 @@ import path from "node:path";
16
16
  import { resolveSourceEntries } from "../indexer/search/search-source.js";
17
17
  import { resolveSourcesForOrigin } from "../registry/origin-resolve.js";
18
18
  import { assertFlatAssetName, combineCreatePath, normalizeCreateSubPath } from "./asset/asset-create.js";
19
- import { assetPathForName } from "./asset/asset-placement.js";
19
+ import { assetPathForName, deriveCanonicalAssetName } from "./asset/asset-placement.js";
20
20
  import { displayRef, isFullRefInput, parseRefInput } from "./asset/resolve-ref.js";
21
21
  import { isWithin } from "./common.js";
22
22
  import { loadConfig } from "./config/config.js";
23
23
  import { NotFoundError, UsageError } from "./errors.js";
24
24
  import { resolveMutationTarget } from "./mutation-target.js";
25
+ import { sensitiveMarkerPath } from "./sensitive-marker-path.js";
25
26
  import { formatRefForMessage, withWriteTargetMutation } from "./write-source.js";
27
+ export { sensitiveMarkerPath } from "./sensitive-marker-path.js";
28
+ /**
29
+ * Walk each stash's env files and return one entry per `.env` file, using the
30
+ * env asset spec's canonical-name logic (e.g. `env/team/prod.env` →
31
+ * `env/team/prod`, `env/team/.env` → `env/team/default`). Moved here from
32
+ * `commands/env/env-cli.ts` (#950) so `commands/health` can reuse it to name
33
+ * which env asset supplies a credential's variable — pure move, `akm env
34
+ * list`'s behaviour is unchanged. `listKeysFn` stays injected (rather than a
35
+ * static import of `commands/env/env.ts`) so this core module never depends
36
+ * upward on the commands layer. `config` defaults to the real `loadConfig()`
37
+ * (unchanged default for `akm env list`); `commands/health` passes its own
38
+ * injected/resolved config so a test-supplied `loadConfig` seam is honoured
39
+ * instead of this always re-reading the real config/sources.
40
+ */
41
+ export function listEnvsRecursive(listKeysFn, config = loadConfig()) {
42
+ const result = [];
43
+ for (const source of resolveSourceEntries(undefined, config)) {
44
+ const root = path.join(source.path, "env");
45
+ if (!fs.existsSync(root))
46
+ continue;
47
+ const walk = (dir) => {
48
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
49
+ const full = path.join(dir, entry.name);
50
+ if (entry.isDirectory()) {
51
+ walk(full);
52
+ continue;
53
+ }
54
+ if (!entry.isFile())
55
+ continue;
56
+ if (entry.name !== ".env" && !entry.name.endsWith(".env"))
57
+ continue;
58
+ const canonical = deriveCanonicalAssetName("env", root, full);
59
+ if (!canonical)
60
+ continue;
61
+ // Skip sensitive envs: a sibling .sensitive marker file suppresses listing.
62
+ const markerPath = sensitiveMarkerPath(full, "env");
63
+ if (fs.existsSync(markerPath))
64
+ continue;
65
+ const { keys } = listKeysFn(full);
66
+ result.push({ ref: makeEnvRef(canonical, source, config), path: full, keys });
67
+ }
68
+ };
69
+ walk(root);
70
+ }
71
+ return result;
72
+ }
26
73
  /**
27
74
  * The `vault` asset type was removed in 0.9.0. The env/secret input path no
28
75
  * longer routes through the legacy stored-ref parser (which carries the removal
@@ -91,10 +138,17 @@ export function findEnvSource(origin, type, name) {
91
138
  }
92
139
  return named;
93
140
  }
94
- export function makeEnvRef(name, source) {
141
+ /**
142
+ * `config` defaults to the real `loadConfig()`, same as {@link displayDefaultBundle}.
143
+ * `listEnvsRecursive` threads its own (possibly injected) config through here so a
144
+ * caller-supplied config governs ref display the same way it governs which stashes
145
+ * get walked — otherwise this would silently fall back to reading the real config a
146
+ * second time even when the walk itself honoured an injected one.
147
+ */
148
+ export function makeEnvRef(name, source, config = loadConfig()) {
95
149
  // F4b output-spelling flip: `env/name` in the primary stash, `bundle//env/name`
96
150
  // for a slug-clean named source.
97
- return displayRef({ type: "env", name, bundleId: source?.registryId }, displayDefaultBundle(source));
151
+ return displayRef({ type: "env", name, bundleId: source?.registryId }, displayDefaultBundle(source, config));
98
152
  }
99
153
  /**
100
154
  * Resolve an env ref to an absolute `.env` path. Accepts the `env/<name>`
@@ -132,8 +186,7 @@ export function makeSecretRef(name, source) {
132
186
  // `bundle//secrets/name` for a slug-clean named source.
133
187
  return displayRef({ type: "secret", name, bundleId: source?.registryId }, displayDefaultBundle(source));
134
188
  }
135
- function displayDefaultBundle(source) {
136
- const config = loadConfig();
189
+ function displayDefaultBundle(source, config = loadConfig()) {
137
190
  if (config.defaultBundle || !source)
138
191
  return config.defaultBundle;
139
192
  const primary = resolveSourceEntries(undefined, config)[0];
@@ -77,7 +77,11 @@ const USAGE_HINTS = {
77
77
  WORKFLOW_IR_VERSION_UNSUPPORTED: "Abandon the run with `akm workflow abandon <id>`, then start it again from the workflow source — a frozen plan this akm cannot execute is not re-executable in place.",
78
78
  // P3b (docs/plans/specs/p3b-child-executor.md §4.3).
79
79
  WORKFLOW_OUTPUT_INVALID: "Check each `outputs:` entry's `from:` against the step artifact it names, and its `schema:` against the value that step actually promotes.",
80
+ };
81
+ /** Default hint for each TransientErrorCode. */
82
+ const TRANSIENT_HINTS = {
80
83
  RUN_LEASE_HELD: "Wait for the named engine invocation to finish or for the lease to expire, then retry. `akm workflow status <id>` shows the current lease.",
84
+ STATE_DB_CONTENDED: "Another akm process is writing state.db right now. Wait a few seconds and retry; commands that support --skip-if-locked can skip instead of failing.",
81
85
  };
82
86
  /** Default hint for each NotFoundError code. */
83
87
  const NOT_FOUND_HINTS = {
@@ -89,6 +93,7 @@ const NOT_FOUND_HINTS = {
89
93
  // for a mistyped id, which points at the wrong thing entirely.
90
94
  PROPOSAL_NOT_FOUND: "Run `akm proposal list` to see pending proposals and their ids.",
91
95
  FILE_NOT_FOUND: "Check the path exists and is readable.",
96
+ IMPROVE_RUN_NOT_FOUND: "Run `akm improve` first, or `akm improve report --since 30d` to see recent run ids in `runIds`.",
92
97
  };
93
98
  /**
94
99
  * Base class for all akm-thrown, classified errors. Carries the `kind`
@@ -131,6 +136,31 @@ export class UsageError extends AkmError {
131
136
  return this._hint ?? USAGE_HINTS[this.code];
132
137
  }
133
138
  }
139
+ /**
140
+ * Raised when a condition is ordinary, retryable contention rather than a
141
+ * bad command line or a genuine failure — another akm process holds a lock
142
+ * or is writing state.db right now. Distinct from `UsageError` (#948
143
+ * addendum, dev-team field review 2026-09-09): schedulers classify exit 2 as
144
+ * "fix the command line", so contention needs its own exit code (75,
145
+ * sysexits EX_TEMPFAIL) a cron wrapper can branch on to retry instead of
146
+ * alerting.
147
+ */
148
+ export class TransientError extends AkmError {
149
+ kind = "transient";
150
+ code;
151
+ _hint;
152
+ constructor(msg, code, hint) {
153
+ super(msg);
154
+ this.name = "TransientError";
155
+ this.code = code;
156
+ this._hint = hint;
157
+ // Fixes `instanceof` checks under ES5 transpilation targets.
158
+ Object.setPrototypeOf(this, new.target.prototype);
159
+ }
160
+ hint() {
161
+ return this._hint ?? TRANSIENT_HINTS[this.code];
162
+ }
163
+ }
134
164
  /** Raised when a requested resource (asset, entry, file) is not found. */
135
165
  export class NotFoundError extends AkmError {
136
166
  kind = "not-found";
@@ -120,9 +120,36 @@ function releaseLockRaw(lockPath) {
120
120
  export function tryAcquireLockSync(lockPath, payload) {
121
121
  return withLockOperationMutex(lockPath, () => tryAcquireLockRaw(lockPath, payload));
122
122
  }
123
- /** Build a PID-bearing payload with a unique token for one acquisition attempt. */
123
+ /**
124
+ * Best-effort launcher pid from `AKM_LAUNCHER_PID` (set by
125
+ * `scripts/node-runtime/akm`/`akm-migrate`, #956) — undefined when unset,
126
+ * empty, or not a positive integer (never trust an ambient env var blindly
127
+ * into a lock message). Also the gate the parent-death watchdog
128
+ * (`core/parent-watchdog.ts`) uses to stay inert outside a launcher-managed
129
+ * run.
130
+ */
131
+ export function launcherPidFromEnv() {
132
+ const raw = process.env.AKM_LAUNCHER_PID;
133
+ if (!raw)
134
+ return undefined;
135
+ const pid = Number.parseInt(raw, 10);
136
+ return Number.isInteger(pid) && pid > 0 ? pid : undefined;
137
+ }
138
+ /**
139
+ * Build a PID-bearing payload with a unique token for one acquisition attempt.
140
+ * Adds `launcherPid` (#956) whenever this process is running under the
141
+ * published launcher, so a lock's holder can be identified by the pid every
142
+ * process listing and task log actually shows (the launcher's) as well as
143
+ * the pid that holds the lock (the bun/node child).
144
+ */
124
145
  export function createLockPayload(metadata = {}) {
125
- return JSON.stringify({ ...metadata, pid: process.pid, lockId: randomUUID() });
146
+ const launcherPid = launcherPidFromEnv();
147
+ return JSON.stringify({
148
+ ...metadata,
149
+ pid: process.pid,
150
+ ...(launcherPid !== undefined ? { launcherPid } : {}),
151
+ lockId: randomUUID(),
152
+ });
126
153
  }
127
154
  /**
128
155
  * Inspect an existing sentinel at `lockPath` without modifying it.
@@ -153,17 +180,17 @@ export function probeLock(lockPath, opts) {
153
180
  return { state: "absent" };
154
181
  const { rawContent, identity } = snapshot;
155
182
  const ageMs = Date.now() - identity.mtimeMs;
156
- const holderPid = extractHolderPid(rawContent);
183
+ const { holderPid, launcherPid } = extractLockIdentity(rawContent);
157
184
  if (holderPid === undefined) {
158
185
  return { state: "stale", reason: "invalid_pid", ageMs, rawContent, identity };
159
186
  }
160
187
  if (!isProcessAlive(holderPid)) {
161
- return { state: "stale", reason: "pid_dead", holderPid, ageMs, rawContent, identity };
188
+ return { state: "stale", reason: "pid_dead", holderPid, launcherPid, ageMs, rawContent, identity };
162
189
  }
163
190
  if (opts?.staleAfterMs !== undefined && ageMs > opts.staleAfterMs) {
164
- return { state: "stale", reason: "age_exceeded", holderPid, ageMs, rawContent, identity };
191
+ return { state: "stale", reason: "age_exceeded", holderPid, launcherPid, ageMs, rawContent, identity };
165
192
  }
166
- return { state: "held", holderPid, ageMs, rawContent, identity };
193
+ return { state: "held", holderPid, launcherPid, ageMs, rawContent, identity };
167
194
  }
168
195
  /**
169
196
  * Revalidate and quarantine the probed sentinel while holding the same operation
@@ -254,25 +281,32 @@ export function releaseLock(ownership) {
254
281
  });
255
282
  }
256
283
  /**
257
- * Extract a PID from a sentinel body. Accepts the two shapes used across
258
- * the codebase: a bare numeric string (config-io, vault, lockfile) and
259
- * a JSON object with a `pid` field (improve). Returns undefined when the
260
- * body is unparseable or yields a non-positive integer.
284
+ * Extract a holder pid, and (#956) a launcher pid when the payload recorded
285
+ * one, from a sentinel body. Accepts the two shapes used across the
286
+ * codebase: a bare numeric string (config-io, vault, lockfile never
287
+ * carries a `launcherPid`) and a JSON object with `pid`/`launcherPid` fields
288
+ * (`createLockPayload`). `holderPid` is undefined when the body is
289
+ * unparseable or yields a non-positive integer; `launcherPid` is undefined
290
+ * whenever the payload has none, independent of whether `holderPid` parsed.
261
291
  */
262
- function extractHolderPid(content) {
292
+ function extractLockIdentity(content) {
263
293
  const trimmed = content.trim();
264
294
  if (!trimmed)
265
- return undefined;
295
+ return {};
266
296
  if (trimmed.startsWith("{")) {
267
297
  try {
268
298
  const parsed = JSON.parse(trimmed);
269
299
  const pid = typeof parsed.pid === "number" ? parsed.pid : Number.NaN;
270
- return Number.isInteger(pid) && pid > 0 ? pid : undefined;
300
+ const rawLauncherPid = typeof parsed.launcherPid === "number" ? parsed.launcherPid : Number.NaN;
301
+ return {
302
+ holderPid: Number.isInteger(pid) && pid > 0 ? pid : undefined,
303
+ launcherPid: Number.isInteger(rawLauncherPid) && rawLauncherPid > 0 ? rawLauncherPid : undefined,
304
+ };
271
305
  }
272
306
  catch {
273
- return undefined;
307
+ return {};
274
308
  }
275
309
  }
276
310
  const pid = Number.parseInt(trimmed, 10);
277
- return Number.isInteger(pid) && pid > 0 ? pid : undefined;
311
+ return { holderPid: Number.isInteger(pid) && pid > 0 ? pid : undefined };
278
312
  }
@@ -3,6 +3,7 @@
3
3
  // file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
4
  import { cloneExecutionJsonObject } from "../execution/json.js";
5
5
  import { isRecord } from "./common.js";
6
+ import { IMPROVE_PROCESS_ENGINE_CAPABILITIES } from "./config/engine-semantics.js";
6
7
  const COMMON_FIELDS = [
7
8
  "schemaVersion",
8
9
  "ok",
@@ -17,6 +18,7 @@ const COMMON_FIELDS = [
17
18
  "plannedRefs",
18
19
  "plan",
19
20
  "actions",
21
+ "skippedProcesses",
20
22
  "distillSkipped",
21
23
  "validationFailures",
22
24
  "schemaRepairs",
@@ -47,6 +49,7 @@ const COMMON_FIELDS = [
47
49
  "sync",
48
50
  "writtenPaths",
49
51
  "terminated",
52
+ "usageReport",
50
53
  ];
51
54
  const V2_FIELDS = new Set([...COMMON_FIELDS, "strategy", "strategyFilteredRefs"]);
52
55
  function fail(message) {
@@ -179,6 +182,50 @@ function validateProactivePlan(value) {
179
182
  fail("plan.proactive.selected must equal plan.proactive.selectedRefs.length");
180
183
  }
181
184
  }
185
+ /** #947 — plan.processes: one row per IMPROVE_PROCESS_ENGINE_CAPABILITIES name, plus an optional "triage.judgment" row. */
186
+ function validateProcessRoutingRows(value) {
187
+ if (!Array.isArray(value))
188
+ fail("plan.processes must be an array");
189
+ const canonicalNames = Object.keys(IMPROVE_PROCESS_ENGINE_CAPABILITIES);
190
+ const engineKinds = new Set(["llm", "agent", "sdk"]);
191
+ const seen = new Set();
192
+ for (const row of value) {
193
+ if (!isRecord(row))
194
+ fail("plan.processes entries must be objects");
195
+ requireExactFields(row, new Set(["process", "enabled", "engine", "model", "engineKind", "notices", "unavailable", "eligibleRefs"]));
196
+ if (typeof row.process !== "string" ||
197
+ !(canonicalNames.includes(row.process) || row.process === "triage.judgment")) {
198
+ fail("plan.processes.process is invalid");
199
+ }
200
+ if (seen.has(row.process))
201
+ fail(`plan.processes must not repeat "${row.process}"`);
202
+ seen.add(row.process);
203
+ if (typeof row.enabled !== "boolean")
204
+ fail("plan.processes.enabled must be a boolean");
205
+ if (row.engine !== undefined && typeof row.engine !== "string")
206
+ fail("plan.processes.engine must be a string");
207
+ if (row.model !== undefined && typeof row.model !== "string")
208
+ fail("plan.processes.model must be a string");
209
+ if (row.engineKind !== undefined && (typeof row.engineKind !== "string" || !engineKinds.has(row.engineKind))) {
210
+ fail("plan.processes.engineKind is invalid");
211
+ }
212
+ validateLoweringNotices(row.notices);
213
+ if (row.unavailable !== undefined) {
214
+ if (!isRecord(row.unavailable))
215
+ fail("plan.processes.unavailable must be an object");
216
+ requireExactFields(row.unavailable, new Set(["configKey", "reason"]));
217
+ if (typeof row.unavailable.configKey !== "string" || typeof row.unavailable.reason !== "string") {
218
+ fail("plan.processes.unavailable must contain string configKey and reason");
219
+ }
220
+ }
221
+ if (row.eligibleRefs !== undefined)
222
+ requireCount(row, "eligibleRefs", "plan.processes entry");
223
+ }
224
+ for (const name of canonicalNames) {
225
+ if (!seen.has(name))
226
+ fail(`plan.processes must contain exactly one "${name}" row`);
227
+ }
228
+ }
182
229
  function validateImprovePlan(value, dryRun, plannedRefNames) {
183
230
  if (!isRecord(value))
184
231
  fail("plan must be an object");
@@ -190,6 +237,7 @@ function validateImprovePlan(value, dryRun, plannedRefNames) {
190
237
  "limits",
191
238
  "gates",
192
239
  "effectiveRefs",
240
+ "processes",
193
241
  "proactive",
194
242
  "consolidation",
195
243
  "stages",
@@ -328,6 +376,7 @@ function validateImprovePlan(value, dryRun, plannedRefNames) {
328
376
  if (value.limits.totalCeiling !== undefined && value.effectiveRefs.length > value.limits.totalCeiling) {
329
377
  fail("plan.effectiveRefs cannot exceed plan.limits.totalCeiling");
330
378
  }
379
+ validateProcessRoutingRows(value.processes);
331
380
  if (value.proactive !== undefined)
332
381
  validateProactivePlan(value.proactive);
333
382
  validateConsolidationPlan(value.consolidation);
@@ -392,6 +441,7 @@ function validateCommon(value) {
392
441
  }
393
442
  for (const field of [
394
443
  "actions",
444
+ "skippedProcesses",
395
445
  "validationFailures",
396
446
  "schemaRepairs",
397
447
  "extract",
@@ -442,6 +492,7 @@ function validateCommon(value) {
442
492
  "terminated",
443
493
  "plan",
444
494
  "deadUrlCoverage",
495
+ "usageReport",
445
496
  ]) {
446
497
  if (value[field] !== undefined && !isRecord(value[field]))
447
498
  fail(`${field} must be an object`);
@@ -87,3 +87,20 @@ export function isLoopbackEndpoint(endpoint) {
87
87
  return true;
88
88
  }
89
89
  }
90
+ /**
91
+ * The lowest-common-denominator concurrency default for an LLM/embedding
92
+ * endpoint: 1 for a loopback endpoint (a local model server serves one
93
+ * inference at a time; concurrent requests thrash it — reload thrash, HTTP
94
+ * 500 "Model reloaded"), 2 for a remote one (enough to overlap request
95
+ * latency without hammering a rate-limited API). A leaf helper so both
96
+ * callers — the indexer's LLM enrichment pool (`getDefaultLlmConcurrency`,
97
+ * `src/indexer/indexer.ts`) and the embedding pool
98
+ * (`resolveEmbeddingConcurrency`, `src/llm/embedders/remote.ts`) — share one
99
+ * definition instead of mirroring it; `src/llm/embedders/remote.ts` cannot
100
+ * import `getDefaultLlmConcurrency` directly (`src/indexer/indexer.ts`
101
+ * already depends on this module transitively through
102
+ * materialize-embeddings.ts).
103
+ */
104
+ export function defaultConcurrencyForEndpoint(endpoint) {
105
+ return isLoopbackEndpoint(endpoint) ? 1 : 2;
106
+ }
@@ -0,0 +1,64 @@
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
+ * Parent-death watchdog (#956).
6
+ *
7
+ * The published launcher (`scripts/node-runtime/akm`) now forwards SIGTERM to
8
+ * its child, so a `kill <launcher-pid>` no longer orphans it — but a launcher
9
+ * that dies WITHOUT delivering a signal (SIGKILL, an out-of-memory kill, a
10
+ * supervisor that force-removes the process) still reparents the child to
11
+ * init with nothing to catch. Field evidence: 40 orphaned
12
+ * `bun …/dist/cli.js` processes over one day, up to 10h old, some created by
13
+ * a curate hook killing its own launcher on timeout — every akm invocation
14
+ * under the launcher is exposed to this, not only `akm index`, so this
15
+ * watchdog runs for every command (wired at the CLI entry point in
16
+ * `src/cli.ts`), not just index.
17
+ *
18
+ * `process.ppid` on POSIX changes the instant the parent exits (the child is
19
+ * reparented, usually to pid 1) — polling it is the standard orphan-detection
20
+ * trick when no direct death notification exists. This module owns only the
21
+ * poll/compare mechanics; the caller's `onOrphaned` decides what "stop"
22
+ * means. `src/cli.ts` wires it to `process.kill(process.pid, "SIGTERM")`,
23
+ * reusing the same self-signal every command already has rather than a
24
+ * second, parallel abort path. Only `akm index`/`improve` register their own
25
+ * SIGTERM listener (the index command's AbortController in
26
+ * `commands/sources/stash-cli.ts`) and get a graceful, in-process shutdown
27
+ * from it. Every other command has no listener of its own, so the runtime's
28
+ * default disposition terminates it directly — `exit` handlers (lock
29
+ * release included) do NOT run — and any lock it held is cleared later by
30
+ * the next acquirer's dead-pid stale-reclaim (`file-lock.ts`), not by
31
+ * in-process release. Either way the orphaned process stops, which is this
32
+ * watchdog's actual job.
33
+ */
34
+ /** Pure comparison seam: true once the observed ppid differs from the one seen at startup. */
35
+ export function isReparented(initialPpid, currentPpid) {
36
+ return currentPpid !== initialPpid;
37
+ }
38
+ /**
39
+ * Start polling `getPpid()` every `intervalMs` and invoke `onOrphaned` once,
40
+ * the first time the observed ppid no longer matches `initialPpid`. The timer
41
+ * is unref'ed so it never keeps the process alive on its own — a command that
42
+ * finishes normally still exits promptly.
43
+ */
44
+ export function startParentDeathWatchdog(options) {
45
+ const { initialPpid, onOrphaned } = options;
46
+ const intervalMs = options.intervalMs ?? 2000;
47
+ const getPpid = options.getPpid ?? (() => process.ppid);
48
+ const setIntervalFn = options.setIntervalFn ?? setInterval;
49
+ const clearIntervalFn = options.clearIntervalFn ?? clearInterval;
50
+ let fired = false;
51
+ const timer = setIntervalFn(() => {
52
+ if (fired)
53
+ return;
54
+ if (isReparented(initialPpid, getPpid())) {
55
+ fired = true;
56
+ onOrphaned();
57
+ }
58
+ }, intervalMs);
59
+ if (typeof timer !== "number")
60
+ timer.unref?.();
61
+ return {
62
+ stop: () => clearIntervalFn(timer),
63
+ };
64
+ }
@@ -234,6 +234,17 @@ export function getDbPath(env = process.env) {
234
234
  export function getIndexWriterLockPath() {
235
235
  return path.join(getDataDir(), "index.db.write.lock");
236
236
  }
237
+ /**
238
+ * Path to the opt-in, PID-liveness-only rebuild lock an explicit `akm index`
239
+ * run acquires (#956). Distinct from {@link getIndexWriterLockPath}'s
240
+ * `index.db.write.lock`, which is the asset-mutation lease and unrelated to
241
+ * indexing since #872 — this lock never blocks and is never required, it
242
+ * only lets a scheduled/opportunistic `akm index --skip-if-locked` step
243
+ * aside instead of contending with a run already in progress.
244
+ */
245
+ export function getIndexRebuildLockPath() {
246
+ return path.join(getDataDir(), "index.rebuild.lock");
247
+ }
237
248
  export function getMaintenanceBarrierPath() {
238
249
  return path.join(getDataDir(), "maintenance.barrier.lock");
239
250
  }
@@ -0,0 +1,107 @@
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
+ * PID-liveness-only run lock — the shared mechanics behind `akm improve`'s
6
+ * whole-run lock (`commands/improve/locks.ts`) and the index rebuild lock
7
+ * (`indexer/index-rebuild-lock.ts`, #956).
8
+ *
9
+ * No `staleAfterMs`: only a verifiably dead holder is ever reclaimed. This is
10
+ * the #872 lesson encoded at the mechanics layer so every future caller gets
11
+ * it for free — an age-based stale window let a live-but-wedged holder's
12
+ * lease survive forever from the holder's own point of view while stranding
13
+ * every OTHER invocation once the clock passed, which cost one real install
14
+ * a half-day indexing outage. SQLite's own WAL + busy_timeout + BEGIN
15
+ * IMMEDIATE already serialize concurrent writers at the correctness layer —
16
+ * this lock only ever avoids duplicate LOGICAL work or lets a caller choose
17
+ * to skip instead of contend.
18
+ *
19
+ * This module performs exactly one create-or-probe attempt (with the same
20
+ * absent-race and stale-reclaim retries `improve` established) and reports
21
+ * whether the caller now owns the lock or, if not, who currently holds it.
22
+ * It does not decide what "held" means — skip, throw, or warn-and-proceed is
23
+ * entirely up to the caller — and it does not serialize the attempt itself:
24
+ * every caller must wrap the call in
25
+ * `withMaintenanceStartBarrier`/`tryWithMaintenanceStartBarrier`
26
+ * (`core/maintenance-barrier.ts`) so two racing processes never both create
27
+ * the sentinel in the same window.
28
+ */
29
+ import fs from "node:fs";
30
+ import path from "node:path";
31
+ import { ConfigError } from "./errors.js";
32
+ import { createLockPayload, probeLock, reclaimStaleLock, tryAcquireLockSync } from "./file-lock.js";
33
+ import { describeInaccessiblePath } from "./path-access.js";
34
+ /**
35
+ * Render a lock holder's pid for a message: `"4242"`, or `"4242 (launcher
36
+ * 4240)"` when the holder's launcher pid is known (#956) — every process
37
+ * listing and task log shows the launcher pid, not the bun/node child's, so
38
+ * naming only the holder pid left an operator unable to connect the two.
39
+ */
40
+ export function formatLockHolderPid(holder) {
41
+ if (holder.pid === null)
42
+ return "unknown";
43
+ return holder.launcherPid !== null ? `${holder.pid} (launcher ${holder.launcherPid})` : String(holder.pid);
44
+ }
45
+ function parseLockPayload(rawContent) {
46
+ if (!rawContent)
47
+ return null;
48
+ try {
49
+ return JSON.parse(rawContent);
50
+ }
51
+ catch {
52
+ return null;
53
+ }
54
+ }
55
+ function holderOf(lock) {
56
+ return { pid: lock?.pid ?? null, startedAt: lock?.startedAt ?? null, launcherPid: lock?.launcherPid ?? null };
57
+ }
58
+ /**
59
+ * Attempt to acquire `lockPath`. Returns `{ state: "acquired" }` with an
60
+ * ownership handle for {@link releaseLock}, or `{ state: "held", holder }`
61
+ * naming the current holder (best-effort; `pid`/`startedAt` are `null` when
62
+ * the holder identity could not be determined, e.g. a release-then-reacquire
63
+ * race). Throws {@link ConfigError} if the sentinel exists but cannot be read
64
+ * (#791: a lock we cannot see may be genuinely held — never a reclaim
65
+ * candidate).
66
+ */
67
+ export function tryAcquireRunLock(lockPath, options) {
68
+ fs.mkdirSync(path.dirname(lockPath), { recursive: true });
69
+ const lockPayload = () => createLockPayload({ startedAt: new Date().toISOString(), ...options.payloadMetadata });
70
+ let ownership = tryAcquireLockSync(lockPath, lockPayload());
71
+ if (ownership)
72
+ return { state: "acquired", ownership };
73
+ const probe = probeLock(lockPath);
74
+ // Race: the holder released between the failed acquire above and this
75
+ // probe. Retry once rather than falling through with a null-PID "held"
76
+ // report for a lock nobody actually holds.
77
+ if (probe.state === "absent") {
78
+ ownership = tryAcquireLockSync(lockPath, lockPayload());
79
+ if (ownership)
80
+ return { state: "acquired", ownership };
81
+ // Re-grabbed by another racer in this exact window — no holder detail
82
+ // available without re-probing (which could itself race again).
83
+ return { state: "held", holder: { pid: null, startedAt: null, launcherPid: null } };
84
+ }
85
+ if (probe.state === "inaccessible") {
86
+ throw new ConfigError(`${options.label} lock exists but is not readable: ${describeInaccessiblePath(lockPath, probe.code)}.`, "DATA_DIR_UNREADABLE");
87
+ }
88
+ const lock = parseLockPayload(probe.rawContent);
89
+ if (probe.state === "stale") {
90
+ if (!reclaimStaleLock(lockPath, probe)) {
91
+ return { state: "held", holder: holderOf(lock) };
92
+ }
93
+ options.onReclaimed?.({
94
+ holderPid: lock?.pid ?? probe.holderPid ?? null,
95
+ lockedAt: lock?.startedAt ?? null,
96
+ ageMs: probe.ageMs ?? null,
97
+ reason: probe.reason === "pid_dead" ? "pid_not_alive" : probe.reason,
98
+ });
99
+ ownership = tryAcquireLockSync(lockPath, lockPayload());
100
+ if (ownership)
101
+ return { state: "acquired", ownership };
102
+ // Acquired by another racer during stale recovery.
103
+ return { state: "held", holder: holderOf(lock) };
104
+ }
105
+ // probe.state === "held"
106
+ return { state: "held", holder: holderOf(lock) };
107
+ }
@@ -0,0 +1,19 @@
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
+ * Path to the sibling marker file that suppresses listing for a sensitive
6
+ * env/secret asset. Deliberately its own leaf module (no imports) rather than
7
+ * living on `core/env-secret-ref.ts` (#950 originally put it there): that
8
+ * module transitively imports the source providers via
9
+ * `indexer/search/search-source`, and `commands/env/env.ts` is imported back
10
+ * from `core/adapter/adapters/akm-metadata.ts` (for `scanEnvKeyNames`) — so an
11
+ * `env.ts` import of the heavier module closes a real cycle through the
12
+ * adapter registry (`core/adapter/adapters/index.ts`'s `BUILTIN_ADAPTERS`
13
+ * construction sees `akmAdapter` still TDZ). Keeping this helper leaf-only
14
+ * lets `commands/env/env.ts` and `core/env-secret-ref.ts` both depend on it
15
+ * without either pulling the other's heavier graph.
16
+ */
17
+ export function sensitiveMarkerPath(assetPath, type) {
18
+ return type === "env" ? assetPath.replace(/\.env$/, ".sensitive") : `${assetPath}.sensitive`;
19
+ }