akm-cli 0.9.17 → 0.9.18
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 +22 -0
- package/dist/commands/health/checks.js +6 -6
- package/dist/commands/health.js +3 -3
- package/dist/commands/improve/consolidate/pair-pass.js +2 -2
- package/dist/commands/improve/consolidate.js +3 -3
- package/dist/commands/improve/distill.js +1 -1
- package/dist/commands/improve/preparation.js +2 -0
- package/dist/commands/improve/reflect.js +1 -1
- package/dist/commands/improve/stage.js +5 -3
- package/dist/commands/proposal/repository.js +5 -1
- package/dist/scripts/akm-migrate-node.js +23 -2
- package/dist/scripts/akm-migrate.js +23 -2
- package/dist/storage/sqlite-read-snapshot.js +46 -2
- package/dist/storage/state-db-integrity.js +12 -9
- package/docs/migration/v0.7-to-v0.8.md +2 -2
- package/docs/reference/data-and-telemetry.md +5 -2
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,28 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
|
|
6
6
|
|
|
7
7
|
## [Unreleased]
|
|
8
8
|
|
|
9
|
+
## [0.9.18] - 2026-09-29
|
|
10
|
+
|
|
11
|
+
### Fixed
|
|
12
|
+
|
|
13
|
+
- **A running `akm improve` no longer loses its SQLite file locks, which let
|
|
14
|
+
an older `sqlite3` delete `state.db`'s WAL.** Copying a database file
|
|
15
|
+
inside a process that also holds it open drops every POSIX lock the process
|
|
16
|
+
holds on it, and `akm improve` did exactly that (its read snapshots copy
|
|
17
|
+
`state.db`, hundreds of times a run). A peer on SQLite older than 3.51 (the
|
|
18
|
+
system `sqlite3` command, Python's `sqlite3` module) that then opened
|
|
19
|
+
`state.db` read-write, even just for `.backup`, saw no reader and deleted
|
|
20
|
+
`state.db-wal`/`-shm` at close, leaving colliding rowids, stale index
|
|
21
|
+
entries and lost rows. Affected: every release since 0.9.2 through the
|
|
22
|
+
snapshot, and 0.9.2 through 0.9.16 also on every `openStateDatabase` (fixed
|
|
23
|
+
in 0.9.17-alpha.4). The snapshot now copies in a child `cp` (Windows keeps
|
|
24
|
+
the in-process copy; its locks belong to the handle), and `improve` reads
|
|
25
|
+
proposals through its own connection instead of snapshotting per asset.
|
|
26
|
+
`akm health`'s `state-db-integrity` check now runs `PRAGMA integrity_check`,
|
|
27
|
+
since `quick_check` does not compare indexes with their tables and reported
|
|
28
|
+
this damage as `ok`. On earlier releases, open a live database only with
|
|
29
|
+
`sqlite3 -readonly`.
|
|
30
|
+
|
|
9
31
|
## [0.9.17] - 2026-09-29
|
|
10
32
|
|
|
11
33
|
### Added
|
|
@@ -888,7 +888,7 @@ export const HEALTH_CHECKS = [
|
|
|
888
888
|
// R0: nothing looked at state.db's own SQLite-level integrity before
|
|
889
889
|
// this — the round-trip probe above only proves one row can be appended
|
|
890
890
|
// and read back, which stays true on a database that fails
|
|
891
|
-
// `PRAGMA
|
|
891
|
+
// `PRAGMA integrity_check` elsewhere (corrupt indexes, out-of-order rowids).
|
|
892
892
|
// Also reports the freelist ratio (fraction of pages VACUUM could
|
|
893
893
|
// reclaim) so a bloated-but-uncorrupted file is visible as a warning
|
|
894
894
|
// rather than silence.
|
|
@@ -904,8 +904,8 @@ export const HEALTH_CHECKS = [
|
|
|
904
904
|
kind: "deterministic",
|
|
905
905
|
status: "fail",
|
|
906
906
|
confidence: "high",
|
|
907
|
-
message: `state.db failed PRAGMA
|
|
908
|
-
`sqlite3 state.db ".recover" | sqlite3 state.new.db, verify state.new.db passes
|
|
907
|
+
message: `state.db failed PRAGMA integrity_check: ${detail}. Repair: back up state.db, then run ` +
|
|
908
|
+
`sqlite3 -readonly state.db ".recover" | sqlite3 state.new.db, verify state.new.db passes integrity_check, stop ` +
|
|
909
909
|
"every akm process, then delete state.db-wal and state.db-shm before swapping state.new.db in as " +
|
|
910
910
|
"state.db — a leftover WAL from the OLD database is replayed onto the new one and corrupts it.",
|
|
911
911
|
evidence: { path: ctx.stateDbPath, lines, freelistRatio },
|
|
@@ -917,7 +917,7 @@ export const HEALTH_CHECKS = [
|
|
|
917
917
|
kind: "deterministic",
|
|
918
918
|
status: "fail",
|
|
919
919
|
confidence: "high",
|
|
920
|
-
message: `state.db passed PRAGMA
|
|
920
|
+
message: `state.db passed PRAGMA integrity_check, but reading its freelist/page-count failed: ${freelistError}.`,
|
|
921
921
|
evidence: { path: ctx.stateDbPath, lines, freelistError },
|
|
922
922
|
};
|
|
923
923
|
}
|
|
@@ -928,8 +928,8 @@ export const HEALTH_CHECKS = [
|
|
|
928
928
|
status: freelistWarn ? "warn" : "pass",
|
|
929
929
|
confidence: "high",
|
|
930
930
|
message: freelistWarn
|
|
931
|
-
? `state.db passed PRAGMA
|
|
932
|
-
: "state.db passed PRAGMA
|
|
931
|
+
? `state.db passed PRAGMA integrity_check, but ${(freelistRatio * 100).toFixed(1)}% of its pages are free (reclaimable by VACUUM).`
|
|
932
|
+
: "state.db passed PRAGMA integrity_check.",
|
|
933
933
|
evidence: {
|
|
934
934
|
path: ctx.stateDbPath,
|
|
935
935
|
freelistCount: ctx.stateDbFreelist.freelistCount,
|
package/dist/commands/health.js
CHANGED
|
@@ -17,7 +17,7 @@ import { countImproveRunsSince } from "../storage/repositories/improve-runs-repo
|
|
|
17
17
|
import { closeDatabase, openReadonlyExistingDatabase } from "../storage/repositories/index-connection.js";
|
|
18
18
|
import { getAllEntries } from "../storage/repositories/index-entries-repository.js";
|
|
19
19
|
import { queryTaskHistory } from "../storage/repositories/task-history-repository.js";
|
|
20
|
-
import { getStateDbFreelistInfo,
|
|
20
|
+
import { getStateDbFreelistInfo, runStateDbIntegrityCheck } from "../storage/state-db-integrity.js";
|
|
21
21
|
import { pkgVersion } from "../version.js";
|
|
22
22
|
import { collectArchiveUsageAdvisory } from "./health/archive-usage.js";
|
|
23
23
|
import { HEALTH_CHECKS, probeActiveImproveStrategy, runHealthEngineProbes, runPendingStateMigrationsCheck, SESSION_EXTRACTION_LEDGER_WINDOW_DAYS, } from "./health/checks.js";
|
|
@@ -182,10 +182,10 @@ function gatherTaskHistoryPhase(db, since, stateDbPath, now) {
|
|
|
182
182
|
const requiredTables = ["events", "proposals", "schema_migrations", "task_history"];
|
|
183
183
|
const missingTables = requiredTables.filter((name) => !tableNames.includes(name));
|
|
184
184
|
const probe = probeStateDbRoundTrip(stateDbPath);
|
|
185
|
-
// R0: read-only, independent of the round-trip probe above —
|
|
185
|
+
// R0: read-only, independent of the round-trip probe above — integrity_check
|
|
186
186
|
// catches corruption a successful append/read cannot (out-of-order rowids,
|
|
187
187
|
// bad index entry counts), and the freelist reading is purely informational.
|
|
188
|
-
const stateDbIntegrity =
|
|
188
|
+
const stateDbIntegrity = runStateDbIntegrityCheck(stateDbPath);
|
|
189
189
|
const stateDbFreelist = getStateDbFreelistInfo(stateDbPath);
|
|
190
190
|
// D8 (spec §5.3): a marked "command" row or a legacy (unmarked) "prompt"
|
|
191
191
|
// row is the agent/LLM arm; an unmarked "command" row is the legacy
|
|
@@ -635,7 +635,7 @@ seams = {}) {
|
|
|
635
635
|
// also be judged as part of another until that decision resolves.
|
|
636
636
|
const pendingRetireRefs = new Set();
|
|
637
637
|
try {
|
|
638
|
-
for (const p of listProposalsReadOnly(stashDir, { status: "pending" })) {
|
|
638
|
+
for (const p of listProposalsReadOnly(stashDir, { status: "pending" }, opts.proposalsCtx)) {
|
|
639
639
|
if (!isRetireProposal(p))
|
|
640
640
|
continue;
|
|
641
641
|
pendingRetireRefs.add(stripBundle(p.ref));
|
|
@@ -657,7 +657,7 @@ seams = {}) {
|
|
|
657
657
|
const rejectedPairKeys = new Set();
|
|
658
658
|
try {
|
|
659
659
|
for (const status of ["rejected", "reverted"]) {
|
|
660
|
-
for (const p of listProposalsReadOnly(stashDir, { status, includeArchive: true })) {
|
|
660
|
+
for (const p of listProposalsReadOnly(stashDir, { status, includeArchive: true }, opts.proposalsCtx)) {
|
|
661
661
|
if (!isRetireProposal(p) || !p.retirement)
|
|
662
662
|
continue;
|
|
663
663
|
rejectedPairKeys.add(rejectedPairKey(p.retirement.retiredRef, p.retirement.successorRef, p.retirement.retiredContentHash, p.retirement.successorContentHash));
|
|
@@ -222,10 +222,10 @@ function injectRandomClusterMembers(memories, profile, warnings) {
|
|
|
222
222
|
return out;
|
|
223
223
|
}
|
|
224
224
|
/** Body hashes of pending consolidate proposals, so the prompt can mark memories already queued. */
|
|
225
|
-
function loadPendingConsolidateProposalHashes(stashDir) {
|
|
225
|
+
function loadPendingConsolidateProposalHashes(stashDir, proposalsCtx) {
|
|
226
226
|
const hashes = new Set();
|
|
227
227
|
try {
|
|
228
|
-
for (const p of listProposalsReadOnly(stashDir, { status: "pending" })) {
|
|
228
|
+
for (const p of listProposalsReadOnly(stashDir, { status: "pending" }, proposalsCtx)) {
|
|
229
229
|
if (p.source !== "consolidate")
|
|
230
230
|
continue;
|
|
231
231
|
try {
|
|
@@ -622,7 +622,7 @@ async function planConsolidation(opts, config, stashDir, memories, warnings, sta
|
|
|
622
622
|
assertRunnerCredentials(llmRunner);
|
|
623
623
|
const { ordered, embedTelemetry } = await clusterMemoriesBySimilarity(budgeted, config, stateDb, opts.signal);
|
|
624
624
|
const chunks = slice(injectRandomClusterMembers(ordered, opts.improveProfile, warnings));
|
|
625
|
-
const pendingProposalBodyHashes = loadPendingConsolidateProposalHashes(stashDir);
|
|
625
|
+
const pendingProposalBodyHashes = loadPendingConsolidateProposalHashes(stashDir, opts.proposalsCtx);
|
|
626
626
|
warn(`[consolidate] ${budgeted.length} memories / ${chunks.length} chunk(s) / chunk_size=${chunkSize}` +
|
|
627
627
|
` / pending-proposal hashes: ${pendingProposalBodyHashes.size}`);
|
|
628
628
|
const planned = await judgeConsolidationChunks({
|
|
@@ -799,7 +799,7 @@ function readDistillFeedback(run) {
|
|
|
799
799
|
}
|
|
800
800
|
/** System + user prompt: rejected-proposal context, optional CLS neighbours, stash standards. */
|
|
801
801
|
async function buildDistillMessages(run, feedback, kind, outputRef) {
|
|
802
|
-
const rejectedProposals = rejectedProposalContext(run.stash, run.inputRef, run.options.ctx);
|
|
802
|
+
const rejectedProposals = rejectedProposalContext(run.stash, run.inputRef, run.options.ctx, run.options.eventsCtx);
|
|
803
803
|
// CLS interleaving (default off): show related lessons so the model does not overwrite them.
|
|
804
804
|
const cls = getImproveProcessConfig("distill", run.profile)?.cls ?? {};
|
|
805
805
|
let clsContext = "";
|
|
@@ -209,6 +209,8 @@ async function runConsolidationPass(args) {
|
|
|
209
209
|
maxChunkSize: processConfig?.maxChunkSize,
|
|
210
210
|
signal: args.budgetSignal,
|
|
211
211
|
p90ChunkSecondsDefault: processConfig?.p90ChunkSecondsDefault,
|
|
212
|
+
// Its read-only proposal lookups go through the run's own state.db handle.
|
|
213
|
+
...(eventsCtx?.db ? { proposalsCtx: { db: eventsCtx.db } } : {}),
|
|
212
214
|
}));
|
|
213
215
|
}
|
|
214
216
|
return { consolidation, plan: planned.plan };
|
|
@@ -761,7 +761,7 @@ async function gatherReflectPromptSources(options, stash, parsedRef, assetConten
|
|
|
761
761
|
relatedLessons: options.ref && parsedRef
|
|
762
762
|
? await readRelatedLessons(stash, options.ref, parsedRef, options.itemRef, options.eventsCtx)
|
|
763
763
|
: [],
|
|
764
|
-
rejectedProposals: rejectedProposalContext(stash, options.ref, options.ctx),
|
|
764
|
+
rejectedProposals: rejectedProposalContext(stash, options.ref, options.ctx, options.eventsCtx),
|
|
765
765
|
standardsContext: resolveStandardsContext(options.ref, stash),
|
|
766
766
|
};
|
|
767
767
|
}
|
|
@@ -100,12 +100,14 @@ export const MAX_REJECTED_PROPOSALS = 3;
|
|
|
100
100
|
/**
|
|
101
101
|
* Reflexion context: the newest reviewer rejections for `ref`. Procedural
|
|
102
102
|
* refusals (expiry, stale target, missing asset) are not judgements on the
|
|
103
|
-
* content and are left out. Reads never create state.db
|
|
103
|
+
* content and are left out. Reads never create state.db, and an improve run's
|
|
104
|
+
* live connection (`eventsCtx.db`) is read through, not copied.
|
|
104
105
|
*/
|
|
105
|
-
export function rejectedProposalContext(stash, ref, ctx) {
|
|
106
|
+
export function rejectedProposalContext(stash, ref, ctx, eventsCtx) {
|
|
106
107
|
if (!ref)
|
|
107
108
|
return [];
|
|
108
|
-
|
|
109
|
+
const proposalsCtx = eventsCtx?.db ? { ...ctx, db: eventsCtx.db } : ctx;
|
|
110
|
+
return listProposalsReadOnly(stash, { ref, status: "rejected", includeArchive: true }, proposalsCtx)
|
|
109
111
|
.filter((p) => !isProceduralRejection(p))
|
|
110
112
|
.sort((a, b) => new Date(b.updatedAt ?? 0).getTime() - new Date(a.updatedAt ?? 0).getTime())
|
|
111
113
|
.slice(0, MAX_REJECTED_PROPOSALS)
|
|
@@ -373,9 +373,13 @@ export function listProposals(stashDir, options = {}, ctx) {
|
|
|
373
373
|
/**
|
|
374
374
|
* {@link listProposals} on a read snapshot that never creates or migrates
|
|
375
375
|
* state.db: prompt building runs before the first dispatch has validated its
|
|
376
|
-
* credentials, and a missing store is simply empty.
|
|
376
|
+
* credentials, and a missing store is simply empty. A caller that already
|
|
377
|
+
* holds a live connection (`ctx.db`) reads through it: a snapshot would copy
|
|
378
|
+
* the whole database for nothing.
|
|
377
379
|
*/
|
|
378
380
|
export function listProposalsReadOnly(stashDir, options = {}, ctx) {
|
|
381
|
+
if (ctx?.db)
|
|
382
|
+
return queryProposals(ctx.db, stashDir, options);
|
|
379
383
|
const dbPath = ctx?.dbPath ?? getStateDbPath();
|
|
380
384
|
if (!fs.existsSync(dbPath))
|
|
381
385
|
return [];
|
|
@@ -52064,6 +52064,7 @@ import os6 from "node:os";
|
|
|
52064
52064
|
import path44 from "node:path";
|
|
52065
52065
|
|
|
52066
52066
|
// src/storage/sqlite-read-snapshot.ts
|
|
52067
|
+
import { spawnSync as spawnSync5 } from "node:child_process";
|
|
52067
52068
|
import fs36 from "node:fs";
|
|
52068
52069
|
import os5 from "node:os";
|
|
52069
52070
|
import path43 from "node:path";
|
|
@@ -52108,6 +52109,26 @@ function fingerprintsEqual(left, right) {
|
|
|
52108
52109
|
const sameFile = (a, b) => a?.size === b?.size && a?.mtimeNs === b?.mtimeNs && a?.ctimeNs === b?.ctimeNs;
|
|
52109
52110
|
return sameFile(left.main, right.main) && sameFile(left.wal, right.wal);
|
|
52110
52111
|
}
|
|
52112
|
+
function copyFileOutsideThisProcess(source, destination) {
|
|
52113
|
+
if (process.platform === "win32") {
|
|
52114
|
+
fs36.copyFileSync(source, destination);
|
|
52115
|
+
return;
|
|
52116
|
+
}
|
|
52117
|
+
const result = spawnSync5("cp", ["--", source, destination], {
|
|
52118
|
+
encoding: "utf8",
|
|
52119
|
+
stdio: ["ignore", "ignore", "pipe"]
|
|
52120
|
+
});
|
|
52121
|
+
if (result.error) {
|
|
52122
|
+
throw new SqliteReadSnapshotUnavailableError(`cannot run cp to copy ${source}: ${result.error.message}`);
|
|
52123
|
+
}
|
|
52124
|
+
if (result.status === 0)
|
|
52125
|
+
return;
|
|
52126
|
+
if (fileFingerprint(source) === undefined) {
|
|
52127
|
+
throw Object.assign(new Error(`${source} disappeared while it was being copied`), { code: "ENOENT" });
|
|
52128
|
+
}
|
|
52129
|
+
const reason = result.stderr.trim() || (result.signal ? `killed by ${result.signal}` : `exit status ${result.status}`);
|
|
52130
|
+
throw new Error(`cp could not copy ${source}: ${reason}`);
|
|
52131
|
+
}
|
|
52111
52132
|
function openSqliteReadSnapshot(dbPath) {
|
|
52112
52133
|
if (!pathExists(dbPath))
|
|
52113
52134
|
return;
|
|
@@ -52123,9 +52144,9 @@ function openSqliteReadSnapshot(dbPath) {
|
|
|
52123
52144
|
if (pathExists(`${dbPath}-journal`))
|
|
52124
52145
|
continue;
|
|
52125
52146
|
const before = databaseFingerprint(dbPath);
|
|
52126
|
-
|
|
52147
|
+
copyFileOutsideThisProcess(dbPath, snapshotPath);
|
|
52127
52148
|
if (before.wal)
|
|
52128
|
-
|
|
52149
|
+
copyFileOutsideThisProcess(`${dbPath}-wal`, `${snapshotPath}-wal`);
|
|
52129
52150
|
else
|
|
52130
52151
|
fs36.rmSync(`${snapshotPath}-wal`, { force: true });
|
|
52131
52152
|
const after = databaseFingerprint(dbPath);
|
|
@@ -52059,6 +52059,7 @@ import os6 from "os";
|
|
|
52059
52059
|
import path44 from "path";
|
|
52060
52060
|
|
|
52061
52061
|
// src/storage/sqlite-read-snapshot.ts
|
|
52062
|
+
import { spawnSync as spawnSync5 } from "child_process";
|
|
52062
52063
|
import fs36 from "fs";
|
|
52063
52064
|
import os5 from "os";
|
|
52064
52065
|
import path43 from "path";
|
|
@@ -52103,6 +52104,26 @@ function fingerprintsEqual(left, right) {
|
|
|
52103
52104
|
const sameFile = (a, b) => a?.size === b?.size && a?.mtimeNs === b?.mtimeNs && a?.ctimeNs === b?.ctimeNs;
|
|
52104
52105
|
return sameFile(left.main, right.main) && sameFile(left.wal, right.wal);
|
|
52105
52106
|
}
|
|
52107
|
+
function copyFileOutsideThisProcess(source, destination) {
|
|
52108
|
+
if (process.platform === "win32") {
|
|
52109
|
+
fs36.copyFileSync(source, destination);
|
|
52110
|
+
return;
|
|
52111
|
+
}
|
|
52112
|
+
const result = spawnSync5("cp", ["--", source, destination], {
|
|
52113
|
+
encoding: "utf8",
|
|
52114
|
+
stdio: ["ignore", "ignore", "pipe"]
|
|
52115
|
+
});
|
|
52116
|
+
if (result.error) {
|
|
52117
|
+
throw new SqliteReadSnapshotUnavailableError(`cannot run cp to copy ${source}: ${result.error.message}`);
|
|
52118
|
+
}
|
|
52119
|
+
if (result.status === 0)
|
|
52120
|
+
return;
|
|
52121
|
+
if (fileFingerprint(source) === undefined) {
|
|
52122
|
+
throw Object.assign(new Error(`${source} disappeared while it was being copied`), { code: "ENOENT" });
|
|
52123
|
+
}
|
|
52124
|
+
const reason = result.stderr.trim() || (result.signal ? `killed by ${result.signal}` : `exit status ${result.status}`);
|
|
52125
|
+
throw new Error(`cp could not copy ${source}: ${reason}`);
|
|
52126
|
+
}
|
|
52106
52127
|
function openSqliteReadSnapshot(dbPath) {
|
|
52107
52128
|
if (!pathExists(dbPath))
|
|
52108
52129
|
return;
|
|
@@ -52118,9 +52139,9 @@ function openSqliteReadSnapshot(dbPath) {
|
|
|
52118
52139
|
if (pathExists(`${dbPath}-journal`))
|
|
52119
52140
|
continue;
|
|
52120
52141
|
const before = databaseFingerprint(dbPath);
|
|
52121
|
-
|
|
52142
|
+
copyFileOutsideThisProcess(dbPath, snapshotPath);
|
|
52122
52143
|
if (before.wal)
|
|
52123
|
-
|
|
52144
|
+
copyFileOutsideThisProcess(`${dbPath}-wal`, `${snapshotPath}-wal`);
|
|
52124
52145
|
else
|
|
52125
52146
|
fs36.rmSync(`${snapshotPath}-wal`, { force: true });
|
|
52126
52147
|
const after = databaseFingerprint(dbPath);
|
|
@@ -9,7 +9,12 @@
|
|
|
9
9
|
* must not do that. This helper copies a stable main/WAL pair and opens that
|
|
10
10
|
* private copy, so SQLite never attaches to the operator's original
|
|
11
11
|
* main/WAL/SHM files.
|
|
12
|
+
*
|
|
13
|
+
* The copy itself is made by a child process, never by this one: see
|
|
14
|
+
* {@link copyFileOutsideThisProcess} for why an in-process read of a live
|
|
15
|
+
* database is unsafe.
|
|
12
16
|
*/
|
|
17
|
+
import { spawnSync } from "node:child_process";
|
|
13
18
|
import fs from "node:fs";
|
|
14
19
|
import os from "node:os";
|
|
15
20
|
import path from "node:path";
|
|
@@ -57,6 +62,45 @@ function fingerprintsEqual(left, right) {
|
|
|
57
62
|
const sameFile = (a, b) => a?.size === b?.size && a?.mtimeNs === b?.mtimeNs && a?.ctimeNs === b?.ctimeNs;
|
|
58
63
|
return sameFile(left.main, right.main) && sameFile(left.wal, right.wal);
|
|
59
64
|
}
|
|
65
|
+
/**
|
|
66
|
+
* Copy `source` to `destination` without opening `source` in THIS process.
|
|
67
|
+
*
|
|
68
|
+
* POSIX advisory locks belong to the process, not to the descriptor: when a
|
|
69
|
+
* process closes ANY descriptor for a file, the kernel drops every lock that
|
|
70
|
+
* process holds on it, including the SHARED lock a live SQLite connection in
|
|
71
|
+
* this same process (`akm improve` keeps one on state.db for the whole run)
|
|
72
|
+
* holds. A peer using an older SQLite (< 3.51) read-write would then see no
|
|
73
|
+
* reader, take EXCLUSIVE when it closes, and delete the `-wal`/`-shm` this
|
|
74
|
+
* process is still using. The read therefore happens in a child `cp`, whose
|
|
75
|
+
* descriptors and locks are its own. Windows locks belong to the handle, so
|
|
76
|
+
* an in-process copy cannot release another handle's lock there (and there is
|
|
77
|
+
* no `cp`).
|
|
78
|
+
*
|
|
79
|
+
* A source that vanished (a WAL checkpointed away mid-copy) throws an
|
|
80
|
+
* `ENOENT` error, which {@link openSqliteReadSnapshot} retries.
|
|
81
|
+
*/
|
|
82
|
+
function copyFileOutsideThisProcess(source, destination) {
|
|
83
|
+
if (process.platform === "win32") {
|
|
84
|
+
fs.copyFileSync(source, destination);
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
const result = spawnSync("cp", ["--", source, destination], {
|
|
88
|
+
encoding: "utf8",
|
|
89
|
+
stdio: ["ignore", "ignore", "pipe"],
|
|
90
|
+
});
|
|
91
|
+
if (result.error) {
|
|
92
|
+
throw new SqliteReadSnapshotUnavailableError(`cannot run cp to copy ${source}: ${result.error.message}`);
|
|
93
|
+
}
|
|
94
|
+
if (result.status === 0)
|
|
95
|
+
return;
|
|
96
|
+
// `cp` reports its errors as text, so the vanished-source case is recognised
|
|
97
|
+
// by looking at the source itself.
|
|
98
|
+
if (fileFingerprint(source) === undefined) {
|
|
99
|
+
throw Object.assign(new Error(`${source} disappeared while it was being copied`), { code: "ENOENT" });
|
|
100
|
+
}
|
|
101
|
+
const reason = result.stderr.trim() || (result.signal ? `killed by ${result.signal}` : `exit status ${result.status}`);
|
|
102
|
+
throw new Error(`cp could not copy ${source}: ${reason}`);
|
|
103
|
+
}
|
|
60
104
|
/**
|
|
61
105
|
* Open an isolated copy of an existing SQLite database.
|
|
62
106
|
*
|
|
@@ -82,9 +126,9 @@ export function openSqliteReadSnapshot(dbPath) {
|
|
|
82
126
|
if (pathExists(`${dbPath}-journal`))
|
|
83
127
|
continue;
|
|
84
128
|
const before = databaseFingerprint(dbPath);
|
|
85
|
-
|
|
129
|
+
copyFileOutsideThisProcess(dbPath, snapshotPath);
|
|
86
130
|
if (before.wal)
|
|
87
|
-
|
|
131
|
+
copyFileOutsideThisProcess(`${dbPath}-wal`, `${snapshotPath}-wal`);
|
|
88
132
|
else
|
|
89
133
|
fs.rmSync(`${snapshotPath}-wal`, { force: true });
|
|
90
134
|
const after = databaseFingerprint(dbPath);
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
*
|
|
8
8
|
* `akm health`'s `state-db-integrity` check (src/commands/health/checks.ts)
|
|
9
9
|
* is a pure projection like every other check, so the actual IO lives here:
|
|
10
|
-
* a read-only `PRAGMA
|
|
10
|
+
* a read-only `PRAGMA integrity_check` and a read-only freelist/page-count read.
|
|
11
11
|
* Both open their own short-lived read-only connection via the plain
|
|
12
12
|
* {@link openDatabase} opener rather than `openStateDatabase`
|
|
13
13
|
* (src/core/state-db.ts), since a corrupt database must not need a clean
|
|
@@ -27,8 +27,8 @@
|
|
|
27
27
|
import { appendEvent } from "../core/events.js";
|
|
28
28
|
import { openDatabase } from "./database.js";
|
|
29
29
|
import { applyReadonlyPragmas } from "./sqlite-pragmas.js";
|
|
30
|
-
/** How many corruption errors `PRAGMA
|
|
31
|
-
const
|
|
30
|
+
/** How many corruption errors `PRAGMA integrity_check` collects before it stops scanning and returns. */
|
|
31
|
+
const INTEGRITY_CHECK_ERROR_LIMIT = 10;
|
|
32
32
|
/**
|
|
33
33
|
* Above this fraction of free pages, `state-db-integrity` warns, and
|
|
34
34
|
* {@link vacuumIfReclaimable} compacts state.db (after improve's retention
|
|
@@ -48,16 +48,19 @@ function openReadonlyStateDb(dbPath) {
|
|
|
48
48
|
return db;
|
|
49
49
|
}
|
|
50
50
|
/**
|
|
51
|
-
* Run `PRAGMA
|
|
52
|
-
*
|
|
53
|
-
*
|
|
54
|
-
*
|
|
51
|
+
* Run `PRAGMA integrity_check(N)` against `dbPath` read-only. Unlike
|
|
52
|
+
* `quick_check`, it also verifies every index against its table ("row N
|
|
53
|
+
* missing from index", "wrong # of entries in index"): the damage a WAL
|
|
54
|
+
* deleted under a live connection leaves behind, which `quick_check` reports
|
|
55
|
+
* as `ok`. Sub-second on a healthy state.db of a few hundred MB; on a corrupt
|
|
56
|
+
* one, `N` bounds how many errors SQLite collects before it stops scanning,
|
|
57
|
+
* which keeps the check's runtime bounded even against a badly corrupt file.
|
|
55
58
|
*/
|
|
56
|
-
export function
|
|
59
|
+
export function runStateDbIntegrityCheck(dbPath) {
|
|
57
60
|
let db;
|
|
58
61
|
try {
|
|
59
62
|
db = openReadonlyStateDb(dbPath);
|
|
60
|
-
const rows = db.prepare(`PRAGMA
|
|
63
|
+
const rows = db.prepare(`PRAGMA integrity_check(${INTEGRITY_CHECK_ERROR_LIMIT})`).all();
|
|
61
64
|
const lines = rows.map((row) => String(firstColumn(row)));
|
|
62
65
|
const ok = lines.length === 1 && lines[0] === "ok";
|
|
63
66
|
return { ok, lines };
|
|
@@ -988,7 +988,7 @@ akm-migrate storage --dry-run
|
|
|
988
988
|
and look for import warnings. You can also inspect `state.db` directly:
|
|
989
989
|
|
|
990
990
|
```sh
|
|
991
|
-
sqlite3 ~/.local/share/akm/state.db 'SELECT COUNT(*) FROM events;'
|
|
991
|
+
sqlite3 -readonly ~/.local/share/akm/state.db 'SELECT COUNT(*) FROM events;'
|
|
992
992
|
```
|
|
993
993
|
|
|
994
994
|
**`akm list` shows empty stashes.**
|
|
@@ -1039,7 +1039,7 @@ If you need to roll back to 0.7.x:
|
|
|
1039
1039
|
wrote new events on 0.8.0 and need them in 0.7.x, export them:
|
|
1040
1040
|
|
|
1041
1041
|
```sh
|
|
1042
|
-
sqlite3 ~/.local/share/akm/state.db \
|
|
1042
|
+
sqlite3 -readonly ~/.local/share/akm/state.db \
|
|
1043
1043
|
"SELECT json_object('schemaVersion', schema_version, 'ts', ts, 'eventType', event_type, 'ref', ref, 'metadata', json(metadata)) FROM events ORDER BY id;" \
|
|
1044
1044
|
>> ~/.cache/akm/events.jsonl
|
|
1045
1045
|
```
|
|
@@ -344,10 +344,13 @@ rm -rf ~/.cache/akm/config-backups/
|
|
|
344
344
|
|
|
345
345
|
# Delete the events log from state.db (non-reversible)
|
|
346
346
|
# There is no akm CLI command to do this directly (`akm log` only exposes
|
|
347
|
-
# `list`/`tail`, no delete/purge verb). Use SQLite directly
|
|
347
|
+
# `list`/`tail`, no delete/purge verb). Use SQLite directly.
|
|
348
|
+
# Stop akm first (no `akm` process or scheduled task running): an older
|
|
349
|
+
# `sqlite3` (< 3.51) opened read-write alongside a running akm can corrupt
|
|
350
|
+
# the database.
|
|
348
351
|
sqlite3 ~/.local/share/akm/state.db "DELETE FROM events;"
|
|
349
352
|
|
|
350
|
-
# Delete all proposals
|
|
353
|
+
# Delete all proposals (same precondition: stop akm first)
|
|
351
354
|
sqlite3 ~/.local/share/akm/state.db "DELETE FROM proposals;"
|
|
352
355
|
```
|
|
353
356
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "akm-cli",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.18",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "akm (Agent Knowledge Manager) — a portable, local-first capability library for AI agents. Discover, load, share, and improve reusable skills, scripts, workflows, and knowledge across any shell-capable coding agent, including Claude Code, OpenCode, and Cursor.",
|
|
6
6
|
"keywords": [
|