akm-cli 0.9.15-beta.3 → 0.9.15-beta.5

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.3] - 2026-09-10
7
+ ## [0.9.15-beta.5] - 2026-09-10
8
8
 
9
9
  ### Added
10
10
 
@@ -159,6 +159,32 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
159
159
  (#951).** These are akm's resolved data/config/cache/state directories, so a
160
160
  script can read `akm info --format json | jq -r .dataDir` instead of hardcoding
161
161
  a path that differs between a host install and a container.
162
+ - **A real `curate_rerank` implementation: `search.curateRerank`, a cross-
163
+ encoder rerank pass over `akm curate`'s already-selected candidates (#951).**
164
+ `curate_rerank` was a dead `llm.features.*` key removed in 0.8.0 with no wire
165
+ call ever implemented under it. `search.curateRerank.{enabled, endpoint,
166
+ model, apiKey, timeoutMs, topN}` configures a standalone `/rerank`-style
167
+ endpoint (`src/llm/rerank-client.ts`, `POST {model, query, documents}` →
168
+ `{results: [{index, relevance_score}]}`); when enabled, curate sends its top
169
+ `topN` (default 8) already-ranked candidates and reorders them by the
170
+ endpoint's scores. Disabled by default, and best-effort like every other
171
+ bounded LLM feature: a misconfigured endpoint, network failure, timeout, or
172
+ malformed response falls back to curate's own ranking unchanged rather than
173
+ failing the command. Deliberately its own config arm rather than a third
174
+ `engines` kind alongside `"llm"`/`"agent"` — that union's kinds are
175
+ load-bearing through execution-lowering, runner dispatch, and the harness
176
+ model map, none of which a reranker touches.
177
+ - **Per-run task log files are purged past the retention window (#951).**
178
+ `tasks/logs/<taskId>/<timestamp>.log` (the per-run human-readable tail;
179
+ `logs.db` is the durable, already-purged record) had file separation but no
180
+ cleanup, so old run files accumulated forever. `akm improve`'s existing
181
+ retention pass now also deletes `.log` files under `getTaskLogDir()` older
182
+ than the same `improve.eventRetentionDays` window (default 90d, `0` disables
183
+ it) it already uses for `task_logs`/events/`improve_runs` — bounded to that
184
+ one directory, one level of `<taskId>` subdirectories, `.log` files only.
185
+ Path-agnostic bundled scripts and the `akm show env/<name>`/`akm task list`
186
+ items from the same review were previously confirmed shipped and are
187
+ unchanged here.
162
188
  - **`akm index --reembed` forces a full re-embed (#955).** Bypasses the
163
189
  compatibility check above entirely and purges + regenerates every stored
164
190
  embedding, for the rare case where the check's verdict should not be trusted. A
@@ -250,8 +276,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
250
276
  `embedding.contextLength` is Ollama's `num_ctx` only now — it used to also
251
277
  silently set the per-request token budget (`embedding.maxTokens`), so
252
278
  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.
279
+ The request budget is `embedding.maxTokens` (default 6000, see #954
280
+ below), so a request carries about 11 documents alongside the new
281
+ per-document cap by default.
255
282
  - **`akm index` reports where its embedding credential came from, before the
256
283
  first provider request (#953).** A field report suspected a gateway was
257
284
  receiving unauthenticated embedding requests despite `embedding.apiKey`
@@ -279,6 +306,19 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
279
306
  it differs from the running CLI, pointing at `akm task sync` as the
280
307
  remedy. `unknown` when not probed, no task is installed, or the recorded
281
308
  binary cannot be executed.
309
+ - **`akm improve <ref> --show-prompt` prints the composed reflect prompt for
310
+ one asset and exits (#952).** A beta.3 field round confirmed the #952 prompt
311
+ fix by reading source, but no live `akm improve` completed across three
312
+ attempts, leaving no cheap way to see the prompt in practice. `--show-prompt`
313
+ reuses every read-only step `akm improve`'s live reflect step already
314
+ performs (source resolution, runner selection, feedback/schema-hint/
315
+ related-lesson/rejected-proposal gathering) and stops before the dispatch
316
+ lease reflect would otherwise acquire — no lock, index write, or engine call,
317
+ same as `--dry-run`. Requires a fully-qualified asset ref as the scope; JSON/
318
+ yaml output carries the prompt as a `prompt` field, text output prints it
319
+ directly so the #952 framing (feedback shown as an unverified report, and
320
+ the instruction never to emit the truncation marker or out-of-asset content)
321
+ can be checked by eye.
282
322
 
283
323
  ### Changed
284
324
 
@@ -463,6 +503,32 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
463
503
 
464
504
  ### Fixed
465
505
 
506
+ - **An alias-level `engine` binding in `models.json` is honoured on every
507
+ platform column (#946).** The per-platform nested form
508
+ (`"fast": {"opencode": {"engine": "local-fast"}}`) already worked, but the
509
+ flat shorthand from this issue's own acceptance criteria
510
+ (`"fast": {"engine": "local-fast"}`) did not: `parseModelMapLayer` treated
511
+ every key under an alias as a platform-column name, so `engine` was parsed
512
+ as a fourth platform holding the model string, the three real columns kept
513
+ their hardcoded cloud defaults, and `akm agent --model fast` silently
514
+ dispatched to a cloud model instead of the configured local engine. The
515
+ flat `model`/`inference`/`engine` keys are now a wildcard default merged
516
+ onto every column, a same-alias per-platform entry still overrides it, and
517
+ an alias naming an unknown engine fails with a `ConfigError` rather than
518
+ falling back.
519
+ - **Two `improve` runs colliding on the lock is transient, not a broken
520
+ config (#948).** The lock-held path threw a `ConfigError`, surfacing as
521
+ exit 78 (`INVALID_CONFIG_FILE`) and telling a supervisor to stop retrying
522
+ an ordinary collision. It is now a `TransientError`/`IMPROVE_LOCK_HELD` at
523
+ exit 75, matching the `MAINTENANCE_BARRIER_BUSY`/`INDEX_DB_CONTENDED`
524
+ treatment the index rebuild lock already had.
525
+ - **The embedding path no longer leaks a raw `database is locked` (#956).**
526
+ `reclassifyIndexDbContention` moved out of `indexer.ts` into a shared
527
+ module and `materialize-embeddings.ts`'s embedding-generation catch now
528
+ routes through it, so contention produces the same "index database is
529
+ busy" wording as every other path instead of the raw driver string.
530
+ Control flow is unchanged: this was always non-fatal.
531
+
466
532
  - **Starting a workflow ref that already has an active run in a different scope
467
533
  now warns instead of silently duplicating it (#942).** `akm workflow run
468
534
  <ref>`'s per-scope concurrency guard is unchanged by design — two unrelated
@@ -610,6 +676,18 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
610
676
  precedent (#948) for state.db; the original driver text survives as
611
677
  `cause`. `--skip-if-locked` is unaffected — it already skips gracefully
612
678
  before ever attempting the write.
679
+ - **A concurrent plain `akm index` (no `--skip-if-locked`) could still exit
680
+ 78 instead of 75, a 2026-09-10 field re-test found (#956).** Two `akm
681
+ index` runs colliding on the short internal barrier that registers the
682
+ opt-in rebuild lock (shared with every other akm lock/lease) threw
683
+ `ConfigError("INVALID_CONFIG_FILE")` — a config-error exit that tells a
684
+ supervisor to stop retrying, when this is ordinary contention between two
685
+ legitimate runs. The barrier is meant to be held only milliseconds, so it
686
+ now retries briefly (a bounded, jittered backoff) before giving up, letting
687
+ an ordinary collision succeed instead of erroring at all; if it is still
688
+ busy after that, it raises `TransientError` with a dedicated
689
+ `MAINTENANCE_BARRIER_BUSY` code (exit 75) instead of the config error. The
690
+ rebuild lock itself is unaffected and still never blocks (#872).
613
691
  - **The fingerprint-rename canary embeds the exact text the stored vector was
614
692
  generated from (#955).** `sampleEmbeddedEntriesForCanary` handed the canary
615
693
  the entry's raw `search_text`, while the main embedding pass caps it to
@@ -621,6 +699,34 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
621
699
  purge and rebuild on a same-model rename. The canary now caps each sampled
622
700
  entry's search text the same way, through the same `capEmbeddingText`
623
701
  helper, before requesting its vector.
702
+ - **`akm improve --require-engines` now probes reachability instead of only
703
+ checking config/credentials, and a dead engine can no longer hang a run
704
+ past its timeout or a signal (#957).** Field, beta.3: engines pointed at a
705
+ dead endpoint, then `akm improve --require-engines` ran ~4 minutes with
706
+ zero output, ignoring an external `timeout 30` (SIGTERM) and akm's own
707
+ `--timeout-ms` — the documented exit-78 "required engines unavailable"
708
+ path could never be observed, because `--require-engines` only checked
709
+ that an engine was configured and credentialed, never whether it actually
710
+ answered. It now also runs the same bounded reachability probe `akm
711
+ health`'s `default-llm-engine`/`configured-engines` checks already use
712
+ (one `/models` request per distinct endpoint), before any lock, log, or
713
+ index side effect, and aborts at exit 78 naming the unreachable engine and
714
+ endpoint. Separately, a live run now prints one default-level line if it
715
+ has waited more than a few seconds on its first engine response, so a
716
+ scheduled run's log is never silently empty while an engine is slow or
717
+ dead — `--timeout-ms` and an engine's own configured timeout already
718
+ aborted the in-flight request correctly (confirmed by this investigation,
719
+ not changed), and SIGTERM/SIGINT already ended the process within its
720
+ documented grace period.
721
+ - **`akm improve --show-prompt` now includes `avoidPatterns` when a live
722
+ improve loop has set them (#952).** The preview built its own second copy
723
+ of reflect's prompt-source gathering and `ReflectPromptInput` assembly,
724
+ which had already drifted from the real dispatch path: it never read
725
+ `avoidPatterns` (recent-error context from earlier assets in the same
726
+ run), so the preview was not always the prompt a live iteration would
727
+ actually send. Both the preview and the real dispatch path (`akmReflect`,
728
+ `runReflectRefineIterations`) now gather sources and assemble the prompt
729
+ input through the same two shared helpers, so this cannot drift again.
624
730
 
625
731
  ## [0.9.14] - 2026-09-04
626
732
 
@@ -12,6 +12,7 @@ import { withEngineFallback } from "../../integrations/agent/engine-fallback.js"
12
12
  import { isLlmCredentialAvailable, resolveEngine, } from "../../integrations/agent/engine-resolution.js";
13
13
  import { executionEngineDefinitionsFromConfig } from "../../integrations/agent/execution-definitions.js";
14
14
  import { loadModelMap, mergeModelMapLayers, parseModelMapLayer, readInstalledModelMapText, resolveModelMapAlias, userModelMapPath, } from "../../integrations/agent/model-map.js";
15
+ import { probeEndpointOnce } from "../../llm/client.js";
15
16
  import { listKeys } from "../env/env.js";
16
17
  import { resolveImprovePlan } from "../improve/improve-strategies.js";
17
18
  import { ENGINE_LAST_USED_LOOKBACK_DAYS } from "./engine-usage.js";
@@ -20,13 +21,7 @@ import { ACTIVE_RUN_WARN_MS, TASK_FAIL_RATE_WARN, } from "./types.js";
20
21
  function probeConnectionReachable(connection, deps, cache) {
21
22
  if (!deps.probeReachable)
22
23
  return Promise.resolve(undefined);
23
- const key = connection.endpoint.replace(/\/+$/, "");
24
- let pending = cache.get(key);
25
- if (!pending) {
26
- pending = deps.probeReachable(connection);
27
- cache.set(key, pending);
28
- }
29
- return pending;
24
+ return probeEndpointOnce(connection, cache, deps.probeReachable);
30
25
  }
31
26
  function reachabilityEvidence(reach) {
32
27
  return reach
@@ -15,12 +15,16 @@ import { redactSensitiveText } from "../../core/redaction.js";
15
15
  import { clearLogFile, setLogFile, warn } from "../../core/warn.js";
16
16
  import { resolveWriteTarget } from "../../core/write-source.js";
17
17
  import { collectEngineCredentialValues } from "../../integrations/agent/engine-resolution.js";
18
- import { akmImprove } from "./improve.js";
18
+ import { probeEndpointOnce, probeLlmEndpoint } from "../../llm/client.js";
19
+ import { getOutputMode } from "../../output/context.js";
20
+ import { deliverRendered } from "../../output/html-render.js";
21
+ import { akmImprove, resolveImproveReadSource } from "./improve.js";
19
22
  import { runImproveReportQuery } from "./improve-report.js";
20
23
  import { buildImproveRunId, recordImproveRunResult, recordTerminatedImproveRun, } from "./improve-result-file.js";
21
24
  import { runImproveSession } from "./improve-session.js";
22
- import { resolveImprovePlan } from "./improve-strategies.js";
25
+ import { resolveImprovePlan, } from "./improve-strategies.js";
23
26
  import { formatUsageReportTable } from "./improve-usage-report.js";
27
+ import { renderReflectPromptPreview } from "./reflect.js";
24
28
  let akmImproveForRun = akmImprove;
25
29
  /** Swap the CLI's improve work implementation in deterministic subprocess tests. */
26
30
  export function _setAkmImproveForTests(fake) {
@@ -93,6 +97,84 @@ function assertRequiredEnginesAvailable(plan) {
93
97
  const lines = plan.engineUnavailable.map((item) => ` - ${item.process} (${item.configKey}): ${item.reason}`);
94
98
  throw new ConfigError(`--require-engines: ${plan.engineUnavailable.length} improve process${plan.engineUnavailable.length === 1 ? "" : "es"} cannot run because ${plan.engineUnavailable.length === 1 ? "its" : "their"} engine is unavailable:\n${lines.join("\n")}`, "LLM_NOT_CONFIGURED");
95
99
  }
100
+ /**
101
+ * Every distinct `kind: "llm"` connection the active strategy's plan would
102
+ * actually dispatch against — the main per-process runners plus triage's own
103
+ * judgment engine, which is resolved separately (#957).
104
+ */
105
+ function collectRequiredEngineTargets(plan) {
106
+ const targets = [];
107
+ for (const [processName, process] of Object.entries(plan.processes)) {
108
+ if (process.runner) {
109
+ targets.push({ process: processName, engine: process.runner.engine, connection: process.runner.connection });
110
+ }
111
+ }
112
+ if (plan.triageJudgment?.kind === "llm") {
113
+ targets.push({
114
+ process: "triage.judgment",
115
+ engine: plan.triageJudgment.engine,
116
+ connection: plan.triageJudgment.connection,
117
+ });
118
+ }
119
+ return targets;
120
+ }
121
+ /**
122
+ * `--require-engines` field re-test (#957): the static check above only
123
+ * proves an engine is configured and credentialed — it cannot see a dead
124
+ * endpoint. A field run against an unreachable engine sat silent for
125
+ * minutes instead of hitting the documented exit-78 path. Reuse the SAME
126
+ * bounded reachability probe `akm health`'s `default-llm-engine` /
127
+ * `configured-engines` checks already run (`probeLlmEndpoint`, a single
128
+ * `/models` GET bounded by its own default timeout) once per distinct
129
+ * endpoint (via the shared `probeEndpointOnce` memoization health/checks.ts
130
+ * also uses), so a dead engine is caught here instead of during dispatch.
131
+ */
132
+ async function assertRequiredEnginesReachable(plan, probeReachable = probeLlmEndpoint) {
133
+ const targets = collectRequiredEngineTargets(plan);
134
+ if (targets.length === 0)
135
+ return;
136
+ const probesByEndpoint = new Map();
137
+ const probed = await Promise.all(targets.map(async (target) => ({
138
+ ...target,
139
+ reach: await probeEndpointOnce(target.connection, probesByEndpoint, probeReachable),
140
+ })));
141
+ const unreachable = probed.filter((item) => !item.reach.reachable);
142
+ if (unreachable.length === 0)
143
+ return;
144
+ const lines = unreachable.map((item) => ` - ${item.process} (engine "${item.engine}", ${item.connection.endpoint}): ${item.reach.error ?? "did not respond"}`);
145
+ throw new ConfigError(`--require-engines: ${unreachable.length} improve process${unreachable.length === 1 ? "" : "es"} cannot run because ${unreachable.length === 1 ? "its" : "their"} engine endpoint is not reachable:\n${lines.join("\n")}`, "LLM_NOT_CONFIGURED");
146
+ }
147
+ /**
148
+ * `--show-prompt` (#952): render the composed reflect prompt for one asset ref
149
+ * and exit, before any lock, log, index write, or engine dispatch — the field
150
+ * had no cheap way to confirm the #952 prompt fix (unverified-feedback framing,
151
+ * no-truncation-marker instruction) without running a full improve cycle.
152
+ * Reuses `renderReflectPromptPreview` (reflect.ts), which stops before the
153
+ * dispatch lease reflect would otherwise acquire, so this never calls an engine.
154
+ */
155
+ async function runShowPromptCli(refArg, parsedRef, taskArg, targetArg, resolvedPlan) {
156
+ const readSource = resolveImproveReadSource(resolvedPlan.config, parsedRef, targetArg);
157
+ const preview = await renderReflectPromptPreview({
158
+ ref: refArg,
159
+ ...(taskArg ? { task: taskArg } : {}),
160
+ improveProfile: resolvedPlan.strategy.config,
161
+ config: resolvedPlan.config,
162
+ stashDir: readSource.source.path,
163
+ });
164
+ const outputMode = getOutputMode();
165
+ if (outputMode.format === "text") {
166
+ deliverRendered(preview.prompt, outputMode.outputPath);
167
+ return;
168
+ }
169
+ output("improve", {
170
+ schemaVersion: 2,
171
+ ok: true,
172
+ ref: preview.ref,
173
+ engine: preview.engine,
174
+ engineKind: preview.engineKind,
175
+ prompt: preview.prompt,
176
+ });
177
+ }
96
178
  /**
97
179
  * `akm improve report` (#944): a scope value that dispatches to the per-run
98
180
  * LLM usage/routing report instead of a real improve run — "report" is not,
@@ -172,7 +254,12 @@ export const improveCommand = defineCommand({
172
254
  },
173
255
  "require-engines": {
174
256
  type: "boolean",
175
- description: "Abort before any indexing, lock, or log side effect (exit 78) if the active strategy would enable a process whose engine or credential cannot be resolved in this process's environment. Without this flag, improve degrades gracefully instead: it skips the affected processes and reports them in the result's skippedProcesses. Recommended alongside --skip-if-locked for scheduled runs.",
257
+ description: "Abort before any indexing, lock, or log side effect (exit 78) if the active strategy would enable a process whose engine or credential cannot be resolved in this process's environment, OR whose endpoint fails a bounded reachability probe (the same probe akm health runs). Without this flag, improve degrades gracefully instead: it skips the affected processes and reports them in the result's skippedProcesses. Recommended alongside --skip-if-locked for scheduled runs.",
258
+ default: false,
259
+ },
260
+ "show-prompt": {
261
+ type: "boolean",
262
+ description: "Print the composed reflect prompt for one asset ref and exit — no lock, index write, or engine dispatch (#952). Requires a fully-qualified asset ref as the scope positional (e.g. `akm improve lessons/my-lesson --show-prompt`). JSON/yaml format carries the prompt as a `prompt` field; text format prints it directly.",
176
263
  default: false,
177
264
  },
178
265
  run: {
@@ -216,8 +303,10 @@ export const improveCommand = defineCommand({
216
303
  const targetArg = getStringArg(args, "bundle");
217
304
  const taskArg = getStringArg(args, "task");
218
305
  // #947 — `--plan` is a zero-logic discoverability alias for `--dry-run`;
219
- // it must never fork the computation, only set the same flag.
220
- const dryRun = args["dry-run"] || args.plan;
306
+ // it must never fork the computation, only set the same flag. #952 —
307
+ // `--show-prompt` implies the same read-only posture (it never reaches
308
+ // akmImprove at all, but keeps writeTarget/resolvedPlan unset the same way).
309
+ const dryRun = args["dry-run"] || args.plan || args["show-prompt"];
221
310
  const limitRaw = parsePositiveIntFlag(args.limit ?? undefined);
222
311
  const timeoutMs = parsePositiveIntFlag(args["timeout-ms"], "--timeout-ms");
223
312
  const requireFeedbackSignal = args["require-feedback-signal"];
@@ -239,8 +328,20 @@ export const improveCommand = defineCommand({
239
328
  // is disabled purely by an unreachable credential; a live run keeps
240
329
  // throwing (allowAllDisabled unset).
241
330
  const resolvedPlan = resolveImprovePlan(strategyArg, effectiveConfig, { allowAllDisabled: Boolean(dryRun) });
242
- if (args["require-engines"])
331
+ // #952 — same interception point as the `report` scope above: before any
332
+ // lock, log, or index side effect. Requires a single fully-qualified
333
+ // asset ref (not a type or whole-bundle scope).
334
+ if (args["show-prompt"]) {
335
+ if (!scopeArg || !scopeRef) {
336
+ throw new UsageError("`--show-prompt` requires a fully-qualified asset ref as the scope (e.g. `akm improve lessons/my-lesson --show-prompt`).", "INVALID_FLAG_VALUE");
337
+ }
338
+ await runShowPromptCli(scopeArg, scopeRef, taskArg, targetArg, resolvedPlan);
339
+ return;
340
+ }
341
+ if (args["require-engines"]) {
243
342
  assertRequiredEnginesAvailable(resolvedPlan);
343
+ await assertRequiredEnginesReachable(resolvedPlan);
344
+ }
244
345
  const selectedStrategyName = resolvedPlan.strategy.name;
245
346
  const sensitiveValues = collectEngineCredentialValues(effectiveConfig);
246
347
  // Only set the keys the user actually passed (citty leaves the flag
@@ -65,6 +65,19 @@ export function renderSyncCommitMessage(template, result, nowMs) {
65
65
  };
66
66
  return template.replace(/\{(\w+)\}/g, (match, key) => tokens[key] ?? match);
67
67
  }
68
+ /**
69
+ * How long the improve loop waits for its FIRST engine response (success or
70
+ * error — any terminal record proves the run is not silent) before printing
71
+ * one default-level line. The timer is armed once the triage/index prepass
72
+ * finishes and the loop is about to start dispatching engine requests — not
73
+ * at run start — so it measures engine latency, not prepass time. Field
74
+ * re-test (#957): an engine pointed at a dead endpoint produced zero output
75
+ * for minutes, so a genuine hang looked identical to a normal-but-slow run.
76
+ * A few seconds is short enough that an operator watching a scheduled run's
77
+ * live log sees something promptly, long enough that an ordinary fast
78
+ * response never prints it.
79
+ */
80
+ export const FIRST_ENGINE_RESPONSE_HEARTBEAT_MS = 5_000;
68
81
  export function armBudgetWatchdog(budgetMs, controller, deps) {
69
82
  const setTimeoutFn = deps?.setTimeoutFn ?? setTimeout;
70
83
  const clearTimeoutFn = deps?.clearTimeoutFn ?? clearTimeout;
@@ -119,6 +132,12 @@ export async function akmImprove(options = {}) {
119
132
  options = setup.options;
120
133
  const { budgetMs, budgetAbortController, scope, selectedStrategy, syncRepoDir, resolvedStateDbPath, resolvedLockPath, } = setup;
121
134
  let clearBudgetTimer = () => { };
135
+ let clearFirstResponseHeartbeat = () => { };
136
+ // #957: set by the usage sink's onRecord callback the moment any engine
137
+ // call terminates (success or error), including one issued by the prepass
138
+ // itself — makes arming the heartbeat below a no-op when the run is
139
+ // already known not to be silent.
140
+ let firstEngineResponseSeen = false;
122
141
  let initialGitPaths = new Set();
123
142
  const runJournal = createRunWriteJournal();
124
143
  const preEnsureCleanupWarnings = [];
@@ -175,7 +194,10 @@ export async function akmImprove(options = {}) {
175
194
  return buildLockSkippedResult(selectedStrategy.name, scope, options.runId);
176
195
  }
177
196
  improveLockOwnership = acquisition.ownership;
178
- disposeLlmUsageSink = installLlmUsagePersistence(() => eventsCtx);
197
+ disposeLlmUsageSink = installLlmUsagePersistence(() => eventsCtx, () => {
198
+ firstEngineResponseSeen = true;
199
+ clearFirstResponseHeartbeat();
200
+ });
179
201
  exitBackstop = releaseRunLock;
180
202
  process.on("exit", exitBackstop);
181
203
  initialGitPaths =
@@ -215,6 +237,7 @@ export async function akmImprove(options = {}) {
215
237
  // If the live prepass fails, emit its summary and clear the owning sink
216
238
  // before any run teardown. The disposer is idempotent with the main finalizer.
217
239
  disposeLlmUsageSink();
240
+ clearFirstResponseHeartbeat();
218
241
  clearBudgetTimer();
219
242
  if (exitBackstop) {
220
243
  process.removeListener("exit", exitBackstop);
@@ -233,6 +256,20 @@ export async function akmImprove(options = {}) {
233
256
  // are all in hand. See buildImproveRunContext for exactly which
234
257
  // already-resolved values back each field.
235
258
  const ctx = buildImproveRunContext(setup, eventsCtx);
259
+ // #957: arm the heartbeat here, immediately before the improve loop
260
+ // starts dispatching engine requests — not at run start, where its timer
261
+ // would measure the triage/index prepass instead of engine latency. A
262
+ // no-op when the prepass already produced a terminal LLM record (the
263
+ // onRecord callback above already saw it). Cleared the moment any call
264
+ // terminates (success or error) — never rearmed, so this prints at most
265
+ // once per run.
266
+ if (!firstEngineResponseSeen) {
267
+ const firstResponseTimer = setTimeout(() => {
268
+ warn("[improve] Still waiting for the first engine response...");
269
+ }, FIRST_ENGINE_RESPONSE_HEARTBEAT_MS);
270
+ firstResponseTimer.unref?.();
271
+ clearFirstResponseHeartbeat = () => clearTimeout(firstResponseTimer);
272
+ }
236
273
  const seq = await runImproveStageSequence({
237
274
  run: setup,
238
275
  strategyFilteredRefs,
@@ -292,6 +329,8 @@ export async function akmImprove(options = {}) {
292
329
  // #576: clear the per-run LLM usage sink BEFORE closing `eventsDb` below, so
293
330
  // no late sink invocation can write through a closed handle.
294
331
  disposeLlmUsageSink();
332
+ // #957: never leave the first-response heartbeat timer pending past the run.
333
+ clearFirstResponseHeartbeat();
295
334
  // O-1 (#364): Clear the budget abort timer so it does not keep the event
296
335
  // loop alive after the run completes.
297
336
  clearBudgetTimer();
@@ -345,8 +384,13 @@ function describeRunWrittenPaths(setup, writtenPaths) {
345
384
  }
346
385
  return [...described].sort();
347
386
  }
348
- /** Resolve a dry-run inspection source without adapting it into a write target. */
349
- function resolveImproveReadSource(config, scopedRef, explicitTarget, fallbackStashDir) {
387
+ /**
388
+ * Resolve a dry-run inspection source without adapting it into a write target.
389
+ * Exported so `improve-cli.ts`'s `--show-prompt` (#952) can resolve the same
390
+ * read-only bundle a plain `--dry-run` would, without duplicating this
391
+ * selector/target/fallback precedence.
392
+ */
393
+ export function resolveImproveReadSource(config, scopedRef, explicitTarget, fallbackStashDir) {
350
394
  if (scopedRef?.origin && explicitTarget && scopedRef.origin !== explicitTarget) {
351
395
  throw new UsageError(`Qualified ref bundle "${scopedRef.origin}" conflicts with --target "${explicitTarget}".`, "INVALID_FLAG_VALUE", `Drop --target or use --target ${scopedRef.origin}.`);
352
396
  }
@@ -2,7 +2,7 @@
2
2
  // License, v. 2.0. If a copy of the MPL was not distributed with this
3
3
  // file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
4
  import path from "node:path";
5
- import { ConfigError } from "../../core/errors.js";
5
+ import { TransientError } from "../../core/errors.js";
6
6
  import { appendEvent } from "../../core/events.js";
7
7
  import { releaseLock } from "../../core/file-lock.js";
8
8
  import { tryWithMaintenanceStartBarrier, withMaintenanceStartBarrier } from "../../core/maintenance-barrier.js";
@@ -65,7 +65,14 @@ function tryAcquireImproveLockUnlocked(lockPath, skipIfLocked, onRecovered) {
65
65
  warn(`[improve] another improve run holds the lock (PID ${pid}, started ${startedAt}); skipping (--skip-if-locked)`);
66
66
  return { state: "skipped" };
67
67
  }
68
- throw new ConfigError(`akm improve is already running (PID ${pid}, started ${startedAt}). Delete ${lockPath} to force.`, "INVALID_CONFIG_FILE");
68
+ // #948 field follow-up: two legitimate `improve` invocations colliding on
69
+ // this lock is ordinary, retryable contention — not a broken config file.
70
+ // A `ConfigError` here surfaced as exit 78 (INVALID_CONFIG_FILE) and told
71
+ // a supervisor to stop retrying a normal lock collision. Reclassified to
72
+ // `TransientError`/`IMPROVE_LOCK_HELD` (exit 75), mirroring the
73
+ // `MAINTENANCE_BARRIER_BUSY`/`INDEX_DB_CONTENDED` treatment #956 already
74
+ // applied to the index rebuild lock and the maintenance-start barrier.
75
+ throw new TransientError(`akm improve is already running (PID ${pid}, started ${startedAt}). Delete ${lockPath} to force.`, "IMPROVE_LOCK_HELD");
69
76
  }
70
77
  export function releaseImproveLock(ownership) {
71
78
  releaseLock(ownership);
@@ -9,7 +9,7 @@ import { DEFAULT_GRAPH_EXTRACTION_BATCH_SIZE, loadConfig } from "../../core/conf
9
9
  import { UsageError } from "../../core/errors.js";
10
10
  import { appendEvent } from "../../core/events.js";
11
11
  import { openLogsDatabase, purgeOldTaskLogs } from "../../core/logs-db.js";
12
- import { getDbPath } from "../../core/paths.js";
12
+ import { getDbPath, getTaskLogDir } from "../../core/paths.js";
13
13
  import { withStateDb } from "../../core/state-db.js";
14
14
  import { info } from "../../core/warn.js";
15
15
  import { DEFAULT_GRAPH_EXTRACTION_INCLUDE_TYPES, runGraphExtractionPass, } from "../../indexer/graph/graph-extraction.js";
@@ -25,6 +25,7 @@ import { closeDatabase, openIndexDatabase } from "../../storage/repositories/ind
25
25
  import { getEntryByRef } from "../../storage/repositories/index-entries-repository.js";
26
26
  import { clearAssetOutcomeMissing, countAssetOutcomeMissing, deleteAssetOutcomeMissingBefore, listAssetOutcomeMissingState, stampAssetOutcomeMissing, } from "../../storage/repositories/outcome-repository.js";
27
27
  import { clearAssetSalienceMissing, countAssetSalienceMissing, deleteAssetSalienceMissingBefore, listAssetSalienceMissingState, stampAssetSalienceMissing, } from "../../storage/repositories/salience-repository.js";
28
+ import { purgeOldTaskLogFiles } from "../../tasks/run/task-log.js";
28
29
  import { expireStaleProposals, listProposals, purgeOrphanProposals } from "../proposal/repository.js";
29
30
  import { checkDeadUrls } from "../url-checker.js";
30
31
  import { DEFAULT_RETENTION_DAYS as CYCLE_METRICS_RETENTION_DAYS, runCollapseDetector } from "./collapse-detector.js";
@@ -1124,6 +1125,25 @@ export function runRetentionPurgePass(ctx) {
1124
1125
  }
1125
1126
  }
1126
1127
  }
1128
+ // Per-run flat log files under getTaskLogDir() (#951): logs.db above is
1129
+ // the durable record and already retention-purged, so the transitional
1130
+ // `<taskId>/<timestamp>.log` tail files can be deleted on the same
1131
+ // window without losing anything. A separate try/catch — a filesystem
1132
+ // problem here must not block the DB purges above.
1133
+ try {
1134
+ const taskLogFilesPurged = purgeOldTaskLogFiles(undefined, retentionDays);
1135
+ if (taskLogFilesPurged > 0) {
1136
+ info(`[improve] task log files purge: ${taskLogFilesPurged} file(s) older than ${retentionDays}d removed from ${getTaskLogDir()}`);
1137
+ }
1138
+ appendEvent({
1139
+ eventType: "task_log_files_purged",
1140
+ ref: "task_log_files/_purge",
1141
+ metadata: { purgedCount: taskLogFilesPurged, retentionDays },
1142
+ }, eventsCtx);
1143
+ }
1144
+ catch (err) {
1145
+ warnings.push(`task log files purge failed: ${errMessage(err)}`);
1146
+ }
1127
1147
  }
1128
1148
  return { warnings };
1129
1149
  }