feat(FN-3173): add SQLite WAL tuning, integrity checks, and agent log batch

This merge brings FN-3173's SQLite stability improvements: WAL tuning pragmas for better concurrency, periodic integrity checks with self-healing recovery, and batched agent log writes to reduce I/O overhead. It also includes a new cron-runner for scheduled maintenance tasks, TUI mouse wheel scrolli

Fusion-Task-Id: FN-3173
This commit is contained in:
Fusion
2026-05-01 23:26:14 -07:00
committed by gsxdsm
parent 3fdeac1fc4
commit a284138167
10 changed files with 326 additions and 77 deletions

View File

@@ -51,6 +51,29 @@ describe("Database", () => {
expect(row.foreign_keys).toBe(1);
});
it("sets WAL tuning pragmas for disk-backed databases", () => {
const synchronous = db.prepare("PRAGMA synchronous").get() as { synchronous: number };
const autoCheckpoint = db.prepare("PRAGMA wal_autocheckpoint").get() as { wal_autocheckpoint: number };
const journalSizeLimit = db.prepare("PRAGMA journal_size_limit").get() as { journal_size_limit: number };
expect(synchronous.synchronous).toBe(1); // NORMAL
expect(autoCheckpoint.wal_autocheckpoint).toBe(100);
expect(journalSizeLimit.journal_size_limit).toBe(4_194_304);
});
it("does not force WAL tuning pragmas for in-memory databases", () => {
const memDb = new Database(fusionDir, { inMemory: true });
memDb.init();
const autoCheckpoint = memDb.prepare("PRAGMA wal_autocheckpoint").get() as { wal_autocheckpoint: number };
const journalSizeLimit = memDb.prepare("PRAGMA journal_size_limit").get() as { journal_size_limit: number };
expect(autoCheckpoint.wal_autocheckpoint).toBe(1000);
expect(journalSizeLimit.journal_size_limit).toBe(-1);
memDb.close();
});
it("creates all expected tables", () => {
const tables = db.prepare(
"SELECT name FROM sqlite_master WHERE type='table' ORDER BY name"
@@ -409,6 +432,28 @@ describe("Database", () => {
});
});
describe("integrity check", () => {
it("returns ok for healthy databases and leaves corruption flag false", () => {
expect(db.corruptionDetected).toBe(false);
expect(db.integrityCheck()).toEqual({ ok: true });
});
it("keeps corruptionDetected false after init for healthy database", () => {
const diskDb = new Database(fusionDir);
diskDb.init();
expect(diskDb.corruptionDetected).toBe(false);
diskDb.close();
});
it("skips integrity check side effects for in-memory databases", () => {
const memDb = new Database(fusionDir, { inMemory: true });
memDb.init();
expect(memDb.integrityCheck()).toEqual({ ok: true });
expect(memDb.corruptionDetected).toBe(false);
memDb.close();
});
});
describe("foreign key cascade across reopen", () => {
it("cascade delete works after closing and reopening the database", () => {
const now = new Date().toISOString();

View File

@@ -4141,6 +4141,31 @@ Task with acceptance criteria
expect(events[0].taskId).toBe(task.id);
});
it("appendAgentLogBatch inserts all entries and emits per-entry events", async () => {
const task = await createTestTask();
const events: any[] = [];
store.on("agent:log", (entry) => events.push(entry));
await store.appendAgentLogBatch([
{ taskId: task.id, text: "batch 1", type: "text" },
{ taskId: task.id, text: "tool", type: "tool", detail: "read file", agent: "executor" },
]);
const logs = await store.getAgentLogs(task.id);
expect(logs).toHaveLength(2);
expect(logs.map((entry) => entry.text)).toEqual(["batch 1", "tool"]);
expect(events).toHaveLength(2);
expect(events[1]).toMatchObject({ text: "tool", type: "tool", detail: "read file", agent: "executor" });
});
it("appendAgentLogBatch with empty entries is a no-op", async () => {
const task = await createTestTask();
await store.appendAgentLogBatch([]);
expect(await store.getAgentLogCount(task.id)).toBe(0);
});
it("appendAgentLog writes detail when provided", async () => {
const task = await createTestTask();

View File

@@ -11,6 +11,7 @@
import { DatabaseSync } from "./sqlite-adapter.js";
import { isAbsolute, join } from "node:path";
import { mkdirSync, existsSync } from "node:fs";
import { spawnSync } from "node:child_process";
import { DEFAULT_PROJECT_SETTINGS } from "./types.js";
import type { SteeringComment, TaskComment } from "./types.js";
@@ -695,6 +696,8 @@ CREATE INDEX IF NOT EXISTS idxTodoItemsSortOrder ON todo_items(listId, sortOrder
export class Database {
private db: DatabaseSync;
private readonly dbPath: string;
private readonly inMemory: boolean;
corruptionDetected = false;
/** Tracks transaction nesting depth for savepoint-based nested transactions. */
private transactionDepth = 0;
private readonly _fts5Available: boolean;
@@ -707,6 +710,7 @@ export class Database {
// never sets this — it's plumbed through TaskStore for tests that
// don't need cross-instance persistence.
const inMemory = options?.inMemory === true;
this.inMemory = inMemory;
this.dbPath = inMemory ? ":memory:" : join(fusionDir, "fusion.db");
if (!inMemory && !isAbsolute(fusionDir)) {
@@ -741,14 +745,23 @@ export class Database {
}
// WAL is meaningless for `:memory:` connections — SQLite ignores it
// and there's no other writer to coordinate with — so we skip it. The
// remaining pragmas apply uniformly.
// and there's no other writer to coordinate with — so we skip WAL-only
// tuning there.
if (!inMemory) {
// Enable WAL mode for concurrent reader/writer access
this.db.exec("PRAGMA journal_mode = WAL");
// Wait up to 5s for locks to clear before returning SQLITE_BUSY
this.db.exec("PRAGMA busy_timeout = 5000");
// In WAL mode NORMAL is nearly as durable as FULL with much lower fsync cost.
this.db.exec("PRAGMA synchronous = NORMAL");
// Checkpoint aggressively to avoid large WAL growth under bursty writes.
this.db.exec("PRAGMA wal_autocheckpoint = 100");
// Bound WAL growth between checkpoints/maintenance cycles.
this.db.exec("PRAGMA journal_size_limit = 4194304");
} else {
// Wait up to 5s for locks to clear before returning SQLITE_BUSY
this.db.exec("PRAGMA busy_timeout = 5000");
}
// Wait up to 5s for locks to clear before returning SQLITE_BUSY
this.db.exec("PRAGMA busy_timeout = 5000");
// Enable foreign key enforcement
this.db.exec("PRAGMA foreign_keys = ON");
@@ -845,6 +858,47 @@ export class Database {
}
}
integrityCheck(): { ok: true } | { ok: false; errors: string[] } {
if (this.inMemory) {
return { ok: true };
}
const rows = this.db
.prepare("PRAGMA integrity_check(100)")
.all() as Array<Record<string, unknown>>;
const errors = rows
.map((row) => row.integrity_check)
.filter((value): value is string => typeof value === "string" && value !== "ok");
if (errors.length > 0) {
return { ok: false, errors };
}
return { ok: true };
}
recoverDatabase(outputPath: string): boolean {
if (this.inMemory) {
return false;
}
const recoveredSql = spawnSync("sqlite3", ["-cmd", ".recover main", this.dbPath], {
encoding: "utf-8",
maxBuffer: 50 * 1024 * 1024,
});
if (recoveredSql.status !== 0 || !recoveredSql.stdout) {
return false;
}
const rebuilt = spawnSync("sqlite3", [outputPath], {
input: recoveredSql.stdout,
encoding: "utf-8",
maxBuffer: 50 * 1024 * 1024,
});
return rebuilt.status === 0;
}
/**
* Initialize the database: create tables if they don't exist
* and seed meta values.
@@ -871,6 +925,12 @@ export class Database {
this.db.exec(
`INSERT OR IGNORE INTO config (id, nextId, nextWorkflowStepId, settings, workflowSteps, updatedAt) VALUES (1, 1, 1, '${JSON.stringify(DEFAULT_PROJECT_SETTINGS)}', '[]', '${configNow}')`,
);
const integrity = this.integrityCheck();
if (!integrity.ok) {
this.corruptionDetected = true;
console.warn("[fusion:db] Database integrity check FAILED — corruption detected");
}
}
/**

View File

@@ -10,7 +10,7 @@
* @module migration
*/
import { existsSync, readFileSync } from "node:fs";
import { existsSync, readFileSync, realpathSync } from "node:fs";
import { homedir, tmpdir } from "node:os";
import { isAbsolute, join, resolve, basename, dirname } from "node:path";
import type { CentralCore } from "./central-core.js";
@@ -191,17 +191,45 @@ export class FirstRunDetector {
const visited = new Set<string>();
let current = resolve(startDir);
const home = getHomeDir();
const home = resolve(getHomeDir());
const root = dirname(current) === current ? current : "/"; // Handle Windows vs Unix root
// Also stop at the OS temp directory — it is a shared system boundary and
// should never itself host a project; stopping here prevents the walk from
// picking up stale .fusion/ directories left by other processes in /tmp.
const systemTmp = resolve(tmpdir());
while (current !== home && current !== root && current !== systemTmp) {
const normalizePath = (path: string): string => {
try {
return realpathSync(path);
} catch {
return resolve(path);
}
};
const normalizedHome = normalizePath(home);
const normalizedSystemTmp = normalizePath(systemTmp);
const normalizedStartDir = normalizePath(current);
while (true) {
if (visited.has(current)) break;
visited.add(current);
const normalizedCurrent = normalizePath(current);
const isRootBoundary = current === root;
const isHomeBoundary = normalizedCurrent === normalizedHome;
const isTmpBoundary = normalizedCurrent === normalizedSystemTmp;
const isStopBoundary = isRootBoundary || isHomeBoundary || isTmpBoundary;
// Never inspect root/tmp boundaries. Home is checked only when it is the
// explicit starting directory (covers cwd===home tests).
if (
(isRootBoundary || isTmpBoundary) ||
(isHomeBoundary && normalizedCurrent !== normalizedStartDir)
) {
break;
}
if (this.hasFusionProject(current)) {
const name = await this.generateProjectName(current);
projects.push({
@@ -213,6 +241,10 @@ export class FirstRunDetector {
break;
}
if (isStopBoundary) {
break;
}
const parent = dirname(current);
if (parent === current) break;
current = parent;

View File

@@ -204,7 +204,8 @@ export const DEFAULT_PROJECT_SETTINGS = {
maxPostReviewFixes: 1,
maxSpawnedAgentsPerParent: 5,
maxSpawnedAgentsGlobal: 20,
maintenanceIntervalMs: 900_000,
// Run maintenance (including WAL checkpointing) every 5 minutes by default.
maintenanceIntervalMs: 300_000,
autoArchiveDoneTasksEnabled: true,
autoArchiveDoneAfterMs: 48 * 60 * 60 * 1000,
archiveAgentLogMode: "compact",

View File

@@ -4525,6 +4525,51 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
this.emit("agent:log", entry);
}
async appendAgentLogBatch(
entries: Array<{
taskId: string;
text: string;
type: AgentLogEntry["type"];
detail?: string;
agent?: AgentLogEntry["agent"];
}>,
): Promise<void> {
if (entries.length === 0) {
return;
}
const timestamp = new Date().toISOString();
const stmt = this.db.prepare(`
INSERT INTO agentLogEntries (taskId, timestamp, text, type, detail, agent)
VALUES (?, ?, ?, ?, ?, ?)
`);
this.db.transaction(() => {
for (const entry of entries) {
stmt.run(
entry.taskId,
timestamp,
entry.text,
entry.type,
entry.detail ?? null,
entry.agent ?? null,
);
}
});
this.db.bumpLastModified();
for (const entry of entries) {
this.emit("agent:log", {
timestamp,
taskId: entry.taskId,
text: entry.text,
type: entry.type,
...(entry.detail !== undefined && { detail: entry.detail }),
...(entry.agent !== undefined && { agent: entry.agent }),
});
}
}
private mapAgentLogRow(row: Record<string, unknown>): AgentLogEntry {
return {
timestamp: row.timestamp as string,