From 3a33b15d4cb9459022dd669e54dd60c69d24d1f9 Mon Sep 17 00:00:00 2001 From: Keaton Hoskins Date: Sat, 17 Oct 2026 19:17:45 -0700 Subject: [PATCH 1/7] fix(T13891): one project snapshot per run for the unified cleo.db tasks, brain and conduit share the project cleo.db, and vacuumIntoBackupAll VACUUMed it once but then hard-linked the file under brain-* and conduit-* (a full copy where hard links are unsupported). Each prefix rotates on its own schedule, so the links diverged: on the cleocode store four old brain/conduit pairs (5.2 GB) outlived their tasks-* twins and were never pruned. Now the first target on the file writes the one tasks-*.db snapshot and the others report 'linked' (covered) without writing a file. listBrainBackups and backup verify treat the project snapshot as holding brain and conduit; older brain-*/conduit-* files stay listed. Tests assert one project snapshot per run, including where hard links are unsupported. Co-Authored-By: Claude Opus 5.5 --- .changeset/t13891-one-project-snapshot.md | 6 ++ .../sqlite-backup-real-process.test.ts | 24 ++--- .../src/store/__tests__/sqlite-backup.test.ts | 42 +++++---- packages/core/src/store/backup-verify.ts | 7 +- packages/core/src/store/sqlite-backup.ts | 89 ++++++------------- packages/core/src/system/backup.ts | 5 +- 6 files changed, 75 insertions(+), 98 deletions(-) create mode 100644 .changeset/t13891-one-project-snapshot.md diff --git a/.changeset/t13891-one-project-snapshot.md b/.changeset/t13891-one-project-snapshot.md new file mode 100644 index 0000000000..1a4fb57ac6 --- /dev/null +++ b/.changeset/t13891-one-project-snapshot.md @@ -0,0 +1,6 @@ +--- +id: t13891-one-project-snapshot +tasks: [T13891] +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" +--- diff --git a/packages/core/src/store/__tests__/sqlite-backup-real-process.test.ts b/packages/core/src/store/__tests__/sqlite-backup-real-process.test.ts index 2fba57c3b7..d684fcc0fc 100644 --- a/packages/core/src/store/__tests__/sqlite-backup-real-process.test.ts +++ b/packages/core/src/store/__tests__/sqlite-backup-real-process.test.ts @@ -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 `/.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 + * `/.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 @@ -64,7 +65,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. @@ -121,12 +122,11 @@ 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); - - // Sanity: tasks snapshot should also exist — confirms the eager-open - // contract holds for the previously-working path too. - expect(tasksSnapshots.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); }); }); diff --git a/packages/core/src/store/__tests__/sqlite-backup.test.ts b/packages/core/src/store/__tests__/sqlite-backup.test.ts index a27fcbb245..f52979cfb5 100644 --- a/packages/core/src/store/__tests__/sqlite-backup.test.ts +++ b/packages/core/src/store/__tests__/sqlite-backup.test.ts @@ -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'; @@ -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( @@ -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 `-*.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 }); @@ -639,17 +646,17 @@ 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$/); }); /** - * 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 }); @@ -696,13 +703,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); }); /** diff --git a/packages/core/src/store/backup-verify.ts b/packages/core/src/store/backup-verify.ts index 4ccde5fe33..b08db370f4 100644 --- a/packages/core/src/store/backup-verify.ts +++ b/packages/core/src/store/backup-verify.ts @@ -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.` 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$/] + : []), ]; } diff --git a/packages/core/src/store/sqlite-backup.ts b/packages/core/src/store/sqlite-backup.ts index 159001925b..6721240923 100644 --- a/packages/core/src/store/sqlite-backup.ts +++ b/packages/core/src/store/sqlite-backup.ts @@ -47,7 +47,6 @@ import { copyFileSync, existsSync, fsyncSync, - linkSync, mkdirSync, openSync, readdirSync, @@ -700,9 +699,9 @@ async function snapshotProjectTargetsGated( ); if (byPrefix.size === 0) return null; - // One VACUUM per physical database file per run (T12508): tasks, brain and - // conduit share the project cleo.db handle. Maps source realpath → the - // snapshot file written for it in this run. + // One snapshot per physical database file per run (T12508, T13891): tasks, + // brain and conduit share the project cleo.db handle, so only the first of + // them writes a file. Maps source realpath → the snapshot written for it. const writtenBySource = new Map(); return runGatedSnapshot( { @@ -768,32 +767,6 @@ function assertOwnedByProject(source: string, prefix: string, cwd?: string): voi } } -/** - * `link()` error codes that mean "this filesystem cannot hard-link these - * files" rather than a real failure: permission-less link (EPERM), no link - * support (ENOTSUP, e.g. exFAT), different devices (EXDEV), link-count cap - * (EMLINK). The caller copies instead. - */ -const LINK_FALLBACK_CODES = new Set(['EPERM', 'ENOTSUP', 'EXDEV', 'EMLINK']); - -/** - * Hard-link `existing` to `dest`. Returns `false` when the filesystem cannot - * hard-link (see {@link LINK_FALLBACK_CODES}); any other error throws. - * - * @task T12508 - */ -function tryHardLink(existing: string, dest: string): boolean { - try { - linkSync(existing, dest); - return true; - } catch (err) { - if (err instanceof Error && 'code' in err && typeof err.code === 'string') { - if (LINK_FALLBACK_CODES.has(err.code)) return false; - } - throw err; - } -} - /** * An in-progress snapshot file `.tmp-`, or a SQLite sidecar * (`-journal`, `-wal`, `-shm`) that `VACUUM INTO` may leave beside it. @@ -926,12 +899,12 @@ async function freeSnapshotName(backupDir: string, prefix: string): Promise-*.db` (restore, recover-brain-db, listing) - // still finds a file. A filesystem without hard links (exFAT, FAT, some - // network mounts) gets a copy instead. - const existing = source !== null ? writtenBySource.get(source) : undefined; - if (existing !== undefined) { - try { - if (tryHardLink(existing, dest)) { - rotateSnapshots(backupDir, target.prefix, destName); - return 'linked'; - } - const tmp = tempSnapshotPath(dest); - try { - copyFileSync(existing, tmp); - commitSnapshotFile(tmp, dest); - } catch (err) { - rmSync(tmp, { force: true }); - throw err; - } - rotateSnapshots(backupDir, target.prefix, destName); - return 'written'; - } finally { - if (opened && target.closeDb) target.closeDb(opened); - } - } - try { // TRUNCATE checkpoint: flushes all WAL frames to the main DB and truncates // the WAL file to zero bytes, ensuring a consistent DB state before the @@ -1150,12 +1105,18 @@ export function listSqliteBackups( } /** - * List existing brain.db snapshots (newest first). + * List existing snapshots that hold the brain tables (newest first): the + * project store's `tasks-*.db` snapshots, since brain lives in the unified + * `cleo.db` and a run writes one file for it (T13891), plus older + * `brain-*.db` files written before that. */ export function listBrainBackups( cwd?: string, ): Array<{ name: string; path: string; mtimeMs: number }> { - return listSqliteBackupsForPrefix('brain', cwd); + return [ + ...listSqliteBackupsForPrefix('tasks', cwd), + ...listSqliteBackupsForPrefix('brain', cwd), + ].sort((a, b) => b.mtimeMs - a.mtimeMs); } /** diff --git a/packages/core/src/system/backup.ts b/packages/core/src/system/backup.ts index 13192b7a1b..b04b99c769 100644 --- a/packages/core/src/system/backup.ts +++ b/packages/core/src/system/backup.ts @@ -29,8 +29,9 @@ * snapshotter) write to the SAME directory: `.cleo/backups/sqlite/`. The two * producers use distinguishable filename schemes that coexist: * - * - `vacuumIntoBackupAll` writes `tasks-YYYYMMDD-HHmmss.db` / - * `brain-YYYYMMDD-HHmmss.db` (no sidecar). + * - `vacuumIntoBackupAll` writes ONE `tasks-YYYYMMDD-HHmmss.db` per run for + * the unified project store (no sidecar); older `brain-*` / `conduit-*` + * files predate T13891. * - `createBackup` writes `.` + `.meta.json`, * where `backupId = -YYYYMMDD-HHmmss` (matching the same local-time * timestamp format). From b2a0191c4dc3f834f048dfcf3f82539f3e8aa2ab Mon Sep 17 00:00:00 2001 From: Keaton Hoskins Date: Sat, 17 Oct 2026 19:23:48 -0700 Subject: [PATCH 2/7] fix(T13897): brain and conduit recovery read the tasks-* project snapshot After T13891 a run writes one tasks-*.db for the unified store, but three recovery readers matched only role-prefixed files, so `cleo backup recover brain|conduit` would pick the last pre-upgrade brain-* copy (a whole cleo.db, rolling the store back) or fail with E_NO_SNAPSHOT on a fresh install. vacuumSnapshotPrefixes() names tasks as a prefix that holds brain and conduit; buildVacuumSnapshotRegex (and with it collectSnapshotCandidatesForRole / recoverProjectStore), the snapshotMatchesPin vacuum branch and recover-brain-db's VACUUM_FILENAME_RE now pick the freshest file across both prefixes. Tests: brain and conduit recover from a newer tasks-* over an older role-prefixed file; recover-brain-db does the same; the real-process test asserts recovery finds the one project snapshot. Co-Authored-By: Claude Opus 5.5 --- .../__tests__/backup-store-roundtrip.test.ts | 24 +++++++++++++++++ .../store/__tests__/recover-brain-db.test.ts | 26 +++++++++++++++++++ .../sqlite-backup-real-process.test.ts | 7 +++++ packages/core/src/store/backup-recover.ts | 13 ++++++---- packages/core/src/store/recover-brain-db.ts | 6 +++-- .../core/src/store/recover-malformed-db.ts | 19 ++++++++++++-- 6 files changed, 86 insertions(+), 9 deletions(-) diff --git a/packages/core/src/store/__tests__/backup-store-roundtrip.test.ts b/packages/core/src/store/__tests__/backup-store-roundtrip.test.ts index 017e5eb988..dd1a579b90 100644 --- a/packages/core/src/store/__tests__/backup-store-roundtrip.test.ts +++ b/packages/core/src/store/__tests__/backup-store-roundtrip.test.ts @@ -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); diff --git a/packages/core/src/store/__tests__/recover-brain-db.test.ts b/packages/core/src/store/__tests__/recover-brain-db.test.ts index 053d77ac2c..a897c6dfdd 100644 --- a/packages/core/src/store/__tests__/recover-brain-db.test.ts +++ b/packages/core/src/store/__tests__/recover-brain-db.test.ts @@ -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'); diff --git a/packages/core/src/store/__tests__/sqlite-backup-real-process.test.ts b/packages/core/src/store/__tests__/sqlite-backup-real-process.test.ts index d684fcc0fc..f4cbe49b89 100644 --- a/packages/core/src/store/__tests__/sqlite-backup-real-process.test.ts +++ b/packages/core/src/store/__tests__/sqlite-backup-real-process.test.ts @@ -36,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); @@ -128,5 +129,11 @@ describe('sqlite-backup real-process (T10316)', () => { expect(tasksSnapshots).toHaveLength(1); expect(brainSnapshots).toHaveLength(0); expect(files.filter((f) => /^conduit-\d{8}-\d{6}\.db$/.test(f))).toHaveLength(0); + + // 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] ?? '')]); + } }); }); diff --git a/packages/core/src/store/backup-recover.ts b/packages/core/src/store/backup-recover.ts index a8d97b922a..edf9da83a3 100644 --- a/packages/core/src/store/backup-recover.ts +++ b/packages/core/src/store/backup-recover.ts @@ -38,6 +38,7 @@ import { recoverMalformedDb, resolveRoleBackupDirs, resolveRoleDbPath, + vacuumSnapshotPrefixes, } from './recover-malformed-db.js'; import { ensureTriggerSuspendTableAtPath } from './sync/trigger-suspend-at-path.js'; import { @@ -215,11 +216,13 @@ function snapshotMatchesPin(role: DbRole, candidate: SnapshotCandidate, pin: str ); if (iso.startsWith(pin)) return true; } - // Vacuum snapshot: `-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: `-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; } diff --git a/packages/core/src/store/recover-brain-db.ts b/packages/core/src/store/recover-brain-db.ts index 48bc510710..1547f775df 100644 --- a/packages/core/src/store/recover-brain-db.ts +++ b/packages/core/src/store/recover-brain-db.ts @@ -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-/; diff --git a/packages/core/src/store/recover-malformed-db.ts b/packages/core/src/store/recover-malformed-db.ts index 84714cff9a..1afa63d11d 100644 --- a/packages/core/src/store/recover-malformed-db.ts +++ b/packages/core/src/store/recover-malformed-db.ts @@ -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 `-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 - * `-20260523-130026.db`). + * `-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$`); } /** From 3dd9c97a2b6f26536088297e7ff3901572bab737 Mon Sep 17 00:00:00 2001 From: Keaton Hoskins Date: Sun, 18 Oct 2026 14:38:49 -0700 Subject: [PATCH 3/7] test(T13891): mixed backup-verify fixture expects brain healthy from the project snapshot The fresh tasks-* snapshot is the whole project store, so it is brain's freshest snapshot too (T13891); stale coverage stays in the single-DB test. Co-Authored-By: Claude Opus 5.5 --- .../src/cli/commands/__tests__/backup-verify.test.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/packages/cleo/src/cli/commands/__tests__/backup-verify.test.ts b/packages/cleo/src/cli/commands/__tests__/backup-verify.test.ts index f45e1883e8..e2104043e3 100644 --- a/packages/cleo/src/cli/commands/__tests__/backup-verify.test.ts +++ b/packages/cleo/src/cli/commands/__tests__/backup-verify.test.ts @@ -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')}`; @@ -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); }); From ce796eee1218b34e02a2c8d6f518255410261d69 Mon Sep 17 00:00:00 2001 From: Keaton Hoskins Date: Mon, 19 Oct 2026 08:51:32 -0700 Subject: [PATCH 4/7] fix(T13910): prune legacy brain-*/conduit-* snapshots with tasks-* retention Nothing writes brain-*/conduit-* since T13891, so their per-prefix rotation never ran again and the old hard links pinned rotated-out tasks-* snapshots on disk forever. After each tasks-* rotation, delete legacy files older than the oldest retained tasks-* snapshot, which covers brain and conduit recovery (T13897). Co-Authored-By: Claude Opus 5.5 --- .changeset/t13891-one-project-snapshot.md | 4 +- .../src/store/__tests__/sqlite-backup.test.ts | 43 +++++++++++++++++++ packages/core/src/store/sqlite-backup.ts | 43 ++++++++++++++++++- 3 files changed, 87 insertions(+), 3 deletions(-) diff --git a/.changeset/t13891-one-project-snapshot.md b/.changeset/t13891-one-project-snapshot.md index 1a4fb57ac6..73e1e6c075 100644 --- a/.changeset/t13891-one-project-snapshot.md +++ b/.changeset/t13891-one-project-snapshot.md @@ -1,6 +1,6 @@ --- id: t13891-one-project-snapshot -tasks: [T13891] +tasks: [T13891, T13897, T13910] 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" +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 are pruned once older than every retained tasks-* snapshot" --- diff --git a/packages/core/src/store/__tests__/sqlite-backup.test.ts b/packages/core/src/store/__tests__/sqlite-backup.test.ts index f52979cfb5..9b7bd82f8a 100644 --- a/packages/core/src/store/__tests__/sqlite-backup.test.ts +++ b/packages/core/src/store/__tests__/sqlite-backup.test.ts @@ -650,6 +650,49 @@ describe('sqlite-backup', () => { expect(files[0]).toMatch(/^tasks-\d{8}-\d{6}\.db$/); }); + it('legacy brain-*/conduit-* snapshots never outlive the retained tasks-* set (T13910)', async () => { + const tempDir = join(tmpdir(), `cleo-t13910-legacy-${Date.now()}`); + const backupDir = join(tempDir, 'backups', 'sqlite'); + mkdirSync(backupDir, { recursive: true }); + // Pre-T13891 runs linked one snapshot under all three prefixes. + for (const prefix of ['tasks', 'brain', 'conduit']) { + writeFileSync(join(backupDir, `${prefix}-20260101-120000.db`), 'fake'); + } + 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 { vacuumIntoBackupAll } = await import('../sqlite-backup.js'); + await vacuumIntoBackupAll(); + shared.close(); + + const files = readdirSync(backupDir); + const stamp = (f: string): string => f.slice(f.indexOf('-') + 1, -3); + const oldestTasks = files + .filter((f) => /^tasks-\d{8}-\d{6}\.db$/.test(f)) + .map(stamp) + .sort()[0]; + expect(oldestTasks).toBeDefined(); + const legacy = files.filter((f) => /^(brain|conduit)-\d{8}-\d{6}\.db$/.test(f)); + for (const f of legacy) expect(stamp(f) >= (oldestTasks ?? '')).toBe(true); + // The 2026-01-01 tasks twin is outside retention today, so its links go too. + if (!files.includes('tasks-20260101-120000.db')) expect(legacy).toEqual([]); + }); + /** * T13891: on a filesystem without hard links (exFAT → ENOTSUP) the old * design COPIED the shared file under brain and conduit (three full copies diff --git a/packages/core/src/store/sqlite-backup.ts b/packages/core/src/store/sqlite-backup.ts index 6721240923..5dc6e30f11 100644 --- a/packages/core/src/store/sqlite-backup.ts +++ b/packages/core/src/store/sqlite-backup.ts @@ -608,6 +608,44 @@ function rotateSnapshots(backupDir: string, prefix: string, justWritten: string) } } +/** + * Prune legacy `brain-*` / `conduit-*` project snapshots older than the oldest + * retained `tasks-*` one. Before T13891 each run hard-linked one cleo.db + * snapshot under all three prefixes; nothing writes the legacy names now, so + * their own rotation never runs and they would pin rotated-out `tasks-*` + * inodes forever. The retained `tasks-*` set covers brain and conduit + * recovery (T13897), so a legacy file older than all of it is redundant. + * Non-fatal on any filesystem error. + * + * @param backupDir - Project snapshot directory. + * @task T13910 + */ +function pruneLegacyProjectSnapshots(backupDir: string): void { + try { + const names = readdirSync(backupDir); + const stampOf = (name: string): string => name.slice(name.indexOf('-') + 1, -'.db'.length); + const tasksPattern = snapshotPattern('tasks'); + const oldestTasks = names + .filter((f) => tasksPattern.test(f)) + .map(stampOf) + .sort()[0]; + if (oldestTasks === undefined) return; + for (const prefix of ['brain', 'conduit']) { + const pattern = snapshotPattern(prefix); + for (const name of names) { + if (!pattern.test(name) || stampOf(name) >= oldestTasks) continue; + try { + unlinkSync(join(backupDir, name)); + } catch { + // non-fatal — try the rest + } + } + } + } catch { + // non-fatal + } +} + /** Options accepted by {@link vacuumIntoBackup} and {@link vacuumIntoBackupAll}. */ export interface VacuumOptions { /** @@ -948,7 +986,8 @@ async function snapshotOne( // cleo.db holds the tasks, brain and conduit tables, so its one snapshot // covers this prefix. No second file, link or copy: per-prefix links kept // diverging under per-prefix retention, and a filesystem without hard - // links got full copies. Older `brain-*` / `conduit-*` files stay listed. + // links got full copies. Older `brain-*` / `conduit-*` files age out with + // `tasks-*` retention (pruneLegacyProjectSnapshots, T13910). if (source !== null && writtenBySource.has(source)) { if (opened && target.closeDb) target.closeDb(opened); return 'linked'; @@ -986,6 +1025,8 @@ async function snapshotOne( if (source !== null) writtenBySource.set(source, dest); rotateSnapshots(backupDir, target.prefix, destName); + if (target.prefix === 'tasks' && target.tier === 'project') + pruneLegacyProjectSnapshots(backupDir); return 'written'; } finally { // Release the ephemeral handle for raw-file-vacuum-readonly targets. From b30f1523f75a377574e6273c0e7a8997a870aec5 Mon Sep 17 00:00:00 2001 From: Keaton Hoskins Date: Mon, 19 Oct 2026 08:54:57 -0700 Subject: [PATCH 5/7] fix(T13910): file the unified project store as tasks-* whichever prefix was admitted A tasks-only snapshot refreshes the tasks debounce, so the next all-snapshot run admitted only brain and conduit and wrote a brain-* file that conduit recovery (conduit-* or tasks-*) never sees. The project cleo.db snapshot is now always named tasks-* and rotated as tasks-*. Co-Authored-By: Claude Opus 5.5 --- .../src/store/__tests__/sqlite-backup.test.ts | 35 +++++++++++++++++++ packages/core/src/store/sqlite-backup.ts | 17 ++++++--- 2 files changed, 47 insertions(+), 5 deletions(-) diff --git a/packages/core/src/store/__tests__/sqlite-backup.test.ts b/packages/core/src/store/__tests__/sqlite-backup.test.ts index 9b7bd82f8a..2e775d3fcc 100644 --- a/packages/core/src/store/__tests__/sqlite-backup.test.ts +++ b/packages/core/src/store/__tests__/sqlite-backup.test.ts @@ -650,6 +650,41 @@ describe('sqlite-backup', () => { expect(files[0]).toMatch(/^tasks-\d{8}-\d{6}\.db$/); }); + it('a debounced tasks prefix still files the unified store as tasks-*, never brain-* (T13910)', 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 r = await vacuumIntoBackupAll(); + shared.close(); + + expect(r?.snapshotted).not.toContain('tasks'); + const files = readdirSync(backupDir).filter((f) => /^(tasks|brain|conduit)-.*\.db$/.test(f)); + expect(files.length).toBe(2); + for (const f of files) expect(f).toMatch(/^tasks-\d{8}-\d{6}\.db$/); + }); + it('legacy brain-*/conduit-* snapshots never outlive the retained tasks-* set (T13910)', async () => { const tempDir = join(tmpdir(), `cleo-t13910-legacy-${Date.now()}`); const backupDir = join(tempDir, 'backups', 'sqlite'); diff --git a/packages/core/src/store/sqlite-backup.ts b/packages/core/src/store/sqlite-backup.ts index 5dc6e30f11..73fa11103b 100644 --- a/packages/core/src/store/sqlite-backup.ts +++ b/packages/core/src/store/sqlite-backup.ts @@ -56,7 +56,7 @@ import { statSync, unlinkSync, } from 'node:fs'; -import { dirname, join, sep } from 'node:path'; +import { basename, dirname, join, sep } from 'node:path'; import type { DatabaseSync } from 'node:sqlite'; import type { DbInventoryEntry, DbRole } from '@cleocode/contracts'; import { DB_INVENTORY } from '@cleocode/contracts/db-inventory.js'; @@ -977,6 +977,7 @@ async function snapshotOne( let destName: string; let source: string | null; + let filePrefix = target.prefix; try { source = sourceFileOf(db); if (source !== null && (target.tier === 'project' || target.tier === 'derived')) { @@ -992,7 +993,14 @@ async function snapshotOne( if (opened && target.closeDb) target.closeDb(opened); return 'linked'; } - destName = await freeSnapshotName(backupDir, target.prefix); + // The unified project cleo.db is always filed as `tasks-*`, whichever of + // tasks/brain/conduit the gate admitted first: a debounced tasks prefix + // must not leave the run writing a `brain-*` file that conduit recovery + // (conduit-* or tasks-*) never sees (T13910). + if (target.tier === 'project' && source !== null && basename(source) === 'cleo.db') { + filePrefix = 'tasks'; + } + destName = await freeSnapshotName(backupDir, filePrefix); } catch (err) { if (opened && target.closeDb) target.closeDb(opened); throw err; @@ -1024,9 +1032,8 @@ async function snapshotOne( } if (source !== null) writtenBySource.set(source, dest); - rotateSnapshots(backupDir, target.prefix, destName); - if (target.prefix === 'tasks' && target.tier === 'project') - pruneLegacyProjectSnapshots(backupDir); + rotateSnapshots(backupDir, filePrefix, destName); + if (filePrefix === 'tasks' && target.tier === 'project') pruneLegacyProjectSnapshots(backupDir); return 'written'; } finally { // Release the ephemeral handle for raw-file-vacuum-readonly targets. From 519ccc5377dfd09f41c07596d006336066933cb7 Mon Sep 17 00:00:00 2001 From: Keaton Hoskins Date: Mon, 19 Oct 2026 08:55:52 -0700 Subject: [PATCH 6/7] fix(T13891): drop legacy snapshot pruning from this PR; keep tasks-* naming Deleting older brain-*/conduit-* backups is new retention behaviour beyond this PR's scope. Those files are capped at ten per prefix and nothing writes them now, so the disk they hold is bounded; T13910 tracks pruning them separately. The unified store is still filed as tasks-* whichever prefix the gate admitted, with the mixed-prefix debounce regression test. Co-Authored-By: Claude Opus 5.5 --- .changeset/t13891-one-project-snapshot.md | 4 +- .../src/store/__tests__/sqlite-backup.test.ts | 45 +------------------ packages/core/src/store/sqlite-backup.ts | 44 +----------------- 3 files changed, 5 insertions(+), 88 deletions(-) diff --git a/.changeset/t13891-one-project-snapshot.md b/.changeset/t13891-one-project-snapshot.md index 73e1e6c075..cc728c8675 100644 --- a/.changeset/t13891-one-project-snapshot.md +++ b/.changeset/t13891-one-project-snapshot.md @@ -1,6 +1,6 @@ --- id: t13891-one-project-snapshot -tasks: [T13891, T13897, T13910] +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 are pruned once older than every retained tasks-* snapshot" +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" --- diff --git a/packages/core/src/store/__tests__/sqlite-backup.test.ts b/packages/core/src/store/__tests__/sqlite-backup.test.ts index 2e775d3fcc..a72ba1ce71 100644 --- a/packages/core/src/store/__tests__/sqlite-backup.test.ts +++ b/packages/core/src/store/__tests__/sqlite-backup.test.ts @@ -650,7 +650,7 @@ describe('sqlite-backup', () => { expect(files[0]).toMatch(/^tasks-\d{8}-\d{6}\.db$/); }); - it('a debounced tasks prefix still files the unified store as tasks-*, never brain-* (T13910)', async () => { + 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 }); @@ -685,49 +685,6 @@ describe('sqlite-backup', () => { for (const f of files) expect(f).toMatch(/^tasks-\d{8}-\d{6}\.db$/); }); - it('legacy brain-*/conduit-* snapshots never outlive the retained tasks-* set (T13910)', async () => { - const tempDir = join(tmpdir(), `cleo-t13910-legacy-${Date.now()}`); - const backupDir = join(tempDir, 'backups', 'sqlite'); - mkdirSync(backupDir, { recursive: true }); - // Pre-T13891 runs linked one snapshot under all three prefixes. - for (const prefix of ['tasks', 'brain', 'conduit']) { - writeFileSync(join(backupDir, `${prefix}-20260101-120000.db`), 'fake'); - } - 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 { vacuumIntoBackupAll } = await import('../sqlite-backup.js'); - await vacuumIntoBackupAll(); - shared.close(); - - const files = readdirSync(backupDir); - const stamp = (f: string): string => f.slice(f.indexOf('-') + 1, -3); - const oldestTasks = files - .filter((f) => /^tasks-\d{8}-\d{6}\.db$/.test(f)) - .map(stamp) - .sort()[0]; - expect(oldestTasks).toBeDefined(); - const legacy = files.filter((f) => /^(brain|conduit)-\d{8}-\d{6}\.db$/.test(f)); - for (const f of legacy) expect(stamp(f) >= (oldestTasks ?? '')).toBe(true); - // The 2026-01-01 tasks twin is outside retention today, so its links go too. - if (!files.includes('tasks-20260101-120000.db')) expect(legacy).toEqual([]); - }); - /** * T13891: on a filesystem without hard links (exFAT → ENOTSUP) the old * design COPIED the shared file under brain and conduit (three full copies diff --git a/packages/core/src/store/sqlite-backup.ts b/packages/core/src/store/sqlite-backup.ts index 73fa11103b..9619e8f99d 100644 --- a/packages/core/src/store/sqlite-backup.ts +++ b/packages/core/src/store/sqlite-backup.ts @@ -608,44 +608,6 @@ function rotateSnapshots(backupDir: string, prefix: string, justWritten: string) } } -/** - * Prune legacy `brain-*` / `conduit-*` project snapshots older than the oldest - * retained `tasks-*` one. Before T13891 each run hard-linked one cleo.db - * snapshot under all three prefixes; nothing writes the legacy names now, so - * their own rotation never runs and they would pin rotated-out `tasks-*` - * inodes forever. The retained `tasks-*` set covers brain and conduit - * recovery (T13897), so a legacy file older than all of it is redundant. - * Non-fatal on any filesystem error. - * - * @param backupDir - Project snapshot directory. - * @task T13910 - */ -function pruneLegacyProjectSnapshots(backupDir: string): void { - try { - const names = readdirSync(backupDir); - const stampOf = (name: string): string => name.slice(name.indexOf('-') + 1, -'.db'.length); - const tasksPattern = snapshotPattern('tasks'); - const oldestTasks = names - .filter((f) => tasksPattern.test(f)) - .map(stampOf) - .sort()[0]; - if (oldestTasks === undefined) return; - for (const prefix of ['brain', 'conduit']) { - const pattern = snapshotPattern(prefix); - for (const name of names) { - if (!pattern.test(name) || stampOf(name) >= oldestTasks) continue; - try { - unlinkSync(join(backupDir, name)); - } catch { - // non-fatal — try the rest - } - } - } - } catch { - // non-fatal - } -} - /** Options accepted by {@link vacuumIntoBackup} and {@link vacuumIntoBackupAll}. */ export interface VacuumOptions { /** @@ -987,8 +949,7 @@ async function snapshotOne( // cleo.db holds the tasks, brain and conduit tables, so its one snapshot // covers this prefix. No second file, link or copy: per-prefix links kept // diverging under per-prefix retention, and a filesystem without hard - // links got full copies. Older `brain-*` / `conduit-*` files age out with - // `tasks-*` retention (pruneLegacyProjectSnapshots, T13910). + // links got full copies. Older `brain-*` / `conduit-*` files stay listed. if (source !== null && writtenBySource.has(source)) { if (opened && target.closeDb) target.closeDb(opened); return 'linked'; @@ -996,7 +957,7 @@ async function snapshotOne( // The unified project cleo.db is always filed as `tasks-*`, whichever of // tasks/brain/conduit the gate admitted first: a debounced tasks prefix // must not leave the run writing a `brain-*` file that conduit recovery - // (conduit-* or tasks-*) never sees (T13910). + // (conduit-* or tasks-*) never sees (T13891). if (target.tier === 'project' && source !== null && basename(source) === 'cleo.db') { filePrefix = 'tasks'; } @@ -1033,7 +994,6 @@ async function snapshotOne( if (source !== null) writtenBySource.set(source, dest); rotateSnapshots(backupDir, filePrefix, destName); - if (filePrefix === 'tasks' && target.tier === 'project') pruneLegacyProjectSnapshots(backupDir); return 'written'; } finally { // Release the ephemeral handle for raw-file-vacuum-readonly targets. From 6618cb82180a8526e53e1039831176e53ec6f290 Mon Sep 17 00:00:00 2001 From: Keaton Hoskins Date: Mon, 19 Oct 2026 09:02:07 -0700 Subject: [PATCH 7/7] test(T13891): mixed-prefix regression tolerates same-bucket retention, checks latest data Two snapshots in the same retention buckets may leave one file, so the test no longer requires both. It now proves the brain-admitted run wrote a new tasks-* snapshot (no brain/conduit file) holding data written after the first one. Co-Authored-By: Claude Opus 5.5 --- .../src/store/__tests__/sqlite-backup.test.ts | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/packages/core/src/store/__tests__/sqlite-backup.test.ts b/packages/core/src/store/__tests__/sqlite-backup.test.ts index a72ba1ce71..be4c5653d0 100644 --- a/packages/core/src/store/__tests__/sqlite-backup.test.ts +++ b/packages/core/src/store/__tests__/sqlite-backup.test.ts @@ -676,13 +676,29 @@ describe('sqlite-backup', () => { // 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)); - expect(files.length).toBe(2); 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'); }); /**