akm-cli 0.9.0-beta.2 → 0.9.0-beta.4
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 +248 -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/health/html-report.js +448 -0
- package/dist/commands/health.js +97 -6
- package/dist/commands/improve/consolidate.js +15 -2
- package/dist/commands/improve/extract.js +38 -2
- package/dist/commands/improve/improve-auto-accept.js +27 -1
- package/dist/commands/improve/improve.js +167 -53
- 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/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/runner.js +99 -16
- package/dist/workflows/db.js +4 -0
- package/package.json +2 -1
|
@@ -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
|
}
|
|
@@ -717,7 +722,7 @@ export async function akmImprove(options = {}) {
|
|
|
717
722
|
if (primaryStashDir && shouldAnalyzeMemoryCleanup(scope, memorySummary.eligible, primaryStashDir)) {
|
|
718
723
|
try {
|
|
719
724
|
// Reuse the config resolved at the top of the run instead of a second load.
|
|
720
|
-
await detectAndWriteContradictions(primaryStashDir, _earlyConfig);
|
|
725
|
+
await withLlmStage("memory-contradiction", () => detectAndWriteContradictions(primaryStashDir, _earlyConfig));
|
|
721
726
|
}
|
|
722
727
|
catch (err) {
|
|
723
728
|
// Non-fatal: contradiction detection is a best-effort pass.
|
|
@@ -777,6 +782,9 @@ export async function akmImprove(options = {}) {
|
|
|
777
782
|
// Pinned to the boundary snapshot so the fallback per-call `appendEvent`
|
|
778
783
|
// opens (when the long-lived handle below fails to open) never re-read env.
|
|
779
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 = () => { };
|
|
780
788
|
try {
|
|
781
789
|
// H7 (#566): arm the budget watchdog. `armBudgetWatchdog` captures both the
|
|
782
790
|
// budget timer and the hard-kill timer it schedules on exhaustion, returning
|
|
@@ -796,17 +804,26 @@ export async function akmImprove(options = {}) {
|
|
|
796
804
|
// still pinned to the boundary-resolved path, never a live env re-read.
|
|
797
805
|
eventsCtx = { dbPath: resolvedStateDbPath };
|
|
798
806
|
}
|
|
799
|
-
//
|
|
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
|
|
800
813
|
// pre-filtered (reflect AND distill both refuse them under the active
|
|
801
|
-
// profile).
|
|
802
|
-
//
|
|
803
|
-
//
|
|
804
|
-
//
|
|
805
|
-
|
|
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) {
|
|
806
820
|
appendEvent({
|
|
807
821
|
eventType: "improve_skipped",
|
|
808
|
-
ref:
|
|
809
|
-
metadata: {
|
|
822
|
+
ref: undefined,
|
|
823
|
+
metadata: {
|
|
824
|
+
reason: "profile_filtered_all_passes",
|
|
825
|
+
count: profileFilteredRefs.length,
|
|
826
|
+
},
|
|
810
827
|
}, eventsCtx);
|
|
811
828
|
}
|
|
812
829
|
const preparation = await runImprovePreparationStage({
|
|
@@ -1031,6 +1048,9 @@ export async function akmImprove(options = {}) {
|
|
|
1031
1048
|
throw err;
|
|
1032
1049
|
}
|
|
1033
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();
|
|
1034
1054
|
// O-1 (#364): Clear the budget abort timer so it does not keep the event
|
|
1035
1055
|
// loop alive after the run completes.
|
|
1036
1056
|
clearBudgetTimer();
|
|
@@ -1357,7 +1377,7 @@ async function runConsolidationPass(args) {
|
|
|
1357
1377
|
info(`[improve] consolidation skipped (pool ${eligiblePoolSize} < minPoolSize ${minPoolSize})`);
|
|
1358
1378
|
}
|
|
1359
1379
|
else if (!consolidationOnCooldown) {
|
|
1360
|
-
consolidation = await akmConsolidate({
|
|
1380
|
+
consolidation = await withLlmStage("consolidate", () => akmConsolidate({
|
|
1361
1381
|
...options.consolidateOptions,
|
|
1362
1382
|
config: consolidationConfig,
|
|
1363
1383
|
stashDir: options.stashDir,
|
|
@@ -1380,7 +1400,7 @@ async function runConsolidationPass(args) {
|
|
|
1380
1400
|
// options.consolidateOptions.autoAccept (if explicitly provided by caller)
|
|
1381
1401
|
// still wins because the spread above runs first.
|
|
1382
1402
|
autoAccept: options.consolidateOptions?.autoAccept ?? options.autoAccept,
|
|
1383
|
-
});
|
|
1403
|
+
}));
|
|
1384
1404
|
{
|
|
1385
1405
|
const consolidateGr = await runAutoAcceptGate(consolidation.promoted.map((proposalId) => {
|
|
1386
1406
|
try {
|
|
@@ -1467,7 +1487,9 @@ async function runImprovePreparationStage(args) {
|
|
|
1467
1487
|
// / `akm feedback` invocations. Replaces the akm-plugin session-checkpoint
|
|
1468
1488
|
// hook with an on-demand pull pipeline.
|
|
1469
1489
|
//
|
|
1470
|
-
// 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).
|
|
1471
1493
|
// Each available harness gets one call with the default --since window;
|
|
1472
1494
|
// already-seen sessions (tracked in state.db.extract_sessions_seen) are
|
|
1473
1495
|
// skipped automatically so re-runs don't burn LLM calls on unchanged data.
|
|
@@ -1501,7 +1523,13 @@ async function runImprovePreparationStage(args) {
|
|
|
1501
1523
|
const EXTRACT_DEFAULT_MIN_NEW_SESSIONS = 0;
|
|
1502
1524
|
const configuredMinNewSessions = extractConfig.profiles?.improve?.default?.processes?.extract?.minNewSessions;
|
|
1503
1525
|
const minNewSessions = typeof configuredMinNewSessions === "number" ? configuredMinNewSessions : EXTRACT_DEFAULT_MIN_NEW_SESSIONS;
|
|
1504
|
-
|
|
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)) {
|
|
1505
1533
|
const availableHarnesses = options.extractHarnesses ?? getAvailableHarnesses();
|
|
1506
1534
|
// The guard engages only when minNewSessions > 0; 0 disables it entirely.
|
|
1507
1535
|
let belowMinNewSessions = false;
|
|
@@ -1532,7 +1560,7 @@ async function runImprovePreparationStage(args) {
|
|
|
1532
1560
|
extractResults = [];
|
|
1533
1561
|
for (const h of availableHarnesses) {
|
|
1534
1562
|
try {
|
|
1535
|
-
const result = await akmExtract({
|
|
1563
|
+
const result = await withLlmStage("session-extraction", () => akmExtract({
|
|
1536
1564
|
type: h.name,
|
|
1537
1565
|
...(primaryStashDir !== undefined ? { stashDir: primaryStashDir } : {}),
|
|
1538
1566
|
config: extractConfig,
|
|
@@ -1540,7 +1568,7 @@ async function runImprovePreparationStage(args) {
|
|
|
1540
1568
|
...(options.extractHarnesses ? { harnesses: options.extractHarnesses } : {}),
|
|
1541
1569
|
// C2: pin extract's skip-tracking state.db open to the boundary path.
|
|
1542
1570
|
...(eventsCtx?.dbPath ? { stateDbPath: eventsCtx.dbPath } : {}),
|
|
1543
|
-
});
|
|
1571
|
+
}));
|
|
1544
1572
|
extractResults.push(result);
|
|
1545
1573
|
{
|
|
1546
1574
|
const gr = await runAutoAcceptGate(primaryStashDir
|
|
@@ -1631,7 +1659,13 @@ async function runImprovePreparationStage(args) {
|
|
|
1631
1659
|
const validationFailures = [];
|
|
1632
1660
|
for (const candidate of postCleanupRefs) {
|
|
1633
1661
|
try {
|
|
1634
|
-
|
|
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);
|
|
1635
1669
|
if (!filePath) {
|
|
1636
1670
|
validationFailures.push({ ref: candidate.ref, reason: "file not found on disk" });
|
|
1637
1671
|
continue;
|
|
@@ -1939,15 +1973,32 @@ async function runImprovePreparationStage(args) {
|
|
|
1939
1973
|
const assetMissingOnDisk = [];
|
|
1940
1974
|
const existsCheckedActionable = [];
|
|
1941
1975
|
for (const candidate of sorted) {
|
|
1942
|
-
|
|
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);
|
|
1943
1981
|
if (filePath && fs.existsSync(filePath)) {
|
|
1944
1982
|
existsCheckedActionable.push(candidate);
|
|
1945
1983
|
}
|
|
1946
1984
|
else {
|
|
1947
1985
|
assetMissingOnDisk.push(candidate.ref);
|
|
1948
|
-
appendEvent({ eventType: "improve_skipped", ref: candidate.ref, metadata: { reason: "asset_missing_on_disk" } }, eventsCtx);
|
|
1949
1986
|
}
|
|
1950
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
|
+
}
|
|
1951
2002
|
const actionableRefs = existsCheckedActionable;
|
|
1952
2003
|
// Re-split actionableRefs (sorted) into reflect-path vs distill-only-path while
|
|
1953
2004
|
// preserving sort order. distillOnlyRefs participate in the sort so --limit
|
|
@@ -2188,9 +2239,11 @@ async function runImproveLoopStage(args) {
|
|
|
2188
2239
|
if (remainingBudgetMs() <= 0)
|
|
2189
2240
|
break;
|
|
2190
2241
|
// draftMode: skip DB write so each sample doesn't create a proposal.
|
|
2191
|
-
samples.push(await reflectFn({ ...reflectCallArgs, draftMode: true }));
|
|
2242
|
+
samples.push(await withLlmStage("reflect", () => reflectFn({ ...reflectCallArgs, draftMode: true })));
|
|
2192
2243
|
}
|
|
2193
|
-
const winner = pickMajorityVote(samples.length > 0
|
|
2244
|
+
const winner = pickMajorityVote(samples.length > 0
|
|
2245
|
+
? samples
|
|
2246
|
+
: [await withLlmStage("reflect", () => reflectFn({ ...reflectCallArgs, draftMode: true }))]);
|
|
2194
2247
|
// Persist only the majority-vote winner as a single real proposal.
|
|
2195
2248
|
if (winner.ok && primaryStashDir) {
|
|
2196
2249
|
const persistResult = createProposal(primaryStashDir, {
|
|
@@ -2215,7 +2268,7 @@ async function runImproveLoopStage(args) {
|
|
|
2215
2268
|
}
|
|
2216
2269
|
}
|
|
2217
2270
|
else {
|
|
2218
|
-
reflectResult = await reflectFn(reflectCallArgs);
|
|
2271
|
+
reflectResult = await withLlmStage("reflect", () => reflectFn(reflectCallArgs));
|
|
2219
2272
|
}
|
|
2220
2273
|
const isCooldown = !reflectResult.ok && reflectResult.reason === "cooldown";
|
|
2221
2274
|
// Content-policy guard hits (reflect size-rail rejections) are NOT
|
|
@@ -2232,6 +2285,12 @@ async function runImproveLoopStage(args) {
|
|
|
2232
2285
|
// user's stack were this case; see review §1a row "Reflect refused
|
|
2233
2286
|
// asset type".
|
|
2234
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";
|
|
2235
2294
|
actions.push({
|
|
2236
2295
|
ref: planned.ref,
|
|
2237
2296
|
mode: reflectResult.ok
|
|
@@ -2240,18 +2299,19 @@ async function runImproveLoopStage(args) {
|
|
|
2240
2299
|
? "reflect-cooldown"
|
|
2241
2300
|
: isGuardReject
|
|
2242
2301
|
? "reflect-guard-rejected"
|
|
2243
|
-
: isTypeRefused
|
|
2302
|
+
: isTypeRefused || isNoChange
|
|
2244
2303
|
? "reflect-skipped"
|
|
2245
2304
|
: "reflect-failed",
|
|
2246
2305
|
result: reflectResult,
|
|
2247
2306
|
});
|
|
2248
|
-
// Cooldown skips, guard rejects,
|
|
2249
|
-
// failures — do not pollute recentErrors with them
|
|
2250
|
-
// injected as `avoidPatterns` into the next reflect
|
|
2251
|
-
// rejects ARE worth showing the LLM as a learn-signal
|
|
2252
|
-
// iteration sees "your last expansion was too large";
|
|
2253
|
-
//
|
|
2254
|
-
|
|
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) {
|
|
2255
2315
|
const errMsg = reflectResult.error ?? reflectResult.reason ?? "unknown reflect error";
|
|
2256
2316
|
pushRecentError("reflect", errMsg);
|
|
2257
2317
|
}
|
|
@@ -2373,11 +2433,11 @@ async function runImproveLoopStage(args) {
|
|
|
2373
2433
|
}
|
|
2374
2434
|
}
|
|
2375
2435
|
}
|
|
2376
|
-
const distillResult = await distillFn({
|
|
2436
|
+
const distillResult = await withLlmStage("distill", () => distillFn({
|
|
2377
2437
|
ref: planned.ref,
|
|
2378
2438
|
...(parsedPlannedRef.type === "memory" ? { proposalKind: "auto" } : {}),
|
|
2379
2439
|
...(options.stashDir ? { stashDir: options.stashDir } : {}),
|
|
2380
|
-
});
|
|
2440
|
+
}));
|
|
2381
2441
|
actions.push({ ref: planned.ref, mode: "distill", result: distillResult });
|
|
2382
2442
|
if (distillResult.outcome === "queued" && distillResult.proposal) {
|
|
2383
2443
|
const distillGr = await runAutoAcceptGate([{ proposalId: distillResult.proposal.id, confidence: distillResult.proposal.confidence }], distillGateCfg);
|
|
@@ -2518,7 +2578,9 @@ async function runImprovePostLoopStage(args) {
|
|
|
2518
2578
|
};
|
|
2519
2579
|
}
|
|
2520
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.
|
|
2521
|
-
|
|
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) {
|
|
2522
2584
|
const { options, primaryStashDir, memoryRefsForInference, allWarnings, reindexFn, consolidationRan, budgetSignal, eventsCtx, improveProfile, } = args;
|
|
2523
2585
|
if (!primaryStashDir)
|
|
2524
2586
|
return { memoryInferenceDurationMs: 0, graphExtractionDurationMs: 0 };
|
|
@@ -2537,8 +2599,27 @@ async function runImproveMaintenancePasses(args) {
|
|
|
2537
2599
|
let graphExtractionDurationMs = 0;
|
|
2538
2600
|
let orphansPurged = 0;
|
|
2539
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
|
+
};
|
|
2540
2621
|
try {
|
|
2541
|
-
db =
|
|
2622
|
+
db = openIndexDb();
|
|
2542
2623
|
// Memory inference candidate-discovery (post-Item 9 fix from
|
|
2543
2624
|
// memory:akm-improve-critical-review-2026-05-20). Previously this pass
|
|
2544
2625
|
// was gated on memoryRefsForInference.size > 0 AND passed those refs as a
|
|
@@ -2564,7 +2645,7 @@ async function runImproveMaintenancePasses(args) {
|
|
|
2564
2645
|
const inferenceStart = Date.now();
|
|
2565
2646
|
try {
|
|
2566
2647
|
// O-1 (#364): pass budget signal so a hung inference call is cancelled.
|
|
2567
|
-
memoryInference = await memoryInferenceFn({
|
|
2648
|
+
memoryInference = await withLlmStage("memory-inference", () => memoryInferenceFn({
|
|
2568
2649
|
config,
|
|
2569
2650
|
sources,
|
|
2570
2651
|
signal: budgetSignal,
|
|
@@ -2574,7 +2655,7 @@ async function runImproveMaintenancePasses(args) {
|
|
|
2574
2655
|
const current = event.currentRef ? ` ${event.currentRef}` : "";
|
|
2575
2656
|
info(`[improve] memory inference ${event.processed}/${event.total}${current} (written ${event.writtenFacts}, skipped ${event.skippedNoFacts})`);
|
|
2576
2657
|
},
|
|
2577
|
-
});
|
|
2658
|
+
}));
|
|
2578
2659
|
memoryInferenceDurationMs = Date.now() - inferenceStart;
|
|
2579
2660
|
actions.push({ ref: "memory:_inference", mode: "memory-inference", result: memoryInference });
|
|
2580
2661
|
info(`[improve] memory inference complete (${memoryInference.writtenFacts} facts written from ${memoryInference.splitParents} parents)`);
|
|
@@ -2587,7 +2668,7 @@ async function runImproveMaintenancePasses(args) {
|
|
|
2587
2668
|
if (memoryInference && (memoryInference.splitParents > 0 || memoryInference.writtenFacts > 0)) {
|
|
2588
2669
|
info("[improve] reindexing after memory inference writes");
|
|
2589
2670
|
try {
|
|
2590
|
-
await
|
|
2671
|
+
await reindexWithIndexDbReleased(primaryStashDir);
|
|
2591
2672
|
reindexedAfterInference = true;
|
|
2592
2673
|
info("[improve] reindex after memory inference complete");
|
|
2593
2674
|
}
|
|
@@ -2623,7 +2704,7 @@ async function runImproveMaintenancePasses(args) {
|
|
|
2623
2704
|
if (consolidationRan && !reindexedAfterInference) {
|
|
2624
2705
|
info("[improve] reindexing after consolidation (graph extraction needs current state)");
|
|
2625
2706
|
try {
|
|
2626
|
-
await
|
|
2707
|
+
await reindexWithIndexDbReleased(primaryStashDir);
|
|
2627
2708
|
reindexedAfterInference = true;
|
|
2628
2709
|
info("[improve] reindex after consolidation complete");
|
|
2629
2710
|
}
|
|
@@ -2631,10 +2712,8 @@ async function runImproveMaintenancePasses(args) {
|
|
|
2631
2712
|
allWarnings.push(`reindex after consolidation failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
2632
2713
|
}
|
|
2633
2714
|
}
|
|
2634
|
-
|
|
2635
|
-
|
|
2636
|
-
db = openDatabase(getDbPath(), config.embedding?.dimension ? { embeddingDim: config.embedding.dimension } : undefined);
|
|
2637
|
-
}
|
|
2715
|
+
// #584: no close/reopen needed here — reindexWithIndexDbReleased
|
|
2716
|
+
// already swapped in a fresh post-reindex handle.
|
|
2638
2717
|
// Resolve touched refs to absolute file paths. Skipped for fullScan
|
|
2639
2718
|
// (candidatePaths stays undefined → extractor processes all files).
|
|
2640
2719
|
let candidatePaths;
|
|
@@ -2654,7 +2733,7 @@ async function runImproveMaintenancePasses(args) {
|
|
|
2654
2733
|
info(`[improve] graph extraction ${event.processed}/${event.total}${current} (extracted ${event.extracted}, entities ${event.totalEntities}, relations ${event.totalRelations})`);
|
|
2655
2734
|
};
|
|
2656
2735
|
// O-1 (#364): pass budget signal so a hung graph extraction call is cancelled.
|
|
2657
|
-
graphExtraction = await graphExtractionFn({
|
|
2736
|
+
graphExtraction = await withLlmStage("graph-extraction", () => graphExtractionFn({
|
|
2658
2737
|
config,
|
|
2659
2738
|
sources,
|
|
2660
2739
|
signal: budgetSignal,
|
|
@@ -2662,7 +2741,7 @@ async function runImproveMaintenancePasses(args) {
|
|
|
2662
2741
|
reEnrich: false,
|
|
2663
2742
|
onProgress: progressHandler,
|
|
2664
2743
|
options: { candidatePaths },
|
|
2665
|
-
});
|
|
2744
|
+
}));
|
|
2666
2745
|
graphExtractionDurationMs = Date.now() - extractionStart;
|
|
2667
2746
|
actions.push({ ref: "graph:_artifact", mode: "graph-extraction", result: graphExtraction });
|
|
2668
2747
|
info(`[improve] graph extraction complete (${graphExtraction.quality.extractedFiles} files, ${graphExtraction.quality.entityCount} entities, ${graphExtraction.quality.relationCount} relations)`);
|
|
@@ -2733,18 +2812,22 @@ async function runImproveMaintenancePasses(args) {
|
|
|
2733
2812
|
// invocation, and every command surface emits at least one event besides —
|
|
2734
2813
|
// without this trim, state.db is a permanent append-only log. Config key
|
|
2735
2814
|
// `improve.eventRetentionDays` (default 90, set 0 to disable) controls the
|
|
2736
|
-
// window.
|
|
2737
|
-
// the index `db` above
|
|
2815
|
+
// window. The purge runs against state.db (a different SQLite file from
|
|
2816
|
+
// the index `db` above).
|
|
2738
2817
|
{
|
|
2739
2818
|
const retentionDays = typeof config.improve?.eventRetentionDays === "number" ? config.improve.eventRetentionDays : 90;
|
|
2740
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;
|
|
2741
2828
|
let stateDb;
|
|
2742
2829
|
try {
|
|
2743
|
-
|
|
2744
|
-
// this purge open never re-reads `process.env` live mid-run. The path
|
|
2745
|
-
// is always set by akmImprove; openStateDatabase() falls back to the
|
|
2746
|
-
// env-derived default only if a caller omitted it entirely.
|
|
2747
|
-
stateDb = openStateDatabase(eventsCtx?.dbPath);
|
|
2830
|
+
stateDb = eventsCtx?.db ?? openStateDatabase(eventsCtx?.dbPath);
|
|
2748
2831
|
const purgedCount = purgeOldEvents(stateDb, retentionDays);
|
|
2749
2832
|
if (purgedCount > 0) {
|
|
2750
2833
|
info(`[improve] events purge: ${purgedCount} event(s) older than ${retentionDays}d removed from state.db`);
|
|
@@ -2772,7 +2855,7 @@ async function runImproveMaintenancePasses(args) {
|
|
|
2772
2855
|
allWarnings.push(`events purge failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
2773
2856
|
}
|
|
2774
2857
|
finally {
|
|
2775
|
-
if (stateDb) {
|
|
2858
|
+
if (ownsStateDb && stateDb) {
|
|
2776
2859
|
try {
|
|
2777
2860
|
stateDb.close();
|
|
2778
2861
|
}
|
|
@@ -2781,6 +2864,37 @@ async function runImproveMaintenancePasses(args) {
|
|
|
2781
2864
|
}
|
|
2782
2865
|
}
|
|
2783
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
|
+
}
|
|
2784
2898
|
}
|
|
2785
2899
|
}
|
|
2786
2900
|
// Phase 4A (staleness detection). Activates the `deprecated` belief-state
|
|
@@ -2789,7 +2903,7 @@ async function runImproveMaintenancePasses(args) {
|
|
|
2789
2903
|
// and before the URL check (which lives in the outer caller).
|
|
2790
2904
|
if (sources.length > 0) {
|
|
2791
2905
|
try {
|
|
2792
|
-
stalenessDetection = await stalenessDetectionFn({ config, sources, signal: budgetSignal, db });
|
|
2906
|
+
stalenessDetection = await withLlmStage("staleness-detection", () => stalenessDetectionFn({ config, sources, signal: budgetSignal, db }));
|
|
2793
2907
|
if (stalenessDetection.considered > 0) {
|
|
2794
2908
|
info(`[improve] staleness detection complete (considered ${stalenessDetection.considered}, ` +
|
|
2795
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
|
//
|