akm-cli 0.9.0-beta.1 → 0.9.0-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.
Files changed (41) hide show
  1. package/CHANGELOG.md +128 -0
  2. package/dist/assets/templates/html/default.html +78 -0
  3. package/dist/assets/templates/html/health.html +560 -0
  4. package/dist/assets/templates/html/vendor/echarts.min.js +45 -0
  5. package/dist/cli/shared.js +21 -5
  6. package/dist/cli.js +36 -5
  7. package/dist/commands/config-cli.js +0 -10
  8. package/dist/commands/health/html-report.js +448 -0
  9. package/dist/commands/health.js +97 -6
  10. package/dist/commands/improve/extract.js +38 -2
  11. package/dist/commands/improve/improve-auto-accept.js +27 -1
  12. package/dist/commands/improve/improve-cli.js +7 -0
  13. package/dist/commands/improve/improve.js +201 -66
  14. package/dist/commands/improve/reflect-noise.js +0 -0
  15. package/dist/commands/improve/reflect.js +25 -0
  16. package/dist/commands/proposal/drain.js +73 -6
  17. package/dist/commands/proposal/proposal-cli.js +22 -10
  18. package/dist/commands/proposal/proposal.js +12 -1
  19. package/dist/commands/proposal/validators/proposals.js +361 -338
  20. package/dist/commands/remember.js +6 -2
  21. package/dist/commands/tasks/tasks.js +32 -8
  22. package/dist/core/config/config-schema.js +5 -0
  23. package/dist/core/logs-db.js +304 -0
  24. package/dist/core/state-db.js +107 -14
  25. package/dist/indexer/db/db.js +2 -2
  26. package/dist/indexer/passes/memory-inference.js +61 -22
  27. package/dist/integrations/harnesses/claude/session-log.js +16 -4
  28. package/dist/llm/client.js +15 -0
  29. package/dist/llm/usage-persist.js +77 -0
  30. package/dist/llm/usage-telemetry.js +103 -0
  31. package/dist/output/context.js +3 -2
  32. package/dist/output/html-render.js +73 -0
  33. package/dist/output/shapes/helpers.js +17 -1
  34. package/dist/output/text/helpers.js +69 -1
  35. package/dist/scripts/migrate-storage.js +65 -14
  36. package/dist/scripts/migrations/import-fs-improve-runs-to-db.js +14 -2
  37. package/dist/tasks/backends/cron.js +46 -9
  38. package/dist/tasks/runner.js +99 -16
  39. package/dist/workflows/db.js +4 -0
  40. package/package.json +1 -1
  41. package/dist/commands/config-edit.js +0 -344
@@ -12,6 +12,7 @@ import { ConfigError, NotFoundError, rethrowIfTestIsolationError, UsageError } f
12
12
  import { appendEvent, readEvents } from "../../core/events.js";
13
13
  import { probeLock, releaseLock, releaseLockIfOwned, tryAcquireLockSync } from "../../core/file-lock.js";
14
14
  import { classifyImproveAction } from "../../core/improve-types.js";
15
+ import { openLogsDatabase, purgeOldTaskLogs } from "../../core/logs-db.js";
15
16
  import { getDbPath, getStateDbPathInDataDir } from "../../core/paths.js";
16
17
  import { openStateDatabase, purgeOldEvents, purgeOldImproveRuns } from "../../core/state-db.js";
17
18
  import { info, warn } from "../../core/warn.js";
@@ -27,6 +28,8 @@ import { resolveAssetPath } from "../../indexer/walk/path-resolver.js";
27
28
  import { resolveImproveProcessRunnerFromProfile, resolveTriageJudgmentRunner } from "../../integrations/agent/runner.js";
28
29
  import { getAvailableHarnesses } from "../../integrations/session-logs/index.js";
29
30
  import { isLlmFeatureEnabled, isProcessEnabled } from "../../llm/feature-gate.js";
31
+ import { installLlmUsagePersistence } from "../../llm/usage-persist.js";
32
+ import { withLlmStage } from "../../llm/usage-telemetry.js";
30
33
  import { isGitBackedStash, resolveWritableOverride, saveGitStash } from "../../sources/providers/git.js";
31
34
  import { akmLint } from "../lint/index.js";
32
35
  import { drainProposals } from "../proposal/drain.js";
@@ -112,7 +115,7 @@ async function collectEligibleRefs(scope, stashDir, improveProfile) {
112
115
  };
113
116
  }
114
117
  return {
115
- plannedRefs: [{ ref: scope.value, reason: "scope-ref" }],
118
+ plannedRefs: [{ ref: scope.value, reason: "scope-ref", filePath }],
116
119
  memorySummary: {
117
120
  eligible: parsed.type === "memory" ? 1 : 0,
118
121
  derived: parsed.type === "memory" && parsed.name.endsWith(".derived") ? 1 : 0,
@@ -176,12 +179,14 @@ async function collectEligibleRefs(scope, stashDir, improveProfile) {
176
179
  profileFiltered.set(ref, {
177
180
  ref,
178
181
  reason: "profile_filtered_all_passes",
182
+ filePath: indexed.filePath,
179
183
  });
180
184
  }
181
185
  else {
182
186
  planned.set(ref, {
183
187
  ref,
184
188
  reason: scope.mode === "type" ? "scope-type" : indexed.entry.type === "memory" ? "memory-cleanup" : "scope-type",
189
+ filePath: indexed.filePath,
185
190
  });
186
191
  }
187
192
  }
@@ -498,7 +503,7 @@ export async function akmImprove(options = {}) {
498
503
  fs.mkdirSync(path.dirname(resolvedLockPath), { recursive: true });
499
504
  const lockPayload = () => JSON.stringify({ pid: process.pid, startedAt: new Date().toISOString() });
500
505
  if (tryAcquireLockSync(resolvedLockPath, lockPayload()))
501
- return;
506
+ return "acquired";
502
507
  // Lock file already exists — probe to determine whether it's still held
503
508
  // or whether the prior run died without cleaning up.
504
509
  const probe = probeLock(resolvedLockPath, { staleAfterMs: MAX_LOCK_AGE_MS });
@@ -533,9 +538,19 @@ export async function akmImprove(options = {}) {
533
538
  }
534
539
  releaseLock(resolvedLockPath);
535
540
  if (tryAcquireLockSync(resolvedLockPath, lockPayload()))
536
- return;
541
+ return "acquired";
542
+ // Lost the race to another run that grabbed the freed stale lock.
543
+ if (options.skipIfLocked) {
544
+ warn("[improve] another run acquired the lock during stale recovery; skipping (--skip-if-locked)");
545
+ return "skipped";
546
+ }
537
547
  throw new ConfigError(`akm improve is already running. Delete ${resolvedLockPath} to force.`, "INVALID_CONFIG_FILE");
538
548
  }
549
+ // Lock is held by a live run within the staleness window.
550
+ if (options.skipIfLocked) {
551
+ warn(`[improve] another improve run holds the lock (PID ${lock?.pid}, started ${lock?.startedAt}); skipping (--skip-if-locked)`);
552
+ return "skipped";
553
+ }
539
554
  throw new ConfigError(`akm improve is already running (PID ${lock?.pid}, started ${lock?.startedAt}). Delete ${resolvedLockPath} to force.`, "INVALID_CONFIG_FILE");
540
555
  };
541
556
  // Phase 4 lock-leak guard (§7 ordering hazard): hoisting `improve.lock` above
@@ -583,7 +598,21 @@ export async function akmImprove(options = {}) {
583
598
  // The dry-run branch below produces plannedRefs/memorySummary WITHOUT the lock
584
599
  // or triage (decision: dry-run never mutates the queue).
585
600
  if (!options.dryRun) {
586
- acquireLock();
601
+ if (acquireLock() === "skipped") {
602
+ // Another improve holds the lock and the caller asked to skip rather
603
+ // than fail. Return a clean no-op result (exit 0) before any index/DB
604
+ // work — never registered the exit listener, never set lockAcquired,
605
+ // so we release nothing belonging to the run that owns the lock.
606
+ return {
607
+ schemaVersion: 1,
608
+ ok: true,
609
+ scope,
610
+ dryRun: false,
611
+ skipped: { reason: "lock-held" },
612
+ memorySummary: { eligible: 0, derived: 0 },
613
+ plannedRefs: [],
614
+ };
615
+ }
587
616
  lockAcquired = true;
588
617
  // Backstop release on process.exit() (signal handler / budget watchdog),
589
618
  // which skips the finally below. Removed in that finally on the normal path.
@@ -693,7 +722,7 @@ export async function akmImprove(options = {}) {
693
722
  if (primaryStashDir && shouldAnalyzeMemoryCleanup(scope, memorySummary.eligible, primaryStashDir)) {
694
723
  try {
695
724
  // Reuse the config resolved at the top of the run instead of a second load.
696
- await detectAndWriteContradictions(primaryStashDir, _earlyConfig);
725
+ await withLlmStage("memory-contradiction", () => detectAndWriteContradictions(primaryStashDir, _earlyConfig));
697
726
  }
698
727
  catch (err) {
699
728
  // Non-fatal: contradiction detection is a best-effort pass.
@@ -753,6 +782,9 @@ export async function akmImprove(options = {}) {
753
782
  // Pinned to the boundary snapshot so the fallback per-call `appendEvent`
754
783
  // opens (when the long-lived handle below fails to open) never re-read env.
755
784
  let eventsCtx = { dbPath: resolvedStateDbPath };
785
+ // #576: clears the per-run LLM usage sink. Defaults to a no-op until the sink
786
+ // is installed inside the try; the `finally` always calls it.
787
+ let disposeLlmUsageSink = () => { };
756
788
  try {
757
789
  // H7 (#566): arm the budget watchdog. `armBudgetWatchdog` captures both the
758
790
  // budget timer and the hard-kill timer it schedules on exhaustion, returning
@@ -772,17 +804,26 @@ export async function akmImprove(options = {}) {
772
804
  // still pinned to the boundary-resolved path, never a live env re-read.
773
805
  eventsCtx = { dbPath: resolvedStateDbPath };
774
806
  }
775
- // 2026-05-27: emit `improve_skipped` audit events for refs the planner
807
+ // #576: persist per-call LLM usage telemetry for this run as `llm_usage`
808
+ // events, reusing the same boundary-pinned events context (and long-lived
809
+ // handle when available). Disposed in `finally` so the sink never leaks
810
+ // across runs. Wrapping is best-effort end to end — see usage-telemetry.ts.
811
+ disposeLlmUsageSink = installLlmUsagePersistence(eventsCtx);
812
+ // 2026-05-27: emit an `improve_skipped` audit event for refs the planner
776
813
  // pre-filtered (reflect AND distill both refuse them under the active
777
- // profile). One event per ref so the existing improve_skipped histogram in
778
- // `health.ts#improveSummary.skipReasons` accumulates the right count under
779
- // the new `profile_filtered_all_passes` reason code. See
780
- // `/tmp/akm-health-investigations/planner-profile-metrics-deep-analysis.md`.
781
- for (const filtered of profileFilteredRefs) {
814
+ // profile). Emitted as a single summary event (count only) rather than one
815
+ // event per ref (#592) the per-ref loop caused O(n) sequential state.db
816
+ // writes that consumed ~500 s on a 9 000-ref stash. No downstream consumer
817
+ // needs the per-ref audit trail: health's skip histogram reads the
818
+ // `profile_filtered_all_passes` counters from `improve_completed` metadata.
819
+ if (profileFilteredRefs.length > 0) {
782
820
  appendEvent({
783
821
  eventType: "improve_skipped",
784
- ref: filtered.ref,
785
- metadata: { reason: "profile_filtered_all_passes" },
822
+ ref: undefined,
823
+ metadata: {
824
+ reason: "profile_filtered_all_passes",
825
+ count: profileFilteredRefs.length,
826
+ },
786
827
  }, eventsCtx);
787
828
  }
788
829
  const preparation = await runImprovePreparationStage({
@@ -1007,6 +1048,9 @@ export async function akmImprove(options = {}) {
1007
1048
  throw err;
1008
1049
  }
1009
1050
  finally {
1051
+ // #576: clear the per-run LLM usage sink BEFORE closing `eventsDb` below, so
1052
+ // no late sink invocation can write through a closed handle.
1053
+ disposeLlmUsageSink();
1010
1054
  // O-1 (#364): Clear the budget abort timer so it does not keep the event
1011
1055
  // loop alive after the run completes.
1012
1056
  clearBudgetTimer();
@@ -1333,7 +1377,7 @@ async function runConsolidationPass(args) {
1333
1377
  info(`[improve] consolidation skipped (pool ${eligiblePoolSize} < minPoolSize ${minPoolSize})`);
1334
1378
  }
1335
1379
  else if (!consolidationOnCooldown) {
1336
- consolidation = await akmConsolidate({
1380
+ consolidation = await withLlmStage("consolidate", () => akmConsolidate({
1337
1381
  ...options.consolidateOptions,
1338
1382
  config: consolidationConfig,
1339
1383
  stashDir: options.stashDir,
@@ -1341,16 +1385,13 @@ async function runConsolidationPass(args) {
1341
1385
  // Tie consolidate proposals back to this improve invocation so
1342
1386
  // accept-rate-per-run aggregation works. Mirrors reflect/propose/extract.
1343
1387
  sourceRun: `consolidate-${Date.now()}`,
1344
- // Incremental consolidation: pass the last-consolidation timestamp so
1345
- // akmConsolidate skips chunks with no memory changed since then. Converts
1346
- // consolidation cost from O(pool) to O(changed clusters) — the fix for
1347
- // the rising p95 tail where full-pool re-judging produced 5–10 min runs
1348
- // that promoted ~0. undefined full pass on first-ever run (bootstrap).
1349
- // volumeTriggered correctly forces the run past cooldown but must NOT
1350
- // override incrementalSince the stash has ~1400 eligible memories so
1351
- // volumeTriggered=true on every run, permanently forcing full 12-chunk
1352
- // scans (~264s) instead of the intended 1-2 chunk incremental path (~44s).
1353
- incrementalSince: lastConsolidateTs,
1388
+ // Full-pool sweep: consolidation only runs on the nightly default-profile
1389
+ // pass (quick/frequent disable it), so a complete re-cluster is correct and
1390
+ // affordable here. Do NOT pass incrementalSince — the time-window narrowing
1391
+ // it triggers permanently excludes stale-but-unmerged duplicate clusters,
1392
+ // starving merge recall and letting the pool grow unbounded. (The narrowing
1393
+ // was a band-aid for an every-30-min consolidation cadence that the profile
1394
+ // split has since eliminated.) lastConsolidateTs still gates whether we run.
1354
1395
  maxChunkSize: improveProfile?.processes?.consolidate?.maxChunkSize,
1355
1396
  // Honor profile.autoAccept (already merged into options.autoAccept at the
1356
1397
  // top of akmImprove). The CLI parser always supplies 90 when --auto-accept
@@ -1359,7 +1400,7 @@ async function runConsolidationPass(args) {
1359
1400
  // options.consolidateOptions.autoAccept (if explicitly provided by caller)
1360
1401
  // still wins because the spread above runs first.
1361
1402
  autoAccept: options.consolidateOptions?.autoAccept ?? options.autoAccept,
1362
- });
1403
+ }));
1363
1404
  {
1364
1405
  const consolidateGr = await runAutoAcceptGate(consolidation.promoted.map((proposalId) => {
1365
1406
  try {
@@ -1446,7 +1487,9 @@ async function runImprovePreparationStage(args) {
1446
1487
  // / `akm feedback` invocations. Replaces the akm-plugin session-checkpoint
1447
1488
  // hook with an on-demand pull pipeline.
1448
1489
  //
1449
- // Default-on; opt out via `profiles.improve.default.processes.extract.enabled: false`.
1490
+ // Default-on; opt out via the ACTIVE profile's `processes.extract.enabled: false`
1491
+ // (#593: the gate respects the resolved improve profile, not just the
1492
+ // hardcoded `default` profile path the legacy feature flag reads).
1450
1493
  // Each available harness gets one call with the default --since window;
1451
1494
  // already-seen sessions (tracked in state.db.extract_sessions_seen) are
1452
1495
  // skipped automatically so re-runs don't burn LLM calls on unchanged data.
@@ -1480,7 +1523,13 @@ async function runImprovePreparationStage(args) {
1480
1523
  const EXTRACT_DEFAULT_MIN_NEW_SESSIONS = 0;
1481
1524
  const configuredMinNewSessions = extractConfig.profiles?.improve?.default?.processes?.extract?.minNewSessions;
1482
1525
  const minNewSessions = typeof configuredMinNewSessions === "number" ? configuredMinNewSessions : EXTRACT_DEFAULT_MIN_NEW_SESSIONS;
1483
- if (isLlmFeatureEnabled(extractConfig, "session_extraction")) {
1526
+ // #593: gate on BOTH the legacy feature flag (which only reads
1527
+ // `profiles.improve.default.processes.extract.enabled` — kept for back-compat
1528
+ // with users who disable extract via the default-profile path) AND the active
1529
+ // resolved profile. Without the second check a non-default profile setting
1530
+ // `extract.enabled: false` (e.g. the built-in `quick`) was silently ignored
1531
+ // and extract ran on every improve call regardless.
1532
+ if (isLlmFeatureEnabled(extractConfig, "session_extraction") && resolveProcessEnabled("extract", improveProfile)) {
1484
1533
  const availableHarnesses = options.extractHarnesses ?? getAvailableHarnesses();
1485
1534
  // The guard engages only when minNewSessions > 0; 0 disables it entirely.
1486
1535
  let belowMinNewSessions = false;
@@ -1511,7 +1560,7 @@ async function runImprovePreparationStage(args) {
1511
1560
  extractResults = [];
1512
1561
  for (const h of availableHarnesses) {
1513
1562
  try {
1514
- const result = await akmExtract({
1563
+ const result = await withLlmStage("session-extraction", () => akmExtract({
1515
1564
  type: h.name,
1516
1565
  ...(primaryStashDir !== undefined ? { stashDir: primaryStashDir } : {}),
1517
1566
  config: extractConfig,
@@ -1519,7 +1568,7 @@ async function runImprovePreparationStage(args) {
1519
1568
  ...(options.extractHarnesses ? { harnesses: options.extractHarnesses } : {}),
1520
1569
  // C2: pin extract's skip-tracking state.db open to the boundary path.
1521
1570
  ...(eventsCtx?.dbPath ? { stateDbPath: eventsCtx.dbPath } : {}),
1522
- });
1571
+ }));
1523
1572
  extractResults.push(result);
1524
1573
  {
1525
1574
  const gr = await runAutoAcceptGate(primaryStashDir
@@ -1610,7 +1659,13 @@ async function runImprovePreparationStage(args) {
1610
1659
  const validationFailures = [];
1611
1660
  for (const candidate of postCleanupRefs) {
1612
1661
  try {
1613
- const filePath = await findAssetFilePath(candidate.ref, options.stashDir);
1662
+ // #591: use the path pre-resolved at planning time when it is still on
1663
+ // disk — a serial async DB lookup per ref cost ~500 s on a 9 000-ref
1664
+ // stash. Fall back to findAssetFilePath only for refs that bypassed
1665
+ // collectEligibleRefs' index scan or whose file moved since planning.
1666
+ const filePath = candidate.filePath && fs.existsSync(candidate.filePath)
1667
+ ? candidate.filePath
1668
+ : await findAssetFilePath(candidate.ref, options.stashDir);
1614
1669
  if (!filePath) {
1615
1670
  validationFailures.push({ ref: candidate.ref, reason: "file not found on disk" });
1616
1671
  continue;
@@ -1918,15 +1973,32 @@ async function runImprovePreparationStage(args) {
1918
1973
  const assetMissingOnDisk = [];
1919
1974
  const existsCheckedActionable = [];
1920
1975
  for (const candidate of sorted) {
1921
- const filePath = await findAssetFilePath(candidate.ref, options.stashDir);
1976
+ // #591: prefer the path pre-resolved at planning time (synchronous
1977
+ // existsSync) over a serial async DB lookup per ref.
1978
+ const filePath = candidate.filePath && fs.existsSync(candidate.filePath)
1979
+ ? candidate.filePath
1980
+ : await findAssetFilePath(candidate.ref, options.stashDir);
1922
1981
  if (filePath && fs.existsSync(filePath)) {
1923
1982
  existsCheckedActionable.push(candidate);
1924
1983
  }
1925
1984
  else {
1926
1985
  assetMissingOnDisk.push(candidate.ref);
1927
- appendEvent({ eventType: "improve_skipped", ref: candidate.ref, metadata: { reason: "asset_missing_on_disk" } }, eventsCtx);
1928
1986
  }
1929
1987
  }
1988
+ // #592 audit: one summary event instead of one per missing ref. Normally
1989
+ // tiny, but a stash deletion racing the run could make this O(n) sequential
1990
+ // state.db writes. `refs` is capped so the metadata row stays bounded.
1991
+ if (assetMissingOnDisk.length > 0) {
1992
+ appendEvent({
1993
+ eventType: "improve_skipped",
1994
+ ref: undefined,
1995
+ metadata: {
1996
+ reason: "asset_missing_on_disk",
1997
+ count: assetMissingOnDisk.length,
1998
+ refs: assetMissingOnDisk.slice(0, 50),
1999
+ },
2000
+ }, eventsCtx);
2001
+ }
1930
2002
  const actionableRefs = existsCheckedActionable;
1931
2003
  // Re-split actionableRefs (sorted) into reflect-path vs distill-only-path while
1932
2004
  // preserving sort order. distillOnlyRefs participate in the sort so --limit
@@ -2167,9 +2239,11 @@ async function runImproveLoopStage(args) {
2167
2239
  if (remainingBudgetMs() <= 0)
2168
2240
  break;
2169
2241
  // draftMode: skip DB write so each sample doesn't create a proposal.
2170
- samples.push(await reflectFn({ ...reflectCallArgs, draftMode: true }));
2242
+ samples.push(await withLlmStage("reflect", () => reflectFn({ ...reflectCallArgs, draftMode: true })));
2171
2243
  }
2172
- const winner = pickMajorityVote(samples.length > 0 ? samples : [await reflectFn({ ...reflectCallArgs, draftMode: true })]);
2244
+ const winner = pickMajorityVote(samples.length > 0
2245
+ ? samples
2246
+ : [await withLlmStage("reflect", () => reflectFn({ ...reflectCallArgs, draftMode: true }))]);
2173
2247
  // Persist only the majority-vote winner as a single real proposal.
2174
2248
  if (winner.ok && primaryStashDir) {
2175
2249
  const persistResult = createProposal(primaryStashDir, {
@@ -2194,7 +2268,7 @@ async function runImproveLoopStage(args) {
2194
2268
  }
2195
2269
  }
2196
2270
  else {
2197
- reflectResult = await reflectFn(reflectCallArgs);
2271
+ reflectResult = await withLlmStage("reflect", () => reflectFn(reflectCallArgs));
2198
2272
  }
2199
2273
  const isCooldown = !reflectResult.ok && reflectResult.reason === "cooldown";
2200
2274
  // Content-policy guard hits (reflect size-rail rejections) are NOT
@@ -2211,6 +2285,12 @@ async function runImproveLoopStage(args) {
2211
2285
  // user's stack were this case; see review §1a row "Reflect refused
2212
2286
  // asset type".
2213
2287
  const isTypeRefused = !reflectResult.ok && reflectResult.reason === "unsupported_type";
2288
+ // Noise-gate suppression (#580): the candidate edit was an empty
2289
+ // diff or a cosmetic-only reformat of the current asset. Like
2290
+ // `unsupported_type`, this is a deterministic skip — not an LLM
2291
+ // fault — so it routes to the `reflect-skipped` bucket and stays
2292
+ // out of recentErrors/avoidPatterns.
2293
+ const isNoChange = !reflectResult.ok && reflectResult.reason === "no_change";
2214
2294
  actions.push({
2215
2295
  ref: planned.ref,
2216
2296
  mode: reflectResult.ok
@@ -2219,18 +2299,19 @@ async function runImproveLoopStage(args) {
2219
2299
  ? "reflect-cooldown"
2220
2300
  : isGuardReject
2221
2301
  ? "reflect-guard-rejected"
2222
- : isTypeRefused
2302
+ : isTypeRefused || isNoChange
2223
2303
  ? "reflect-skipped"
2224
2304
  : "reflect-failed",
2225
2305
  result: reflectResult,
2226
2306
  });
2227
- // Cooldown skips, guard rejects, and type-refused skips are not
2228
- // failures — do not pollute recentErrors with them (those get
2229
- // injected as `avoidPatterns` into the next reflect prompt). Guard
2230
- // rejects ARE worth showing the LLM as a learn-signal so the next
2231
- // iteration sees "your last expansion was too large"; type-refused
2232
- // is deterministic and adds no learning signal.
2233
- if (!reflectResult.ok && !isCooldown && !isTypeRefused) {
2307
+ // Cooldown skips, guard rejects, type-refused skips, and noise-gate
2308
+ // skips are not failures — do not pollute recentErrors with them
2309
+ // (those get injected as `avoidPatterns` into the next reflect
2310
+ // prompt). Guard rejects ARE worth showing the LLM as a learn-signal
2311
+ // so the next iteration sees "your last expansion was too large";
2312
+ // type-refused and no-change are deterministic and add no learning
2313
+ // signal.
2314
+ if (!reflectResult.ok && !isCooldown && !isTypeRefused && !isNoChange) {
2234
2315
  const errMsg = reflectResult.error ?? reflectResult.reason ?? "unknown reflect error";
2235
2316
  pushRecentError("reflect", errMsg);
2236
2317
  }
@@ -2352,11 +2433,11 @@ async function runImproveLoopStage(args) {
2352
2433
  }
2353
2434
  }
2354
2435
  }
2355
- const distillResult = await distillFn({
2436
+ const distillResult = await withLlmStage("distill", () => distillFn({
2356
2437
  ref: planned.ref,
2357
2438
  ...(parsedPlannedRef.type === "memory" ? { proposalKind: "auto" } : {}),
2358
2439
  ...(options.stashDir ? { stashDir: options.stashDir } : {}),
2359
- });
2440
+ }));
2360
2441
  actions.push({ ref: planned.ref, mode: "distill", result: distillResult });
2361
2442
  if (distillResult.outcome === "queued" && distillResult.proposal) {
2362
2443
  const distillGr = await runAutoAcceptGate([{ proposalId: distillResult.proposal.id, confidence: distillResult.proposal.confidence }], distillGateCfg);
@@ -2497,7 +2578,9 @@ async function runImprovePostLoopStage(args) {
2497
2578
  };
2498
2579
  }
2499
2580
  // TODO(refactor): mutates the passed-in `allWarnings` array as a hidden side channel. Return warnings in ImproveMaintenanceResult and merge in caller — invasive signature change deferred to next refactor pass.
2500
- async function runImproveMaintenancePasses(args) {
2581
+ // Exported for tests (#584/#585 DB-locking regression coverage); production
2582
+ // callers reach it only through akmImprove → runImprovePostLoopStage.
2583
+ export async function runImproveMaintenancePasses(args) {
2501
2584
  const { options, primaryStashDir, memoryRefsForInference, allWarnings, reindexFn, consolidationRan, budgetSignal, eventsCtx, improveProfile, } = args;
2502
2585
  if (!primaryStashDir)
2503
2586
  return { memoryInferenceDurationMs: 0, graphExtractionDurationMs: 0 };
@@ -2516,8 +2599,27 @@ async function runImproveMaintenancePasses(args) {
2516
2599
  let graphExtractionDurationMs = 0;
2517
2600
  let orphansPurged = 0;
2518
2601
  let proposalsExpired = 0;
2602
+ const openIndexDb = () => openDatabase(getDbPath(), config.embedding?.dimension ? { embeddingDim: config.embedding.dimension } : undefined);
2603
+ // #584: reindexFn opens its own write handle on the same index.db WAL file.
2604
+ // Holding our handle across that call produced SQLITE_BUSY / "database is
2605
+ // locked" failures in production, so the handle is closed BEFORE every
2606
+ // reindex and reopened after — the fresh handle also sees the post-reindex
2607
+ // state that graph extraction and staleness detection below rely on. The
2608
+ // reopen runs in `finally` so a failed reindex still leaves a usable handle.
2609
+ const reindexWithIndexDbReleased = async (stashDir) => {
2610
+ if (db) {
2611
+ closeDatabase(db);
2612
+ db = undefined;
2613
+ }
2614
+ try {
2615
+ await reindexFn({ stashDir });
2616
+ }
2617
+ finally {
2618
+ db = openIndexDb();
2619
+ }
2620
+ };
2519
2621
  try {
2520
- db = openDatabase(getDbPath(), config.embedding?.dimension ? { embeddingDim: config.embedding.dimension } : undefined);
2622
+ db = openIndexDb();
2521
2623
  // Memory inference candidate-discovery (post-Item 9 fix from
2522
2624
  // memory:akm-improve-critical-review-2026-05-20). Previously this pass
2523
2625
  // was gated on memoryRefsForInference.size > 0 AND passed those refs as a
@@ -2543,7 +2645,7 @@ async function runImproveMaintenancePasses(args) {
2543
2645
  const inferenceStart = Date.now();
2544
2646
  try {
2545
2647
  // O-1 (#364): pass budget signal so a hung inference call is cancelled.
2546
- memoryInference = await memoryInferenceFn({
2648
+ memoryInference = await withLlmStage("memory-inference", () => memoryInferenceFn({
2547
2649
  config,
2548
2650
  sources,
2549
2651
  signal: budgetSignal,
@@ -2553,7 +2655,7 @@ async function runImproveMaintenancePasses(args) {
2553
2655
  const current = event.currentRef ? ` ${event.currentRef}` : "";
2554
2656
  info(`[improve] memory inference ${event.processed}/${event.total}${current} (written ${event.writtenFacts}, skipped ${event.skippedNoFacts})`);
2555
2657
  },
2556
- });
2658
+ }));
2557
2659
  memoryInferenceDurationMs = Date.now() - inferenceStart;
2558
2660
  actions.push({ ref: "memory:_inference", mode: "memory-inference", result: memoryInference });
2559
2661
  info(`[improve] memory inference complete (${memoryInference.writtenFacts} facts written from ${memoryInference.splitParents} parents)`);
@@ -2566,7 +2668,7 @@ async function runImproveMaintenancePasses(args) {
2566
2668
  if (memoryInference && (memoryInference.splitParents > 0 || memoryInference.writtenFacts > 0)) {
2567
2669
  info("[improve] reindexing after memory inference writes");
2568
2670
  try {
2569
- await reindexFn({ stashDir: primaryStashDir });
2671
+ await reindexWithIndexDbReleased(primaryStashDir);
2570
2672
  reindexedAfterInference = true;
2571
2673
  info("[improve] reindex after memory inference complete");
2572
2674
  }
@@ -2602,7 +2704,7 @@ async function runImproveMaintenancePasses(args) {
2602
2704
  if (consolidationRan && !reindexedAfterInference) {
2603
2705
  info("[improve] reindexing after consolidation (graph extraction needs current state)");
2604
2706
  try {
2605
- await reindexFn({ stashDir: primaryStashDir });
2707
+ await reindexWithIndexDbReleased(primaryStashDir);
2606
2708
  reindexedAfterInference = true;
2607
2709
  info("[improve] reindex after consolidation complete");
2608
2710
  }
@@ -2610,10 +2712,8 @@ async function runImproveMaintenancePasses(args) {
2610
2712
  allWarnings.push(`reindex after consolidation failed: ${err instanceof Error ? err.message : String(err)}`);
2611
2713
  }
2612
2714
  }
2613
- if (db && reindexedAfterInference) {
2614
- closeDatabase(db);
2615
- db = openDatabase(getDbPath(), config.embedding?.dimension ? { embeddingDim: config.embedding.dimension } : undefined);
2616
- }
2715
+ // #584: no close/reopen needed here — reindexWithIndexDbReleased
2716
+ // already swapped in a fresh post-reindex handle.
2617
2717
  // Resolve touched refs to absolute file paths. Skipped for fullScan
2618
2718
  // (candidatePaths stays undefined → extractor processes all files).
2619
2719
  let candidatePaths;
@@ -2633,7 +2733,7 @@ async function runImproveMaintenancePasses(args) {
2633
2733
  info(`[improve] graph extraction ${event.processed}/${event.total}${current} (extracted ${event.extracted}, entities ${event.totalEntities}, relations ${event.totalRelations})`);
2634
2734
  };
2635
2735
  // O-1 (#364): pass budget signal so a hung graph extraction call is cancelled.
2636
- graphExtraction = await graphExtractionFn({
2736
+ graphExtraction = await withLlmStage("graph-extraction", () => graphExtractionFn({
2637
2737
  config,
2638
2738
  sources,
2639
2739
  signal: budgetSignal,
@@ -2641,7 +2741,7 @@ async function runImproveMaintenancePasses(args) {
2641
2741
  reEnrich: false,
2642
2742
  onProgress: progressHandler,
2643
2743
  options: { candidatePaths },
2644
- });
2744
+ }));
2645
2745
  graphExtractionDurationMs = Date.now() - extractionStart;
2646
2746
  actions.push({ ref: "graph:_artifact", mode: "graph-extraction", result: graphExtraction });
2647
2747
  info(`[improve] graph extraction complete (${graphExtraction.quality.extractedFiles} files, ${graphExtraction.quality.entityCount} entities, ${graphExtraction.quality.relationCount} relations)`);
@@ -2712,18 +2812,22 @@ async function runImproveMaintenancePasses(args) {
2712
2812
  // invocation, and every command surface emits at least one event besides —
2713
2813
  // without this trim, state.db is a permanent append-only log. Config key
2714
2814
  // `improve.eventRetentionDays` (default 90, set 0 to disable) controls the
2715
- // window. `purgeOldEvents()` opens its own state.db handle separate from
2716
- // the index `db` above (different SQLite file).
2815
+ // window. The purge runs against state.db (a different SQLite file from
2816
+ // the index `db` above).
2717
2817
  {
2718
2818
  const retentionDays = typeof config.improve?.eventRetentionDays === "number" ? config.improve.eventRetentionDays : 90;
2719
2819
  if (retentionDays > 0) {
2820
+ // #585: reuse the long-lived eventsCtx.db connection when akmImprove
2821
+ // opened one — opening a second state.db write connection while
2822
+ // eventsDb is still live made two simultaneous writers contend on the
2823
+ // same WAL file ("database is locked"). Only the eventsCtx.dbPath
2824
+ // fallback path (state.db failed to open up-front) opens — and then
2825
+ // owns and closes — its own handle. C2 still holds: the fallback uses
2826
+ // the boundary-pinned path, never a live `process.env` re-read.
2827
+ const ownsStateDb = !eventsCtx?.db;
2720
2828
  let stateDb;
2721
2829
  try {
2722
- // C2: reuse the boundary-pinned state.db path carried on eventsCtx so
2723
- // this purge open never re-reads `process.env` live mid-run. The path
2724
- // is always set by akmImprove; openStateDatabase() falls back to the
2725
- // env-derived default only if a caller omitted it entirely.
2726
- stateDb = openStateDatabase(eventsCtx?.dbPath);
2830
+ stateDb = eventsCtx?.db ?? openStateDatabase(eventsCtx?.dbPath);
2727
2831
  const purgedCount = purgeOldEvents(stateDb, retentionDays);
2728
2832
  if (purgedCount > 0) {
2729
2833
  info(`[improve] events purge: ${purgedCount} event(s) older than ${retentionDays}d removed from state.db`);
@@ -2751,7 +2855,7 @@ async function runImproveMaintenancePasses(args) {
2751
2855
  allWarnings.push(`events purge failed: ${err instanceof Error ? err.message : String(err)}`);
2752
2856
  }
2753
2857
  finally {
2754
- if (stateDb) {
2858
+ if (ownsStateDb && stateDb) {
2755
2859
  try {
2756
2860
  stateDb.close();
2757
2861
  }
@@ -2760,6 +2864,37 @@ async function runImproveMaintenancePasses(args) {
2760
2864
  }
2761
2865
  }
2762
2866
  }
2867
+ // task_logs in logs.db (#579) shares the same retention window as
2868
+ // events/improve_runs — all three are observability data governed by
2869
+ // the single improve.eventRetentionDays knob. Separate try/finally
2870
+ // because logs.db is a different file: a locked/missing logs.db must
2871
+ // not block the state.db purges above.
2872
+ let logsDb;
2873
+ try {
2874
+ logsDb = openLogsDatabase();
2875
+ const taskLogsPurged = purgeOldTaskLogs(logsDb, retentionDays);
2876
+ if (taskLogsPurged > 0) {
2877
+ info(`[improve] task_logs purge: ${taskLogsPurged} log line(s) older than ${retentionDays}d removed from logs.db`);
2878
+ }
2879
+ appendEvent({
2880
+ eventType: "task_logs_purged",
2881
+ ref: "task_logs:_purge",
2882
+ metadata: { purgedCount: taskLogsPurged, retentionDays },
2883
+ }, eventsCtx);
2884
+ }
2885
+ catch (err) {
2886
+ allWarnings.push(`task_logs purge failed: ${err instanceof Error ? err.message : String(err)}`);
2887
+ }
2888
+ finally {
2889
+ if (logsDb) {
2890
+ try {
2891
+ logsDb.close();
2892
+ }
2893
+ catch {
2894
+ // best-effort
2895
+ }
2896
+ }
2897
+ }
2763
2898
  }
2764
2899
  }
2765
2900
  // Phase 4A (staleness detection). Activates the `deprecated` belief-state
@@ -2768,7 +2903,7 @@ async function runImproveMaintenancePasses(args) {
2768
2903
  // and before the URL check (which lives in the outer caller).
2769
2904
  if (sources.length > 0) {
2770
2905
  try {
2771
- stalenessDetection = await stalenessDetectionFn({ config, sources, signal: budgetSignal, db });
2906
+ stalenessDetection = await withLlmStage("staleness-detection", () => stalenessDetectionFn({ config, sources, signal: budgetSignal, db }));
2772
2907
  if (stalenessDetection.considered > 0) {
2773
2908
  info(`[improve] staleness detection complete (considered ${stalenessDetection.considered}, ` +
2774
2909
  `deprecated ${stalenessDetection.deprecated}, confirmed ${stalenessDetection.confirmed}, ` +
@@ -46,6 +46,7 @@ import { baseFailureFields, enoentHintMessage, isEnoentFailure, loadAgentConfigF
46
46
  import { checkReflectSize } from "../proposal/validators/proposal-quality-validators.js";
47
47
  import { createProposal, isProposalSkipped, listProposals, } from "../proposal/validators/proposals.js";
48
48
  import { deriveLessonRef, runLessonQualityJudge } from "./distill.js";
49
+ import { classifyReflectChange } from "./reflect-noise.js";
49
50
  const MAX_FEEDBACK_LINES = 10;
50
51
  const MAX_GLOBAL_FEEDBACK_LINES = 20;
51
52
  /**
@@ -1138,6 +1139,30 @@ export async function akmReflect(options = {}) {
1138
1139
  content: sanitizeOutcome.content,
1139
1140
  ...(sanitizeOutcome.frontmatter ? { frontmatter: sanitizeOutcome.frontmatter } : {}),
1140
1141
  };
1142
+ // 7c. Noise gate (#580): never queue a proposal whose sanitized content is
1143
+ // identical to the current asset (empty diff) or differs only cosmetically
1144
+ // (whitespace reflow, code-fence language hints, YAML scalar re-folding).
1145
+ // Pure deterministic text comparison — see `reflect-noise.ts`. Runs before
1146
+ // the draftMode branch so self-consistency sampling never votes a no-op
1147
+ // candidate into the queue either. Skipped when there is no source asset
1148
+ // (new-asset proposals have nothing to diff against).
1149
+ if (assetContent !== undefined) {
1150
+ const changeKind = classifyReflectChange(assetContent, payload.content);
1151
+ if (changeKind !== "substantive") {
1152
+ const subreason = changeKind === "noop" ? "reflect_skipped_noop" : "reflect_skipped_cosmetic";
1153
+ emitReflectFailed("no_change", subreason, options.ref, { changeKind });
1154
+ return {
1155
+ schemaVersion: 1,
1156
+ ok: false,
1157
+ reason: "no_change",
1158
+ error: changeKind === "noop"
1159
+ ? `Reflect skipped: proposed content for ${payload.ref} is identical to the current asset (empty diff); no proposal created.`
1160
+ : `Reflect skipped: proposed content for ${payload.ref} is a cosmetic-only reformat of the current asset (whitespace/fence/YAML-folding changes); no proposal created.`,
1161
+ ...(options.ref ? { ref: options.ref } : {}),
1162
+ exitCode: result.exitCode,
1163
+ };
1164
+ }
1165
+ }
1141
1166
  // 8. Create the proposal. The proposal queue is the ONLY thing reflect
1142
1167
  // writes — promotion to a real asset is gated by `akm proposal accept`.
1143
1168
  //