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.
- package/CHANGELOG.md +128 -0
- package/dist/assets/templates/html/default.html +78 -0
- package/dist/assets/templates/html/health.html +560 -0
- package/dist/assets/templates/html/vendor/echarts.min.js +45 -0
- package/dist/cli/shared.js +21 -5
- package/dist/cli.js +36 -5
- package/dist/commands/config-cli.js +0 -10
- package/dist/commands/health/html-report.js +448 -0
- package/dist/commands/health.js +97 -6
- package/dist/commands/improve/extract.js +38 -2
- package/dist/commands/improve/improve-auto-accept.js +27 -1
- package/dist/commands/improve/improve-cli.js +7 -0
- package/dist/commands/improve/improve.js +201 -66
- package/dist/commands/improve/reflect-noise.js +0 -0
- package/dist/commands/improve/reflect.js +25 -0
- package/dist/commands/proposal/drain.js +73 -6
- package/dist/commands/proposal/proposal-cli.js +22 -10
- package/dist/commands/proposal/proposal.js +12 -1
- package/dist/commands/proposal/validators/proposals.js +361 -338
- package/dist/commands/remember.js +6 -2
- package/dist/commands/tasks/tasks.js +32 -8
- package/dist/core/config/config-schema.js +5 -0
- package/dist/core/logs-db.js +304 -0
- package/dist/core/state-db.js +107 -14
- package/dist/indexer/db/db.js +2 -2
- package/dist/indexer/passes/memory-inference.js +61 -22
- package/dist/integrations/harnesses/claude/session-log.js +16 -4
- package/dist/llm/client.js +15 -0
- package/dist/llm/usage-persist.js +77 -0
- package/dist/llm/usage-telemetry.js +103 -0
- package/dist/output/context.js +3 -2
- package/dist/output/html-render.js +73 -0
- package/dist/output/shapes/helpers.js +17 -1
- package/dist/output/text/helpers.js +69 -1
- package/dist/scripts/migrate-storage.js +65 -14
- package/dist/scripts/migrations/import-fs-improve-runs-to-db.js +14 -2
- package/dist/tasks/backends/cron.js +46 -9
- package/dist/tasks/runner.js +99 -16
- package/dist/workflows/db.js +4 -0
- package/package.json +1 -1
- 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
|
-
//
|
|
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).
|
|
778
|
-
//
|
|
779
|
-
//
|
|
780
|
-
//
|
|
781
|
-
|
|
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:
|
|
785
|
-
metadata: {
|
|
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
|
-
//
|
|
1345
|
-
//
|
|
1346
|
-
//
|
|
1347
|
-
//
|
|
1348
|
-
//
|
|
1349
|
-
//
|
|
1350
|
-
//
|
|
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 `
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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,
|
|
2228
|
-
// failures — do not pollute recentErrors with them
|
|
2229
|
-
// injected as `avoidPatterns` into the next reflect
|
|
2230
|
-
// rejects ARE worth showing the LLM as a learn-signal
|
|
2231
|
-
// iteration sees "your last expansion was too large";
|
|
2232
|
-
//
|
|
2233
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
|
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
|
|
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
|
-
|
|
2614
|
-
|
|
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.
|
|
2716
|
-
// the index `db` above
|
|
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
|
-
|
|
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}, ` +
|
|
Binary file
|
|
@@ -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
|
//
|