fix: align gc dry-run and wal failures

This commit is contained in:
Dr Kennedy Umege
2026-06-26 11:53:44 +01:00
parent 087a0a4be0
commit 1912cf93ab
3 changed files with 121 additions and 43 deletions
+10 -41
View File
@@ -3,9 +3,7 @@ import * as fs from "node:fs/promises";
import * as path from "node:path";
import { gunzipSync, gzipSync } from "node:zlib";
import { getAgentDir, getBlobsDir, getHistoryDbPath, getModelDbPath, getSessionsDir } from "@oh-my-pi/pi-utils";
import { YAML } from "bun";
import { Settings } from "../config/settings";
import { getDefault } from "../config/settings-schema";
import { listSessionsReadOnly, type SessionInfo, type SessionStatus } from "../session/session-listing";
import { FileSessionStorage } from "../session/session-storage";
@@ -129,53 +127,19 @@ interface GcLockSnapshot {
text: string;
}
type RawConfig = Record<string, unknown>;
function numberSetting(value: number | undefined, fallback: number): number {
if (value === undefined || !Number.isFinite(value)) return fallback;
return Math.max(0, Math.floor(value));
}
function rawConfigValue(config: RawConfig, pathKey: string): unknown {
let current: unknown = config;
for (const segment of pathKey.split(".")) {
if (current === null || current === undefined || typeof current !== "object" || Array.isArray(current)) {
return undefined;
}
current = (current as RawConfig)[segment];
}
return current;
}
function booleanConfigValue(config: RawConfig, pathKey: string, fallback: boolean): boolean {
const value = rawConfigValue(config, pathKey);
return typeof value === "boolean" ? value : fallback;
}
function numberConfigValue(config: RawConfig, pathKey: string, fallback: number): number {
const value = rawConfigValue(config, pathKey);
return typeof value === "number" && Number.isFinite(value) ? value : fallback;
}
async function loadConfigReadOnly(agentDir: string): Promise<RawConfig> {
try {
const parsed = YAML.parse(await Bun.file(path.join(agentDir, "config.yml")).text());
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return {};
return parsed as RawConfig;
} catch {
return {};
}
}
async function resolveOptions(flags: GcCommandFlags): Promise<ResolvedGcOptions> {
const agentDir = path.resolve(flags.agentDir ?? getAgentDir());
const selected = flags.blobs === true || flags.archive === true || flags.wal === true;
const config = flags.apply === true ? undefined : await loadConfigReadOnly(agentDir);
const settings = flags.apply === true ? await Settings.init({ agentDir }) : undefined;
const getBoolean = (pathKey: "gc.blobs" | "gc.archive" | "gc.wal") =>
settings?.get(pathKey) ?? booleanConfigValue(config ?? {}, pathKey, getDefault(pathKey));
const settings =
flags.apply === true ? await Settings.init({ agentDir }) : await Settings.loadReadOnly({ agentDir });
const getBoolean = (pathKey: "gc.blobs" | "gc.archive" | "gc.wal") => settings.get(pathKey);
const getNumber = (pathKey: "gc.coldArchiveAfterDays" | "gc.retainNewestGlobal" | "gc.retainNewestPerCwd") =>
settings?.get(pathKey) ?? numberConfigValue(config ?? {}, pathKey, getDefault(pathKey));
settings.get(pathKey);
return {
apply: flags.apply === true,
json: flags.json === true,
@@ -674,10 +638,11 @@ async function checkpointWal(dbPath: string, apply: boolean): Promise<WalCheckpo
if (!apply || !(await pathExists(dbPath))) return result;
const db = new Database(dbPath);
let checkpointAttempted = false;
try {
db.run("PRAGMA busy_timeout = 5000");
const row = db.prepare("PRAGMA wal_checkpoint(TRUNCATE)").get() as WalCheckpointRow | null;
result.checkpointed = true;
checkpointAttempted = true;
result.busy = sqliteNumber(row?.busy);
result.log = sqliteNumber(row?.log);
result.checkpointedFrames = sqliteNumber(row?.checkpointed);
@@ -690,6 +655,10 @@ async function checkpointWal(dbPath: string, apply: boolean): Promise<WalCheckpo
if (codeOf(error) !== "ENOENT") throw error;
result.walBytes = 0;
}
if (checkpointAttempted && (result.busy > 0 || result.walBytes > 0)) {
throw new Error(`WAL checkpoint failed for ${dbPath}: busy=${result.busy}, walBytes=${result.walBytes}`);
}
result.checkpointed = checkpointAttempted;
return result;
}
+25 -1
View File
@@ -61,6 +61,8 @@ export interface SettingsOptions {
agentDir?: string;
/** Don't persist to disk (for tests) */
inMemory?: boolean;
/** Read config sources without opening storage or writing migrations */
readOnly?: boolean;
/** Initial overrides */
overrides?: Partial<Record<SettingPath, unknown>>;
/** Extra config.yml-style overlays loaded after global/project settings */
@@ -228,7 +230,7 @@ export class Settings {
this.#agentDir = path.normalize(options.agentDir ?? getAgentDir());
this.#configPath = options.inMemory ? null : path.join(this.#agentDir, "config.yml");
this.#configFiles = options.configFiles?.map(file => path.resolve(this.#cwd, expandTilde(file))) ?? [];
this.#persist = !options.inMemory;
this.#persist = !options.inMemory && options.readOnly !== true;
if (options.overrides) {
for (const [key, value] of Object.entries(options.overrides)) {
@@ -270,6 +272,15 @@ export class Settings {
);
}
/**
* Load effective settings from config.yml and project providers without
* opening agent.db, migrating legacy settings, or writing marker files.
*/
static loadReadOnly(options: SettingsOptions = {}): Promise<Settings> {
const instance = new Settings({ ...options, readOnly: true });
return instance.#loadReadOnly();
}
/**
* Create an isolated instance for testing.
* Does not affect the global singleton.
@@ -583,6 +594,19 @@ export class Settings {
return this;
}
async #loadReadOnly(): Promise<Settings> {
const projectPromise = this.#loadProjectSettings();
if (this.#configPath) {
this.#global = await this.#loadYaml(this.#configPath);
}
this.#project = await projectPromise;
this.#configOverlay = await this.#loadConfigOverlays();
this.#rebuildMerged();
return this;
}
async #loadYaml(filePath: string): Promise<RawSettings> {
try {
const content = await Bun.file(filePath).text();
+86 -1
View File
@@ -5,7 +5,14 @@ import * as os from "node:os";
import * as path from "node:path";
import { gunzipSync } from "node:zlib";
import { runGcCommand } from "@oh-my-pi/pi-coding-agent/cli/gc-cli";
import { getAgentDir, getBlobsDir, getHistoryDbPath, getSessionsDir, setAgentDir } from "@oh-my-pi/pi-utils";
import {
getAgentDir,
getBlobsDir,
getHistoryDbPath,
getSessionsDir,
setAgentDir,
setProjectDir,
} from "@oh-my-pi/pi-utils";
import { runCli } from "../src/cli";
import { beginSettingsTest, restoreSettingsTestState, type SettingsTestState } from "./helpers/settings-test-state";
@@ -101,6 +108,12 @@ async function writeConfig(agentDir: string, body: string): Promise<void> {
await Bun.write(path.join(agentDir, "config.yml"), body);
}
async function writeProjectConfig(projectDir: string, body: string): Promise<void> {
const configDir = path.join(projectDir, ".omp");
await fs.mkdir(configDir, { recursive: true });
await Bun.write(path.join(configDir, "config.yml"), body);
}
describe("runGcCommand blob sweep", () => {
test("uses the active configured agent dir when --agent-dir is omitted", async () => {
const originalAgentDir = getAgentDir();
@@ -246,6 +259,50 @@ describe("runGcCommand blob sweep", () => {
expect(await Bun.file(path.join(root, "agent.db")).exists()).toBe(false);
expect(await Bun.file(path.join(root, "settings.json.bak")).exists()).toBe(false);
});
test("dry-run merges project gc settings like apply without initializing settings storage", async () => {
const projectRoot = path.join(root, "project-root");
await fs.mkdir(projectRoot, { recursive: true });
setProjectDir(projectRoot);
await writeSession(root, "project", "archive-me", "complete", { ageDays: 10 });
await writeConfig(
root,
[
"gc:",
" blobs: false",
" archive: false",
" wal: false",
" coldArchiveAfterDays: 30",
" retainNewestGlobal: 1",
" retainNewestPerCwd: 1",
"",
].join("\n"),
);
await writeProjectConfig(
projectRoot,
[
"gc:",
" archive: true",
" coldArchiveAfterDays: 7",
" retainNewestGlobal: 0",
" retainNewestPerCwd: 0",
"",
].join("\n"),
);
const dryRun = await runGcCommand({ flags: { agentDir: root } });
expect(dryRun.blobs).toBeUndefined();
expect(dryRun.archive?.wouldArchive).toBe(1);
expect(dryRun.archive?.archived).toBe(0);
expect(dryRun.wal).toBeUndefined();
expect(await Bun.file(path.join(root, "agent.db")).exists()).toBe(false);
expect(await Bun.file(path.join(root, "settings.json.bak")).exists()).toBe(false);
const applied = await runGcCommand({ flags: { agentDir: root, apply: true } });
expect(applied.archive?.archived).toBe(1);
});
});
describe("runGcCommand history checkpoint", () => {
@@ -295,6 +352,34 @@ describe("runGcCommand history checkpoint", () => {
expect(await Bun.file(path.join(root, "gc.lock")).exists()).toBe(false);
});
test("--apply reports busy WAL checkpoints and releases the gc lock", async () => {
const dbPath = getHistoryDbPath(root);
await fs.mkdir(path.dirname(dbPath), { recursive: true });
const writer = new Database(dbPath);
const reader = new Database(dbPath);
try {
writer.run("PRAGMA journal_mode=WAL");
writer.run("CREATE TABLE history (id INTEGER PRIMARY KEY, prompt TEXT)");
writer.run("INSERT INTO history (prompt) VALUES ('before-reader')");
reader.run("PRAGMA journal_mode=WAL");
reader.run("BEGIN");
reader.prepare("SELECT * FROM history").all();
writer.run("INSERT INTO history (prompt) VALUES ('after-reader')");
await expect(runGcCommand({ flags: { agentDir: root, wal: true, apply: true } })).rejects.toThrow(
`WAL checkpoint failed for ${dbPath}: busy=1`,
);
expect(await Bun.file(path.join(root, "gc.lock")).exists()).toBe(false);
} finally {
try {
reader.run("COMMIT");
} catch {}
reader.close();
writer.close();
}
}, 10_000);
});
describe("runGcCommand cold-session archive", () => {