Skip to content
6 changes: 6 additions & 0 deletions .changeset/t13891-one-project-snapshot.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
id: t13891-one-project-snapshot
tasks: [T13891, T13897]
kind: fix
summary: "session-end and pre-op snapshots write one tasks-*.db per run for the unified project cleo.db; brain and conduit are covered by it instead of getting hard links that diverged under per-prefix retention (or full copies where links are unsupported); listBrainBackups and backup verify read the project snapshot, and older brain-*/conduit-* files stay listed; the project cleo.db snapshot is always named tasks-*, whichever prefix the gate admitted"
---
Original file line number Diff line number Diff line change
Expand Up @@ -214,7 +214,7 @@ describe('runBackupVerify (T10319 core SDK)', () => {
expect(result.summary.corrupt).toBeGreaterThanOrEqual(1);
});

it('detects 3-DB mixed fixture: fresh+healthy / stale+healthy / corrupt', () => {
it('detects 3-DB mixed fixture: fresh+healthy / stale brain covered by the project snapshot / corrupt', () => {
// 1. Fresh + healthy: tasks
const now = new Date();
const stampNow = `${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, '0')}${String(now.getDate()).padStart(2, '0')}-${String(now.getHours()).padStart(2, '0')}${String(now.getMinutes()).padStart(2, '0')}${String(now.getSeconds()).padStart(2, '0')}`;
Expand All @@ -241,14 +241,15 @@ describe('runBackupVerify (T10319 core SDK)', () => {
});

expect(result.dbs['tasks']?.verdict).toBe('healthy');
expect(result.dbs['brain']?.verdict).toBe('stale');
// T13891: the fresh tasks-* snapshot is the whole project store, so it is
// brain's freshest snapshot too; the 48h brain-* copy no longer decides.
expect(result.dbs['brain']?.verdict).toBe('healthy');
expect(result.dbs['conduit']?.verdict).toBe('corrupt');

// Summary must add up across all roles.
const { healthy, stale, corrupt, missing } = result.summary;
expect(healthy + stale + corrupt + missing).toBe(DB_INVENTORY.length);
expect(healthy).toBeGreaterThanOrEqual(1);
expect(stale).toBeGreaterThanOrEqual(1);
expect(healthy).toBeGreaterThanOrEqual(2);
expect(corrupt).toBeGreaterThanOrEqual(1);
});

Expand Down
24 changes: 24 additions & 0 deletions packages/core/src/store/__tests__/backup-store-roundtrip.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,30 @@ describe('backups restore onto the live cleo.db (T13245)', () => {
expect(readFileSync(decoy, 'utf8')).toBe('decoy');
});

it.each([
'brain',
'conduit',
])('backup recover %s picks a newer tasks-* project snapshot over an older %s-* file (T13897)', async (role) => {
// An older role-prefixed file (pre-T13891), then the store moves on and a
// run writes the one unified project snapshot as tasks-*.
const older = sessionSnapshot(role, '20261005-090000');
await seedTasks(env.accessor, [
{ id: 'T003', title: 'after the older snapshot', type: 'task' },
]);
const newer = sessionSnapshot('tasks', '20261005-100000');
const backedUp = fingerprint(newer);
expect(backedUp).not.toEqual(fingerprint(older));
await seedTasks(env.accessor, [
{ id: 'T004', title: 'after the newer snapshot', type: 'task' },
]);
resetDbState();

const result = await recoverProjectStore({ role, projectRoot: env.tempDir, cwd: env.tempDir });
expect(result.restored).toBe(true);
expect(result.source.path).toBe(newer);
expect(fingerprint(live)).toEqual(backedUp);
});

it('a corrupt newest snapshot is skipped for the next valid one', async () => {
const older = sessionSnapshot('tasks', '20261005-090000');
const backedUp = fingerprint(older);
Expand Down
26 changes: 26 additions & 0 deletions packages/core/src/store/__tests__/recover-brain-db.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,32 @@ describe('recoverMalformedBrainDb (T10303)', () => {
expect(result.observationsRecovered).toBe(9);
});

it('picks a newer tasks-* project snapshot over an older brain-* one (T13897)', () => {
const root = mkdtempSync(join(tmpdir(), 'cleo-test-recover-tasks-prefix-'));
const cleoDir = join(root, '.cleo');
const vacuumDir = join(cleoDir, 'backups', 'sqlite');
const snapshotDir = join(cleoDir, 'backups', 'snapshot');
const corruptPath = join(cleoDir, 'brain.db');
mkdirSync(vacuumDir, { recursive: true });
mkdirSync(snapshotDir, { recursive: true });

seedHealthyBrainDb(join(vacuumDir, 'brain-20260523-120000.db'), 2);
const newer = join(vacuumDir, 'tasks-20260523-131622.db');
seedHealthyBrainDb(newer, 5);
writeCorruptFile(corruptPath);

const result = recoverMalformedBrainDb({
corruptPath,
snapshotDir,
vacuumSnapshotDir: vacuumDir,
logger: makeLogger(),
});

expect(result.integrityOK).toBe(true);
expect(result.restoredFrom).toBe(newer);
expect(result.observationsRecovered).toBe(5);
});

it('uses legacy PRE-DUP-FIX artifacts as a last-resort fallback', () => {
const root = mkdtempSync(join(tmpdir(), 'cleo-test-recover-predup-'));
const cleoDir = join(root, '.cleo');
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,11 @@
* 3. Invokes `vacuumIntoBackupAll({ cwd: tempDir })`.
* 4. Exits cleanly.
*
* The parent process then asserts that a `brain-YYYYMMDD-HHmmss.db` file
* landed in `<tempDir>/.cleo/backups/sqlite/`. Before the T10316 fix the
* fast-path returned `null` and snapshotOne silently skipped brain — so
* the only artifacts were `tasks-*.db` snapshots.
* The parent process then asserts the snapshot landed in
* `<tempDir>/.cleo/backups/sqlite/`. Before the T10316 fix the fast-path
* returned `null` and snapshotOne silently skipped brain. Since T13891 brain
* lives in the unified project cleo.db, so the run writes ONE `tasks-*.db`
* snapshot that holds it, and no separate `brain-*.db` copy.
*
* This test deliberately bypasses vitest mocks because the bug surfaces only
* in a real process where module-singleton caches are empty. The mock-based
Expand All @@ -35,6 +36,7 @@ import { tmpdir } from 'node:os';
import { dirname, join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { collectSnapshotCandidatesForRole } from '../recover-malformed-db.js';

const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
Expand Down Expand Up @@ -64,7 +66,7 @@ describe('sqlite-backup real-process (T10316)', () => {
}
});

it('produces brain-YYYYMMDD-HHmmss.db even when no caller pre-opened brain.db', () => {
it('covers brain in the one project snapshot even when no caller pre-opened brain.db (T10316, T13891)', () => {
// The compiled dist MUST exist — the test depends on the real module
// graph (no vitest module mocks). Builds run before tests via the
// monorepo workflow; a missing dist is a clear signal to rebuild.
Expand Down Expand Up @@ -121,12 +123,17 @@ describe('sqlite-backup real-process (T10316)', () => {
const brainSnapshots = files.filter((f) => /^brain-\d{8}-\d{6}\.db$/.test(f));
const tasksSnapshots = files.filter((f) => /^tasks-\d{8}-\d{6}\.db$/.test(f));

// Headline regression check: at LEAST one brain-*.db snapshot file.
// Pre-fix, this count was 0 in production despite tasks-*.db being healthy.
expect(brainSnapshots.length).toBeGreaterThanOrEqual(1);
// T13891: brain lives in the unified project cleo.db, so the run writes
// ONE project snapshot (tasks-*) that holds the brain tables, and no
// separate brain-*.db or conduit-*.db copy.
expect(tasksSnapshots).toHaveLength(1);
expect(brainSnapshots).toHaveLength(0);
expect(files.filter((f) => /^conduit-\d{8}-\d{6}\.db$/.test(f))).toHaveLength(0);

// Sanity: tasks snapshot should also exist — confirms the eager-open
// contract holds for the previously-working path too.
expect(tasksSnapshots.length).toBeGreaterThanOrEqual(1);
// Recovery finds that snapshot for brain and conduit (T13897).
for (const role of ['brain', 'conduit'] as const) {
const candidates = collectSnapshotCandidatesForRole({ role, vacuumSnapshotDir: backupDir });
expect(candidates.map((c) => c.path)).toEqual([join(backupDir, tasksSnapshots[0] ?? '')]);
}
});
});
93 changes: 75 additions & 18 deletions packages/core/src/store/__tests__/sqlite-backup.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
* @epic T4867
*/

import { chmodSync, mkdirSync, readdirSync, statSync, utimesSync, writeFileSync } from 'node:fs';
import { chmodSync, mkdirSync, readdirSync, utimesSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { DatabaseSync } from 'node:sqlite';
Expand Down Expand Up @@ -415,7 +415,13 @@ describe('sqlite-backup', () => {
'tasks-20260102-120000.db',
'tasks-20260101-120000.db',
]);
expect(brainList.map((e) => e.name)).toEqual(['brain-20260103-120000.db']);
// T13891: brain lives in the unified store, so its listing holds the
// project snapshots (tasks-*) plus older brain-* files, newest first.
expect(brainList.map((e) => e.name)).toEqual([
'brain-20260103-120000.db',
'tasks-20260102-120000.db',
'tasks-20260101-120000.db',
]);
// T10317: every project-tier + derived inventory row now contributes a
// bucket to listSqliteBackupsAll(). Empty buckets surface as `[]`.
expect(Object.keys(all).sort()).toEqual(
Expand Down Expand Up @@ -599,11 +605,12 @@ describe('sqlite-backup', () => {
});

/**
* T12508 #2: tasks, brain and conduit share ONE cleo.db handle. A run must
* VACUUM it once and hard-link the same file under the other prefixes, so
* every `<prefix>-*.db` reader still finds a file at no extra disk cost.
* T12508 #2 / T13891: tasks, brain and conduit share ONE cleo.db handle. A
* run VACUUMs it once and writes ONE project snapshot; brain and conduit are
* covered by that file (no hard link, no copy), so a session end costs one
* copy of the store, not three under per-prefix retention.
*/
it('one VACUUM per physical database file; other prefixes are hard links (T12508)', async () => {
it('one project snapshot per run for the unified store; brain and conduit are covered by it (T12508, T13891)', async () => {
const tempDir = join(tmpdir(), `cleo-t12508-dedupe-${Date.now()}`);
const backupDir = join(tempDir, 'backups', 'sqlite');
mkdirSync(tempDir, { recursive: true });
Expand Down Expand Up @@ -639,17 +646,68 @@ describe('sqlite-backup', () => {
expect(r?.snapshotted).toEqual(['tasks', 'brain', 'conduit']);
expect(r?.linked).toEqual(['brain', 'conduit']);
const files = readdirSync(backupDir).filter((f) => /^(tasks|brain|conduit)-/.test(f));
expect(files).toHaveLength(3);
const inodes = new Set(files.map((f) => statSync(join(backupDir, f)).ino));
expect(inodes.size).toBe(1);
expect(files).toHaveLength(1);
expect(files[0]).toMatch(/^tasks-\d{8}-\d{6}\.db$/);
});

it('a debounced tasks prefix still files the unified store as tasks-*, never brain-* (T13891)', async () => {
const tempDir = join(tmpdir(), `cleo-t13910-mixed-${Date.now()}`);
const backupDir = join(tempDir, 'backups', 'sqlite');
mkdirSync(tempDir, { recursive: true });
const shared = new DatabaseSync(join(tempDir, 'cleo.db'));
shared.exec('CREATE TABLE tasks_schema_meta (key TEXT PRIMARY KEY, value TEXT NOT NULL)');
vi.doMock('../sqlite.js', () => ({ getNativeDb: () => shared, getDb: async () => null }));
vi.doMock('../memory-sqlite.js', () => ({
getBrainNativeDb: () => shared,
getBrainDb: async () => null,
}));
vi.doMock('../conduit-sqlite.js', () => ({
getConduitNativeDb: () => shared,
ensureConduitDb: () => ({ action: 'exists', path: '' }),
}));
stubOtherChokepointOpeners();
vi.doMock('../../paths.js', () => ({
getCleoDir: () => tempDir,
getCleoHome: () => tempDir,
resolveOrCwd: (cwd?: string) => cwd ?? tempDir,
}));

const { vacuumIntoBackup, vacuumIntoBackupAll } = await import('../sqlite-backup.js');
// A tasks-only snapshot refreshes the tasks debounce; the next all-snapshot
// run admits only brain and conduit.
await vacuumIntoBackup();
const [first] = readdirSync(backupDir).filter((f) => /^tasks-.*\.db$/.test(f));
expect(first).toBeDefined();
// Data written after the first snapshot must be in the brain-admitted one.
shared.exec("INSERT INTO tasks_schema_meta (key, value) VALUES ('t13891-marker', 'latest')");
const r = await vacuumIntoBackupAll();
shared.close();

expect(r?.snapshotted).not.toContain('tasks');
expect(r?.snapshotted).toContain('brain');
// Same-bucket retention may drop the first snapshot; never a brain/conduit file.
const files = readdirSync(backupDir).filter((f) => /^(tasks|brain|conduit)-.*\.db$/.test(f));
for (const f of files) expect(f).toMatch(/^tasks-\d{8}-\d{6}\.db$/);
const latest = files
.filter((f) => f !== first)
.sort()
.at(-1);
expect(latest).toBeDefined();
const snap = new DatabaseSync(join(backupDir, latest ?? ''), { readOnly: true });
const row = snap
.prepare("SELECT value FROM tasks_schema_meta WHERE key = 't13891-marker'")
.get() as { value: string } | undefined;
snap.close();
expect(row?.value).toBe('latest');
});

/**
* T12508: on a filesystem without hard links (exFAT → ENOTSUP) the shared
* file is COPIED under the other prefixes instead — still one VACUUM, and
* `brain-*` / `conduit-*` still exist for restore and recovery.
* T13891: on a filesystem without hard links (exFAT → ENOTSUP) the old
* design COPIED the shared file under brain and conduit (three full copies
* per session end). Now nothing is linked or copied: one file, and
* `listBrainBackups` finds the brain tables in that project snapshot.
*/
it('falls back to a copy when hard links are unsupported (T12508)', async () => {
it('writes one project snapshot, never a copy, where hard links are unsupported (T13891)', async () => {
const tempDir = join(tmpdir(), `cleo-t12508-nolink-${Date.now()}`);
const backupDir = join(tempDir, 'backups', 'sqlite');
mkdirSync(tempDir, { recursive: true });
Expand Down Expand Up @@ -696,13 +754,12 @@ describe('sqlite-backup', () => {

expect(vacuums).toBe(1);
expect(r?.failed).toEqual([]);
expect(r?.linked).toEqual([]);
expect(r?.linked).toEqual(['brain', 'conduit']);
expect(r?.snapshotted).toEqual(['tasks', 'brain', 'conduit']);
const files = readdirSync(backupDir).filter((f) => /^(tasks|brain|conduit)-.*\.db$/.test(f));
expect(files).toHaveLength(3);
// Three separate files (copies), and brain recovery finds its prefix.
expect(new Set(files.map((f) => statSync(join(backupDir, f)).ino)).size).toBe(3);
expect(brainList).toHaveLength(1);
expect(files).toHaveLength(1);
// The one project snapshot is what the brain listing returns.
expect(brainList.map((e) => e.name)).toEqual(files);
});

/**
Expand Down
13 changes: 8 additions & 5 deletions packages/core/src/store/backup-recover.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ import {
recoverMalformedDb,
resolveRoleBackupDirs,
resolveRoleDbPath,
vacuumSnapshotPrefixes,
} from './recover-malformed-db.js';
import { ensureTriggerSuspendTableAtPath } from './sync/trigger-suspend-at-path.js';
import {
Expand Down Expand Up @@ -215,11 +216,13 @@ function snapshotMatchesPin(role: DbRole, candidate: SnapshotCandidate, pin: str
);
if (iso.startsWith(pin)) return true;
}
// Vacuum snapshot: `<role>-20260523-130026.db`.
const vacuumPrefix = `${role}-`;
if (name.startsWith(vacuumPrefix) && name.endsWith('.db')) {
const tail = name.slice(vacuumPrefix.length, -3); // strip ".db"
if (tail.startsWith(pin)) return true;
// Vacuum snapshot: `<prefix>-20260523-130026.db` (`tasks-*` also holds brain and conduit, T13897).
for (const prefix of vacuumSnapshotPrefixes(role)) {
const vacuumPrefix = `${prefix}-`;
if (name.startsWith(vacuumPrefix) && name.endsWith('.db')) {
const tail = name.slice(vacuumPrefix.length, -3); // strip ".db"
if (tail.startsWith(pin)) return true;
}
}
return false;
}
Expand Down
7 changes: 5 additions & 2 deletions packages/core/src/store/backup-verify.ts
Original file line number Diff line number Diff line change
Expand Up @@ -141,8 +141,11 @@ function buildSnapshotPatterns(role: string): readonly RegExp[] {
new RegExp(`^${safe}\\.db\\.[A-Za-z0-9_-]+-\\d{8}-\\d{6}$`),
new RegExp(`^${safe}\\.db\\.snapshot-\\d{4}-\\d{2}-\\d{2}T\\d{2}-\\d{2}-\\d{2}-\\d{3}Z$`),
// T13245: `createBackup` writes ONE `cleo.db.<backupId>` copy of the
// project store, which holds the tasks, brain and conduit tables.
...(PROJECT_STORE_LABEL_ROLES.has(role) ? [/^cleo\.db\.[A-Za-z0-9_-]+-\d{8}-\d{6}$/] : []),
// project store, which holds the tasks, brain and conduit tables. T13891:
// `vacuumIntoBackupAll` likewise writes one `tasks-*.db` for all three.
...(PROJECT_STORE_LABEL_ROLES.has(role)
? [/^cleo\.db\.[A-Za-z0-9_-]+-\d{8}-\d{6}$/, /^tasks-\d{8}-\d{6}\.db$/]
: []),
];
}

Expand Down
6 changes: 4 additions & 2 deletions packages/core/src/store/recover-brain-db.ts
Original file line number Diff line number Diff line change
Expand Up @@ -138,9 +138,11 @@ const SNAPSHOT_FILENAME_RE = /^brain\.db\.snapshot-(\d{4}-\d{2}-\d{2}T\d{2}-\d{2

/**
* Snapshot filename pattern for VACUUM INTO debounced session-end snapshots:
* `brain-20260523-130026.db`. Format mirrors `sqlite-backup.ts:formatTimestamp`.
* `brain-20260523-130026.db`, and `tasks-*.db`: brain lives in the unified
* project store, whose one snapshot per run carries the tasks prefix
* (T13891, T13897). Format mirrors `sqlite-backup.ts:formatTimestamp`.
*/
const VACUUM_FILENAME_RE = /^brain-(\d{8})-(\d{6})\.db$/;
const VACUUM_FILENAME_RE = /^(?:brain|tasks)-(\d{8})-(\d{6})\.db$/;

/** Legacy pre-T9685 dup-fix backup pattern: `brain.db.PRE-DUP-FIX-191315`. */
const PRE_DUP_FIX_RE = /^brain\.db\.PRE-DUP-FIX-/;
Expand Down
19 changes: 17 additions & 2 deletions packages/core/src/store/recover-malformed-db.ts
Original file line number Diff line number Diff line change
Expand Up @@ -255,14 +255,29 @@ function buildSystemSnapshotRegex(role: DbRole): RegExp {
);
}

/**
* VACUUM INTO filename prefixes that hold a role's data. Brain and conduit
* live in the unified project `cleo.db`, whose one snapshot per run is
* `tasks-*.db` (T13891); older runs also wrote `brain-*` / `conduit-*`.
*
* @param role - Inventory role.
* @returns The prefixes whose `<prefix>-YYYYMMDD-HHmmss.db` files hold it.
* @task T13897
*/
export function vacuumSnapshotPrefixes(role: DbRole): readonly string[] {
return role === 'brain' || role === 'conduit' ? [role, 'tasks'] : [role];
}

/**
* Build the per-role VACUUM-INTO snapshot filename regex (matches
* `<role>-20260523-130026.db`).
* `<prefix>-20260523-130026.db` for every {@link vacuumSnapshotPrefixes}
* prefix, so the freshest is picked across them).
*
* @internal
*/
function buildVacuumSnapshotRegex(role: DbRole): RegExp {
return new RegExp(`^${escapeRegExpLiteral(role)}-(\\d{8})-(\\d{6})\\.db$`);
const prefixes = vacuumSnapshotPrefixes(role).map(escapeRegExpLiteral).join('|');
return new RegExp(`^(?:${prefixes})-(\\d{8})-(\\d{6})\\.db$`);
}

/**
Expand Down
Loading
Loading