From 961e64edb018c05e3f57613ff9c0e066f9370335 Mon Sep 17 00:00:00 2001 From: abate Date: Wed, 26 Aug 2026 09:58:52 +0200 Subject: [PATCH 1/4] fix(session-cache): stop /clear from listing a phantom session MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit /clear does not stay in the current transcript: the CLI opens a NEW jsonl and writes only bookkeeping into it — a local-command caveat, the /clear record, empty stdout. The indexer took that record as the session summary, so every /clear added a "2 msgs" sidebar entry titled "/clear clear included — the CLI writes a command's own output back as a user record, which titled every session where /model preceded the first prompt. A bare slash-command record is only a fallback title, used when the transcript also holds an assistant turn (a headless "/code-review high" run); with no real turn both readers return null and nothing is indexed, the same way a brand-new session stays out of the sidebar until its first prompt. Migration v9 purges the rows the old parser wrote, plus the cache_meta gate of their folders so the next reconcile re-reads exactly those files. Neither kind of row can heal on its own: a phantom sits on a file that never changes again, and a mistitled one keeps its summary because the header-only refresh path only overwrites a summary it can re-derive. --- .ai/contexts/session-cache.md | 3 + db.js | 37 ++++ read-session-file.js | 62 +++++-- test/db-initial-scan-marker.test.js | 8 +- test/db-purge-command-summaries.test.js | 117 +++++++++++++ test/db-schema-reconcile.test.js | 8 +- test/read-session-file-slash-command.test.js | 175 +++++++++++++++++++ 7 files changed, 391 insertions(+), 19 deletions(-) create mode 100644 test/db-purge-command-summaries.test.js create mode 100644 test/read-session-file-slash-command.test.js diff --git a/.ai/contexts/session-cache.md b/.ai/contexts/session-cache.md index 81f1064a..1e87adfd 100644 --- a/.ai/contexts/session-cache.md +++ b/.ai/contexts/session-cache.md @@ -51,6 +51,7 @@ From `derive-project-path.js`: `deriveProjectPath(folderPath)`, `resolveWorktree - **`resolveWorktreePath` collapses `/.worktrees/` → ``** when the parent dir exists. Consequence: many `~/.claude/projects/-home-...workspace-myproject--worktrees-X` folders derive to the same projectPath. Callers must dedupe (see `get-work-files` IPC for the pattern). - **Two-table sidebar payload**: projects are aggregated, but each session row has its own `subagentType` field. A `null`/empty `subagentType` means it's a parent session; anything else (e.g. `'general-purpose'`, `'researcher'`) marks a subagent. - **`fs.watch` debouncing**: the watcher batches per-folder events in a `pendingChanges = Map | true>` for ~200 ms before flushing to `refreshFolder`. A `true` value means "full walk needed" (rare path). +- **A session's title comes from its first *real* user turn, and a transcript without one is not indexed.** `classifyUserText()` in `read-session-file.js` sorts each user record into `prompt` / `command` / `skip`. `skip` is local-command bookkeeping (``, ``, ``, `` — the CLI writes a command's own output back as a `user` record too); `command` is a bare slash-command record (`/clear…`). This matters because **`/clear` and `/model` do not stay in the current transcript — the CLI opens a NEW jsonl and writes only that bookkeeping into it**. Taking a `command` record as the summary therefore (a) titled every session started by `/clear` "`/clear clear ` or ``, plus the `cache_meta` gate of their folders, which makes the next reconcile re-read exactly those files. - **Stats `firstSessionDate`** is computed from `MIN(modified)`, not `MIN(created)`. Old sessions touched by recent reads keep their original `created` but their `modified` reflects the latest indexing — by design (the heatmap measures activity, not creation). - **`main.js`'s `ctx.db` is a hand-built allow-list, not a spread of `db.js`.** `main.js` (~line 323) passes `sessionCache.init({ ..., db: { deleteCachedFolder, getCachedByFolder, upsertCachedSessions, ... } })` as an explicit object literal — it does **not** do `db: require('./db')`. If you add a new function to `db.js` and call it from `session-cache.js` via `ctx.db.`, but forget to add it to both this literal *and* the `require('./db')` destructure at the top of `main.js`, `ctx.db.` is `undefined`. The resulting `TypeError` is thrown inside `populateCacheViaWorker`'s `worker.on('message')` handler, which has no `try/catch` — it lands on stderr (not `electron-log`) and silently aborts the cold-start indexing write loop. Symptom: the log shows `Indexing N projects…` but never `Indexed N sessions across …`, and the affected table stays empty. Guarded by `test/main-ctx-db-wiring.test.js` (static source-grep asserting the allow-list ⊇ every `ctx.db.*` dereference in `session-cache.js`) — run it whenever you touch this boundary, but also update the allow-list by hand since the test only catches *missing* entries, not the intent. @@ -62,6 +63,8 @@ From `derive-project-path.js`: `deriveProjectPath(folderPath)`, `resolveWorktree - `derive-project-path.test.js` — covers the worktree-collapse + cwd extraction paths - `db-daily-activity.test.js` — covers heatmap aggregation - `read-session-file.test.js` — covers header parsing +- `read-session-file-slash-command.test.js` — covers the `/clear` bookkeeping transcript and slash-command titles +- `db-purge-command-summaries.test.js` — covers migration v9's surgical purge - `main-ctx-db-wiring.test.js` — covers the `ctx.db` allow-list ⊇ session-cache.js usage invariant above - IPC consumers of cached payloads: `get-projects`, `get-active-sessions`, `search`, `get-stats-from-db`, `get-work-files`, `list-subagents`, `read-session-jsonl` - Renderer: `public/sidebar.js` (consumes `buildProjectsFromCache` output), `public/stats-view.js` (consumes `getDailyActivity`) diff --git a/db.js b/db.js index 8f288e3b..6f43097d 100644 --- a/db.js +++ b/db.js @@ -227,6 +227,43 @@ const migrations = [ } } catch {} }, + // v9: purge rows summarised from local-command bookkeeping. /clear and /model + // open a new transcript whose first records are only that bookkeeping, which + // read-session-file.js used to take as the summary — titling every session + // started by /clear "/clear…" and listing bookkeeping-only + // transcripts as phantom sessions. The parser no longer does; drop the rows + // it already wrote, plus the cache_meta gate for their folders so the next + // reconcile re-reads exactly those files (every other file in the folder + // still hits the fileMtime fast path and is left untouched). + (db) => { + try { + const bad = db.prepare(`SELECT sessionId, folder FROM session_cache + WHERE summary LIKE '%' OR summary LIKE '%'` + ).all(); + if (bad.length === 0) return; + const delRow = db.prepare('DELETE FROM session_cache WHERE sessionId = ?'); + const delFolderMeta = db.prepare('DELETE FROM cache_meta WHERE folder = ?'); + for (const { sessionId, folder } of bad) { + delRow.run(sessionId); + if (folder) delFolderMeta.run(folder); + } + // Search entries live in tables created further down this file, so they + // may not exist yet on a DB this migration is the first to touch — + // prepared separately so a missing table throws here instead of skipping + // the purge above. External-content FTS5 ordering: fts delete first (it + // reads search_content), then content, then the map. + try { + const delFts = db.prepare("DELETE FROM search_fts WHERE rowid IN (SELECT rowid FROM search_map WHERE type = 'session' AND id = ?)"); + const delContent = db.prepare("DELETE FROM search_content WHERE rowid IN (SELECT rowid FROM search_map WHERE type = 'session' AND id = ?)"); + const delMap = db.prepare("DELETE FROM search_map WHERE type = 'session' AND id = ?"); + for (const { sessionId } of bad) { + delFts.run(sessionId); + delContent.run(sessionId); + delMap.run(sessionId); + } + } catch {} + } catch {} + }, ]; const currentDbVersion = (() => { diff --git a/read-session-file.js b/read-session-file.js index 6fd2978a..5972b5a4 100644 --- a/read-session-file.js +++ b/read-session-file.js @@ -109,6 +109,34 @@ function extractDailyMetrics(lines, fallbackDate) { return Array.from(map.values()); } +// --- First-prompt selection --- +// Some records typed `user` carry no prompt: `!`-prefixed shell input, the +// caveat Claude Code wraps local-command output in, that output itself, and +// bare slash-command invocations. /clear and /model open a BRAND NEW transcript +// whose only records are that bookkeeping, so treating one as the session +// summary both mistitles every session started by /clear and lists +// content-free transcripts as phantom sidebar entries. +const LOCAL_COMMAND_RE = /|||/; +const SLASH_COMMAND_RE = /^\s*([^<]*)<\/command-name>/; +const COMMAND_ARGS_RE = /([^<]*)<\/command-args>/; + +/** Classify a user message's text as a summary candidate: + * 'prompt' — a real user turn, used as-is. + * 'command' — a slash-command invocation, usable only as a fallback. + * 'skip' — local-command bookkeeping, never a summary. + */ +function classifyUserText(text) { + if (!text || LOCAL_COMMAND_RE.test(text)) return { kind: 'skip', text: '' }; + const cmd = text.match(SLASH_COMMAND_RE); + if (cmd) { + const name = cmd[1].trim(); + const args = (text.match(COMMAND_ARGS_RE)?.[1] || '').trim(); + return { kind: 'command', text: (args ? name + ' ' + args : name).slice(0, 120) }; + } + const taskMatch = text.match(/||/.test(text)) { - // Use scheduled task name if present - const taskMatch = text.match(/||/.test(txt)) { - const taskMatch = txt.match(/ { assert.equal(state.marker, null, 'an empty cache means no scan ever completed -- the marker must be earned by the first successful scan, ' + 'otherwise an interrupted first scan would be blessed as complete on relaunch'); - assert.equal(state.version, '8'); + assert.equal(state.version, '9'); } finally { fs.rmSync(dir, { recursive: true, force: true }); } @@ -100,7 +100,7 @@ test('pre-marker install (populated cache, db_version 7) gets the marker backfil assert.equal(state.marker, 'true', 'a populated pre-marker cache can only come from a completed batch-write scan -- ' + 'migration v8 must bless it or every existing install would re-run the full cold-start scan'); - assert.equal(state.version, '8'); + assert.equal(state.version, '9'); assert.equal(state.cacheCount, 1, 'the existing cache rows are preserved, not rescanned'); } finally { fs.rmSync(dir, { recursive: true, force: true }); @@ -110,7 +110,7 @@ test('pre-marker install (populated cache, db_version 7) gets the marker backfil test('the backfill runs once: a partial cache after an interrupted post-marker scan is NOT re-blessed on relaunch', () => { const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'switchboard-marker-partial-')); try { - // First load: fresh DB, migrations run to completion (db_version 8), no + // First load: fresh DB, migrations run to completion (db_version 9), no // marker (empty cache). Now simulate an interrupted first scan: some rows // land in session_cache, the marker is never written. const init = loadDbModule(dir); @@ -122,7 +122,7 @@ test('the backfill runs once: a partial cache after an interrupted post-marker s `, dir); assert.equal(seed.status, 0, seed.stderr); - // Relaunch: db_version is already 8, so migration v8 must not run again. + // Relaunch: db_version is already 9, so migration v8 must not run again. // If it did, the partial cache would be blessed as complete and the next // get-projects would take the warm branch straight into the synchronous // reconcile sweep -- the freeze the marker exists to prevent. diff --git a/test/db-purge-command-summaries.test.js b/test/db-purge-command-summaries.test.js new file mode 100644 index 00000000..dd2b492d --- /dev/null +++ b/test/db-purge-command-summaries.test.js @@ -0,0 +1,117 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const { spawnSync } = require('child_process'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +const APP_DIR = path.join(__dirname, '..'); +// better-sqlite3 is compiled for Electron's ABI — run every DB snippet under +// Electron-as-Node, same as the other db.js tests. +const electronBin = require('electron'); + +function runInElectronNode(code, dataDir) { + return spawnSync(electronBin, ['-e', code], { + cwd: APP_DIR, + env: { ...process.env, ELECTRON_RUN_AS_NODE: '1', SWITCHBOARD_DATA_DIR: dataDir }, + encoding: 'utf8', + }); +} + +function loadDbModule(dataDir) { + return runInElectronNode(`require(${JSON.stringify(path.join(APP_DIR, 'db.js'))})`, dataDir); +} + +// Migration v9 purges the rows a pre-fix parser summarised from the +// slash-command records /clear writes into the transcript it opens. Those rows +// cannot heal on their own: the phantom ones sit on a file that never changes +// again (so the watcher never revisits it), and the real ones keep the bad +// title because the header-only refresh path only overwrites a summary it can +// re-derive. +test('migration v9 purges slash-command summaries and re-opens their folder for re-indexing', () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'switchboard-cmd-purge-')); + try { + const init = loadDbModule(dir); + assert.equal(init.status, 0, init.stderr); + + const seed = runInElectronNode(` + const Database = require('better-sqlite3'); + const db = new Database(require('path').join(process.env.SWITCHBOARD_DATA_DIR, 'switchboard.db')); + db.prepare("INSERT OR REPLACE INTO settings (key, value) VALUES ('db_version', '8')").run(); + const ins = db.prepare('INSERT INTO session_cache (sessionId, folder, projectPath, summary, modified) VALUES (?, ?, ?, ?, ?)'); + ins.run('phantom', 'f1', '/tmp/p1', '/clear\\nclear', '2026-01-01T00:00:00Z'); + ins.run('real', 'f1', '/tmp/p1', 'fix the watcher', '2026-01-02T00:00:00Z'); + ins.run('other-folder', 'f2', '/tmp/p2', 'unrelated work', '2026-01-03T00:00:00Z'); + db.prepare('INSERT INTO cache_meta (folder, projectPath, indexMtimeMs) VALUES (?, ?, ?)').run('f1', '/tmp/p1', 123); + db.prepare('INSERT INTO cache_meta (folder, projectPath, indexMtimeMs) VALUES (?, ?, ?)').run('f2', '/tmp/p2', 456); + // Mirror upsertSearchEntries: map row first, then the content column + // store, then the fts5 shadow row, all sharing the same rowid. + db.prepare('INSERT INTO search_map (rowid, id, type, folder) VALUES (1, ?, ?, ?)').run('phantom', 'session', 'f1'); + db.prepare('INSERT INTO search_content (rowid, title, body) VALUES (1, ?, ?)').run('/clear', 'body'); + db.prepare('INSERT INTO search_fts (rowid, title, body) VALUES (1, ?, ?)').run('/clear', 'body'); + `, dir); + assert.equal(seed.status, 0, seed.stderr); + + const r = loadDbModule(dir); + assert.equal(r.status, 0, r.stderr); + + const inspect = runInElectronNode(` + const Database = require('better-sqlite3'); + const db = new Database(require('path').join(process.env.SWITCHBOARD_DATA_DIR, 'switchboard.db'), { readonly: true }); + console.log(JSON.stringify({ + ids: db.prepare('SELECT sessionId FROM session_cache ORDER BY sessionId').all().map(r => r.sessionId), + folders: db.prepare('SELECT folder FROM cache_meta ORDER BY folder').all().map(r => r.folder), + searchIds: db.prepare("SELECT id FROM search_map WHERE type = 'session'").all().map(r => r.id), + searchContent: db.prepare('SELECT COUNT(*) AS n FROM search_content').get().n, + version: db.prepare("SELECT value FROM settings WHERE key = 'db_version'").get()?.value, + })); + `, dir); + assert.equal(inspect.status, 0, inspect.stderr); + const state = JSON.parse(inspect.stdout.trim().split('\n').pop()); + + assert.deepEqual(state.ids, ['other-folder', 'real'], 'only the command-summary row is dropped'); + assert.deepEqual(state.folders, ['f2'], + 'the affected folder loses its index gate so reconcile re-reads the purged file; untouched folders keep theirs'); + assert.deepEqual(state.searchIds, [], 'the purged row leaves no stale search entry behind'); + assert.equal(state.searchContent, 0); + assert.equal(state.version, '9'); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +test('migration v9 is a no-op on a database with no command summaries', () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'switchboard-cmd-purge-noop-')); + try { + const init = loadDbModule(dir); + assert.equal(init.status, 0, init.stderr); + + const seed = runInElectronNode(` + const Database = require('better-sqlite3'); + const db = new Database(require('path').join(process.env.SWITCHBOARD_DATA_DIR, 'switchboard.db')); + db.prepare("INSERT OR REPLACE INTO settings (key, value) VALUES ('db_version', '8')").run(); + db.prepare('INSERT INTO session_cache (sessionId, folder, summary, modified) VALUES (?, ?, ?, ?)') + .run('s1', 'f1', 'real work', '2026-01-01T00:00:00Z'); + db.prepare('INSERT INTO cache_meta (folder, projectPath, indexMtimeMs) VALUES (?, ?, ?)').run('f1', '/tmp/p1', 123); + `, dir); + assert.equal(seed.status, 0, seed.stderr); + + const r = loadDbModule(dir); + assert.equal(r.status, 0, r.stderr); + + const inspect = runInElectronNode(` + const Database = require('better-sqlite3'); + const db = new Database(require('path').join(process.env.SWITCHBOARD_DATA_DIR, 'switchboard.db'), { readonly: true }); + console.log(JSON.stringify({ + cacheCount: db.prepare('SELECT COUNT(*) AS n FROM session_cache').get().n, + metaCount: db.prepare('SELECT COUNT(*) AS n FROM cache_meta').get().n, + })); + `, dir); + assert.equal(inspect.status, 0, inspect.stderr); + const state = JSON.parse(inspect.stdout.trim().split('\n').pop()); + assert.equal(state.cacheCount, 1, 'no re-index forced on installs that never hit the bug'); + assert.equal(state.metaCount, 1); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); diff --git a/test/db-schema-reconcile.test.js b/test/db-schema-reconcile.test.js index 20392c51..a32d9e30 100644 --- a/test/db-schema-reconcile.test.js +++ b/test/db-schema-reconcile.test.js @@ -83,11 +83,11 @@ test('foreign higher-version database is reconciled, not crashed', () => { assert.ok(state.cols.includes('fileMtime'), 'fileMtime column added'); assert.equal(state.cacheCount, 0, 'stale cache cleared for re-index'); assert.equal(state.metaCount, 0, 'folder index gate cleared for re-index'); - // Fork divergence from upstream: our migrations array has 8 entries (vs 4 - // upstream), so a version-5 DB legitimately runs v6-v8 (all idempotent - // try/catch) and gets bumped to 8. The invariant that matters is "never + // Fork divergence from upstream: our migrations array has 9 entries (vs 4 + // upstream), so a version-5 DB legitimately runs v6-v9 (all idempotent + // try/catch) and gets bumped to 9. The invariant that matters is "never // downgraded"; a DB claiming a version above ours stays untouched. - assert.equal(state.version, '8', 'foreign db_version upgraded to ours, never downgraded'); + assert.equal(state.version, '9', 'foreign db_version upgraded to ours, never downgraded'); for (const col of ['parentSessionId', 'agentId', 'runtime', 'sessionFile']) { assert.ok(state.cols.includes(col), `foreign column ${col} preserved`); } diff --git a/test/read-session-file-slash-command.test.js b/test/read-session-file-slash-command.test.js new file mode 100644 index 00000000..0fee984e --- /dev/null +++ b/test/read-session-file-slash-command.test.js @@ -0,0 +1,175 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +const { readSessionFile, readSessionDisplayHeader } = require('../read-session-file'); + +function mkTmp() { + return fs.mkdtempSync(path.join(os.tmpdir(), 'switchboard-slash-')); +} + +function cleanup(dir) { + fs.rmSync(dir, { recursive: true, force: true }); +} + +function write(dir, sessionId, entries) { + const filePath = path.join(dir, `${sessionId}.jsonl`); + fs.writeFileSync(filePath, entries.map(e => JSON.stringify(e)).join('\n') + '\n', 'utf8'); + return filePath; +} + +// The exact records Claude Code writes into the NEW transcript /clear opens. +const CLEAR_BOOKKEEPING = [ + { type: 'mode', mode: 'normal' }, + { type: 'file-history-snapshot', messageId: 'm1' }, + { + type: 'user', + isMeta: true, + timestamp: '2026-08-26T07:44:02.583Z', + message: { + role: 'user', + content: 'Caveat: The messages below were generated by the user while running local commands.', + }, + }, + { + type: 'user', + timestamp: '2026-08-26T07:44:02.578Z', + message: { + role: 'user', + content: '/clear\n clear\n ', + }, + }, + { + type: 'system', + subtype: 'local_command', + timestamp: '2026-08-26T07:44:02.582Z', + content: '', + }, +]; + +test('a transcript holding only /clear bookkeeping is not indexed at all', () => { + const tmp = mkTmp(); + try { + const filePath = write(tmp, 'cleared', CLEAR_BOOKKEEPING); + assert.equal(readSessionFile(filePath, 'folder-x', '/some/project'), null, + '/clear opens a new transcript with no conversation in it — indexing it puts a phantom session in the sidebar'); + assert.equal(readSessionDisplayHeader(filePath), null); + } finally { + cleanup(tmp); + } +}); + +test('a session started by /clear is titled by its first real prompt', () => { + const tmp = mkTmp(); + try { + const filePath = write(tmp, 'after-clear', [ + ...CLEAR_BOOKKEEPING, + { type: 'user', timestamp: '2026-08-26T07:45:00.000Z', message: { role: 'user', content: 'fix the watcher' } }, + { type: 'assistant', timestamp: '2026-08-26T07:45:01.000Z', message: { role: 'assistant', content: 'on it' } }, + ]); + + const row = readSessionFile(filePath, 'folder-x', '/some/project'); + assert.equal(row.summary, 'fix the watcher'); + assert.equal(row.firstPrompt, 'fix the watcher'); + assert.equal(readSessionDisplayHeader(filePath).summary, 'fix the watcher'); + } finally { + cleanup(tmp); + } +}); + +test('a session whose only user turn is a slash command is titled by that command', () => { + const tmp = mkTmp(); + try { + const filePath = write(tmp, 'command-only', [ + { + type: 'user', + timestamp: '2026-08-26T07:44:02.578Z', + message: { + role: 'user', + content: '/code-review\ncode-review\nhigh', + }, + }, + { type: 'assistant', timestamp: '2026-08-26T07:44:03.000Z', message: { role: 'assistant', content: 'reviewing' } }, + ]); + + // The raw record would render as "/code-review code-review { + const tmp = mkTmp(); + try { + const filePath = write(tmp, 'model-only', [ + { + type: 'user', + timestamp: '2026-08-26T07:44:02.578Z', + message: { + role: 'user', + content: '/model\nmodel\n', + }, + }, + ]); + assert.equal(readSessionFile(filePath, 'folder-x', '/some/project'), null); + assert.equal(readSessionDisplayHeader(filePath), null); + } finally { + cleanup(tmp); + } +}); + +test("a command's own output is not a prompt either", () => { + const tmp = mkTmp(); + try { + // /model logs its confirmation back as a `user` record; before the fix it + // became the title of every session where /model preceded the first prompt. + const filePath = write(tmp, 'model-then-work', [ + ...CLEAR_BOOKKEEPING, + { + type: 'user', + timestamp: '2026-08-26T07:45:00.000Z', + message: { + role: 'user', + content: '/model\nmodel\nsonnet', + }, + }, + { + type: 'user', + timestamp: '2026-08-26T07:45:01.000Z', + message: { role: 'user', content: 'Set model to Sonnet 5' }, + }, + { type: 'user', timestamp: '2026-08-26T07:46:00.000Z', message: { role: 'user', content: 'add the motion sensor' } }, + { type: 'assistant', timestamp: '2026-08-26T07:46:01.000Z', message: { role: 'assistant', content: 'on it' } }, + ]); + + assert.equal(readSessionFile(filePath, 'folder-x', '/some/project').summary, 'add the motion sensor'); + assert.equal(readSessionDisplayHeader(filePath).summary, 'add the motion sensor'); + } finally { + cleanup(tmp); + } +}); + +test('a scheduled-task prompt still wins over a leading slash command', () => { + const tmp = mkTmp(); + try { + const filePath = write(tmp, 'scheduled', [ + ...CLEAR_BOOKKEEPING, + { + type: 'user', + timestamp: '2026-08-26T07:45:00.000Z', + message: { role: 'user', content: 'run it' }, + }, + { type: 'assistant', timestamp: '2026-08-26T07:45:01.000Z', message: { role: 'assistant', content: 'ok' } }, + ]); + assert.equal(readSessionFile(filePath, 'folder-x', '/some/project').summary, + 'Scheduled: nightly introspection'); + } finally { + cleanup(tmp); + } +}); From 6663bc765b70123a8b20e4497f6c84f458ffcdb5 Mon Sep 17 00:00:00 2001 From: Jean-Baptiste Date: Wed, 2 Sep 2026 16:47:19 +0200 Subject: [PATCH 2/4] fix(session-cache): classify a command record whichever tag opens it The CLI writes both tag orders: first for /clear, but first for /auto-compact and /pre-compact. Requiring to open the record left the second form classified as a prompt, so the raw XML became the summary and cleanDisplayName truncated it to "/pre-compact pre-compact tag sitting next to one of its siblings, wherever it sits. The bookkeeping test is anchored for the opposite reason: matching anywhere, it skipped a prompt that merely quotes (a pasted transcript excerpt), and with no other user turn the whole session went unindexed -- invisible in the sidebar and absent from search. Over 1707 real user records the anchored and unanchored forms differ on exactly one record, and that one is prose. --- read-session-file.js | 15 +++- test/read-session-file-slash-command.test.js | 90 ++++++++++++++++++++ 2 files changed, 101 insertions(+), 4 deletions(-) diff --git a/read-session-file.js b/read-session-file.js index 5972b5a4..dc1b93d3 100644 --- a/read-session-file.js +++ b/read-session-file.js @@ -116,8 +116,15 @@ function extractDailyMetrics(lines, fallbackDate) { // whose only records are that bookkeeping, so treating one as the session // summary both mistitles every session started by /clear and lists // content-free transcripts as phantom sidebar entries. -const LOCAL_COMMAND_RE = /|||/; -const SLASH_COMMAND_RE = /^\s*([^<]*)<\/command-name>/; +// Anchored: a bookkeeping record IS one of these envelopes, it does not merely +// mention one. A prompt quoting (pasting a transcript +// excerpt) is a real turn, and skipping it can leave a whole session unindexed. +const LOCAL_COMMAND_RE = /^\s*<(bash-input|bash-stdout|local-command-caveat|local-command-stdout)>/; +// The CLI emits both tag orders — first, and +// first (/auto-compact, /pre-compact) — so a command record is recognised by +// the presence of alongside one of its siblings, not by position. +const COMMAND_NAME_RE = /([^<]*)<\/command-name>/; +const COMMAND_SIBLING_RE = //; const COMMAND_ARGS_RE = /([^<]*)<\/command-args>/; /** Classify a user message's text as a summary candidate: @@ -127,8 +134,8 @@ const COMMAND_ARGS_RE = /([^<]*)<\/command-args>/; */ function classifyUserText(text) { if (!text || LOCAL_COMMAND_RE.test(text)) return { kind: 'skip', text: '' }; - const cmd = text.match(SLASH_COMMAND_RE); - if (cmd) { + const cmd = text.match(COMMAND_NAME_RE); + if (cmd && COMMAND_SIBLING_RE.test(text)) { const name = cmd[1].trim(); const args = (text.match(COMMAND_ARGS_RE)?.[1] || '').trim(); return { kind: 'command', text: (args ? name + ' ' + args : name).slice(0, 120) }; diff --git a/test/read-session-file-slash-command.test.js b/test/read-session-file-slash-command.test.js index 0fee984e..fc551046 100644 --- a/test/read-session-file-slash-command.test.js +++ b/test/read-session-file-slash-command.test.js @@ -173,3 +173,93 @@ test('a scheduled-task prompt still wins over a leading slash command', () => { cleanup(tmp); } }); + +// The CLI writes the two envelope tags in either order. /auto-compact and +// /pre-compact put first; requiring to open +// the record let the raw XML back in as the summary, which cleanDisplayName +// then truncated to "/pre-compact pre-compact comes first is still a command', () => { + const tmp = mkTmp(); + try { + const filePath = write(tmp, 'message-first', [ + { + type: 'user', + timestamp: '2026-08-26T07:44:02.578Z', + message: { + role: 'user', + content: 'auto-compact\n/auto-compact', + }, + }, + { type: 'assistant', timestamp: '2026-08-26T07:44:03.000Z', message: { role: 'assistant', content: 'compacting' } }, + ]); + + const row = readSessionFile(filePath, 'folder-x', '/some/project'); + assert.equal(row.summary, '/auto-compact'); + assert.equal(readSessionDisplayHeader(filePath).summary, '/auto-compact'); + } finally { + cleanup(tmp); + } +}); + +test('a message-first command record carries its args into the title', () => { + const tmp = mkTmp(); + try { + const filePath = write(tmp, 'message-first-args', [ + { + type: 'user', + timestamp: '2026-08-26T07:44:02.578Z', + message: { + role: 'user', + content: 'pre-compact\n/pre-compact\nmanual', + }, + }, + { type: 'assistant', timestamp: '2026-08-26T07:44:03.000Z', message: { role: 'assistant', content: 'noted' } }, + ]); + + assert.equal(readSessionFile(filePath, 'folder-x', '/some/project').summary, '/pre-compact manual'); + } finally { + cleanup(tmp); + } +}); + +test('a message-first command record alone is bookkeeping, not a session', () => { + const tmp = mkTmp(); + try { + const filePath = write(tmp, 'message-first-only', [ + { + type: 'user', + timestamp: '2026-08-26T07:44:02.578Z', + message: { + role: 'user', + content: 'auto-compact\n/auto-compact', + }, + }, + ]); + assert.equal(readSessionFile(filePath, 'folder-x', '/some/project'), null); + assert.equal(readSessionDisplayHeader(filePath), null); + } finally { + cleanup(tmp); + } +}); + +// The bookkeeping test is anchored: a record IS an envelope, it does not merely +// mention one. Matching anywhere in the text made a prompt quoting a transcript +// excerpt disappear -- with no other user turn, the whole session went unindexed +// (absent from the sidebar and from search). +test('a prompt quoting local-command output is a real prompt', () => { + const tmp = mkTmp(); + try { + const quoted = 'why is Set model to Sonnet 5 skipped here?'; + const filePath = write(tmp, 'quoting-prompt', [ + { type: 'user', timestamp: '2026-08-26T07:45:00.000Z', message: { role: 'user', content: quoted } }, + { type: 'assistant', timestamp: '2026-08-26T07:45:01.000Z', message: { role: 'assistant', content: 'because…' } }, + ]); + + const row = readSessionFile(filePath, 'folder-x', '/some/project'); + assert.notEqual(row, null, 'a session whose only prompt quotes an envelope must still be indexed'); + assert.equal(row.summary, quoted); + assert.equal(readSessionDisplayHeader(filePath).summary, quoted); + } finally { + cleanup(tmp); + } +}); From 88316d9298866e2a3a983a2f620bca36a8e07b14 Mon Sep 17 00:00:00 2001 From: Jean-Baptiste Date: Wed, 2 Sep 2026 16:47:48 +0200 Subject: [PATCH 3/4] fix(db): complete migration v9's purge and run it atomically Three gaps in the purge of the rows the pre-fix parser wrote: - session_metrics kept its rows. deleteCachedSession() drops them first for good reason: readSessionFile now returns null on a phantom's file forever, so nothing ever revisits it and its metrics keep inflating getDailyMetrics() and getTotalCounts() for the life of the database. - summaries opening on went unmatched, although the CLI writes that tag order too (/auto-compact, /pre-compact). - the migration runner wraps nothing in a transaction and better-sqlite3 autocommits every statement, so an interruption between the session_cache deletes and the search-table purge was permanent: the relaunch is already at db_version 9 and its SELECT no longer matches the rows it dropped, leaving those search entries orphaned for good. The purge test only seeded summaries, so dropping either of the other two LIKE branches left it green; it now seeds all three shapes plus their metrics, and a second test interrupts the purge midway and asserts no search entry outlives its session_cache row. --- db.js | 52 +++++++++------ test/db-purge-command-summaries.test.js | 86 +++++++++++++++++++++++-- 2 files changed, 112 insertions(+), 26 deletions(-) diff --git a/db.js b/db.js index 6f43097d..b860e690 100644 --- a/db.js +++ b/db.js @@ -237,31 +237,45 @@ const migrations = [ // still hits the fileMtime fast path and is left untouched). (db) => { try { + // Both tag orders the CLI writes: first, and + // first (/auto-compact, /pre-compact). const bad = db.prepare(`SELECT sessionId, folder FROM session_cache - WHERE summary LIKE '%' OR summary LIKE '%'` + WHERE summary LIKE '%' OR summary LIKE '%' + OR summary LIKE '%'` ).all(); if (bad.length === 0) return; const delRow = db.prepare('DELETE FROM session_cache WHERE sessionId = ?'); const delFolderMeta = db.prepare('DELETE FROM cache_meta WHERE folder = ?'); - for (const { sessionId, folder } of bad) { - delRow.run(sessionId); - if (folder) delFolderMeta.run(folder); - } - // Search entries live in tables created further down this file, so they - // may not exist yet on a DB this migration is the first to touch — - // prepared separately so a missing table throws here instead of skipping - // the purge above. External-content FTS5 ordering: fts delete first (it - // reads search_content), then content, then the map. - try { - const delFts = db.prepare("DELETE FROM search_fts WHERE rowid IN (SELECT rowid FROM search_map WHERE type = 'session' AND id = ?)"); - const delContent = db.prepare("DELETE FROM search_content WHERE rowid IN (SELECT rowid FROM search_map WHERE type = 'session' AND id = ?)"); - const delMap = db.prepare("DELETE FROM search_map WHERE type = 'session' AND id = ?"); - for (const { sessionId } of bad) { - delFts.run(sessionId); - delContent.run(sessionId); - delMap.run(sessionId); + // One transaction: a purge interrupted halfway cannot be resumed, because + // the next launch is already at db_version 9 and the SELECT above no + // longer matches the rows it dropped — whatever it left behind in the + // search tables would stay orphaned forever. + db.transaction(() => { + for (const { sessionId, folder } of bad) { + delRow.run(sessionId); + if (folder) delFolderMeta.run(folder); } - } catch {} + // Search entries and per-session metrics live in tables created further + // down this file, so they may not exist yet on a DB this migration is + // the first to touch — prepared separately so a missing table throws + // here instead of skipping the purge above. Metrics must go the same way + // deleteCachedSession drops them: a phantom's file is never re-read, so + // its rows would keep inflating the daily counts and totals for good. + // External-content FTS5 ordering: fts delete first (it reads + // search_content), then content, then the map. + try { + const delMetrics = db.prepare('DELETE FROM session_metrics WHERE sessionId = ?'); + const delFts = db.prepare("DELETE FROM search_fts WHERE rowid IN (SELECT rowid FROM search_map WHERE type = 'session' AND id = ?)"); + const delContent = db.prepare("DELETE FROM search_content WHERE rowid IN (SELECT rowid FROM search_map WHERE type = 'session' AND id = ?)"); + const delMap = db.prepare("DELETE FROM search_map WHERE type = 'session' AND id = ?"); + for (const { sessionId } of bad) { + delMetrics.run(sessionId); + delFts.run(sessionId); + delContent.run(sessionId); + delMap.run(sessionId); + } + } catch {} + })(); } catch {} }, ]; diff --git a/test/db-purge-command-summaries.test.js b/test/db-purge-command-summaries.test.js index dd2b492d..a7b2dfba 100644 --- a/test/db-purge-command-summaries.test.js +++ b/test/db-purge-command-summaries.test.js @@ -40,15 +40,28 @@ test('migration v9 purges slash-command summaries and re-opens their folder for db.prepare("INSERT OR REPLACE INTO settings (key, value) VALUES ('db_version', '8')").run(); const ins = db.prepare('INSERT INTO session_cache (sessionId, folder, projectPath, summary, modified) VALUES (?, ?, ?, ?, ?)'); ins.run('phantom', 'f1', '/tmp/p1', '/clear\\nclear', '2026-01-01T00:00:00Z'); + // The CLI also writes the envelope the other way round (/pre-compact), + // and a command's own output back as a user record. + ins.run('phantom-msg', 'f1', '/tmp/p1', 'pre-compact\\n/pre-compact', '2026-01-01T01:00:00Z'); + ins.run('phantom-out', 'f1', '/tmp/p1', 'Set model to Sonnet 5', '2026-01-01T02:00:00Z'); ins.run('real', 'f1', '/tmp/p1', 'fix the watcher', '2026-01-02T00:00:00Z'); ins.run('other-folder', 'f2', '/tmp/p2', 'unrelated work', '2026-01-03T00:00:00Z'); db.prepare('INSERT INTO cache_meta (folder, projectPath, indexMtimeMs) VALUES (?, ?, ?)').run('f1', '/tmp/p1', 123); db.prepare('INSERT INTO cache_meta (folder, projectPath, indexMtimeMs) VALUES (?, ?, ?)').run('f2', '/tmp/p2', 456); // Mirror upsertSearchEntries: map row first, then the content column // store, then the fts5 shadow row, all sharing the same rowid. - db.prepare('INSERT INTO search_map (rowid, id, type, folder) VALUES (1, ?, ?, ?)').run('phantom', 'session', 'f1'); - db.prepare('INSERT INTO search_content (rowid, title, body) VALUES (1, ?, ?)').run('/clear', 'body'); - db.prepare('INSERT INTO search_fts (rowid, title, body) VALUES (1, ?, ?)').run('/clear', 'body'); + const insMap = db.prepare('INSERT INTO search_map (rowid, id, type, folder) VALUES (?, ?, ?, ?)'); + const insContent = db.prepare('INSERT INTO search_content (rowid, title, body) VALUES (?, ?, ?)'); + const insFts = db.prepare('INSERT INTO search_fts (rowid, title, body) VALUES (?, ?, ?)'); + const insMetrics = db.prepare('INSERT INTO session_metrics (sessionId, date, model, messageCount) VALUES (?, ?, ?, ?)'); + let rowid = 0; + for (const id of ['phantom', 'phantom-msg', 'phantom-out', 'real']) { + rowid++; + insMap.run(rowid, id, 'session', 'f1'); + insContent.run(rowid, id, 'body'); + insFts.run(rowid, id, 'body'); + insMetrics.run(id, '2026-01-01', 'sonnet', 2); + } `, dir); assert.equal(seed.status, 0, seed.stderr); @@ -61,19 +74,23 @@ test('migration v9 purges slash-command summaries and re-opens their folder for console.log(JSON.stringify({ ids: db.prepare('SELECT sessionId FROM session_cache ORDER BY sessionId').all().map(r => r.sessionId), folders: db.prepare('SELECT folder FROM cache_meta ORDER BY folder').all().map(r => r.folder), - searchIds: db.prepare("SELECT id FROM search_map WHERE type = 'session'").all().map(r => r.id), + searchIds: db.prepare("SELECT id FROM search_map WHERE type = 'session' ORDER BY id").all().map(r => r.id), searchContent: db.prepare('SELECT COUNT(*) AS n FROM search_content').get().n, + metricIds: db.prepare('SELECT sessionId FROM session_metrics ORDER BY sessionId').all().map(r => r.sessionId), version: db.prepare("SELECT value FROM settings WHERE key = 'db_version'").get()?.value, })); `, dir); assert.equal(inspect.status, 0, inspect.stderr); const state = JSON.parse(inspect.stdout.trim().split('\n').pop()); - assert.deepEqual(state.ids, ['other-folder', 'real'], 'only the command-summary row is dropped'); + assert.deepEqual(state.ids, ['other-folder', 'real'], + 'every bookkeeping summary is dropped, whichever envelope tag opens it; real rows stay'); assert.deepEqual(state.folders, ['f2'], 'the affected folder loses its index gate so reconcile re-reads the purged file; untouched folders keep theirs'); - assert.deepEqual(state.searchIds, [], 'the purged row leaves no stale search entry behind'); - assert.equal(state.searchContent, 0); + assert.deepEqual(state.searchIds, ['real'], 'the purged rows leave no stale search entry behind'); + assert.equal(state.searchContent, 1); + assert.deepEqual(state.metricIds, ['real'], + 'a phantom sits on a file that is never re-read, so its metrics would inflate the daily counts and totals forever'); assert.equal(state.version, '9'); } finally { fs.rmSync(dir, { recursive: true, force: true }); @@ -115,3 +132,58 @@ test('migration v9 is a no-op on a database with no command summaries', () => { fs.rmSync(dir, { recursive: true, force: true }); } }); + +// The migration runner has no transaction of its own and better-sqlite3 +// autocommits every statement, so an interruption between the session_cache +// deletes and the search-table purge used to be permanent: the relaunch is +// already at db_version 9 and its SELECT no longer matches the rows it dropped, +// leaving their search entries orphaned for good. The trigger below stands in +// for that interruption -- it aborts the purge midway through the loop. +test('migration v9 leaves no orphaned search entry when the purge is interrupted', () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'switchboard-cmd-purge-atomic-')); + try { + const init = loadDbModule(dir); + assert.equal(init.status, 0, init.stderr); + + const seed = runInElectronNode(` + const Database = require('better-sqlite3'); + const db = new Database(require('path').join(process.env.SWITCHBOARD_DATA_DIR, 'switchboard.db')); + db.prepare("INSERT OR REPLACE INTO settings (key, value) VALUES ('db_version', '8')").run(); + const ins = db.prepare('INSERT INTO session_cache (sessionId, folder, projectPath, summary, modified) VALUES (?, ?, ?, ?, ?)'); + const insMap = db.prepare('INSERT INTO search_map (rowid, id, type, folder) VALUES (?, ?, ?, ?)'); + const insContent = db.prepare('INSERT INTO search_content (rowid, title, body) VALUES (?, ?, ?)'); + const insFts = db.prepare('INSERT INTO search_fts (rowid, title, body) VALUES (?, ?, ?)'); + let rowid = 0; + for (const [id, folder] of [['p1', 'f1'], ['p2', 'f2']]) { + ins.run(id, folder, '/tmp/' + folder, '/clear', '2026-01-01T00:00:00Z'); + db.prepare('INSERT INTO cache_meta (folder, projectPath, indexMtimeMs) VALUES (?, ?, ?)').run(folder, '/tmp/' + folder, 1); + rowid++; + insMap.run(rowid, id, 'session', folder); + insContent.run(rowid, id, 'body'); + insFts.run(rowid, id, 'body'); + } + db.exec("CREATE TRIGGER purge_interrupted BEFORE DELETE ON cache_meta WHEN OLD.folder = 'f2' BEGIN SELECT RAISE(ABORT, 'interrupted'); END"); + `, dir); + assert.equal(seed.status, 0, seed.stderr); + + const r = loadDbModule(dir); + assert.equal(r.status, 0, r.stderr); + + const inspect = runInElectronNode(` + const Database = require('better-sqlite3'); + const db = new Database(require('path').join(process.env.SWITCHBOARD_DATA_DIR, 'switchboard.db'), { readonly: true }); + console.log(JSON.stringify({ + orphans: db.prepare(\`SELECT m.id FROM search_map m + WHERE m.type = 'session' + AND NOT EXISTS (SELECT 1 FROM session_cache c WHERE c.sessionId = m.id)\`).all().map(r => r.id), + })); + `, dir); + assert.equal(inspect.status, 0, inspect.stderr); + const state = JSON.parse(inspect.stdout.trim().split('\n').pop()); + + assert.deepEqual(state.orphans, [], + 'an interrupted purge must roll back whole: a row dropped from session_cache without its search entry is unreachable and never retried'); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); From 2f761bb4c50e32d35baa836533fba817df76e649 Mon Sep 17 00:00:00 2001 From: Jean-Baptiste Date: Wed, 2 Sep 2026 16:47:49 +0200 Subject: [PATCH 4/4] docs(session-cache): correct what /clear and /model do to the transcript /model does not open a new jsonl. It is written into the transcript already open -- user records #51, #104 and #2 in three transcripts on this machine. The code is right either way, since a /model at #51 is never a summary candidate, but the written justification was not. Also records the two tag orders a command envelope comes in, why the bookkeeping test is anchored, and what migration v9 now purges. The relaunch comment in db-initial-scan-marker.test.js had its version number bumped to 9 without saying what that implies: at 9 no migration runs again, the v8 marker backfill included. --- .ai/contexts/session-cache.md | 2 +- test/db-initial-scan-marker.test.js | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/.ai/contexts/session-cache.md b/.ai/contexts/session-cache.md index 1e87adfd..bac45bb2 100644 --- a/.ai/contexts/session-cache.md +++ b/.ai/contexts/session-cache.md @@ -51,7 +51,7 @@ From `derive-project-path.js`: `deriveProjectPath(folderPath)`, `resolveWorktree - **`resolveWorktreePath` collapses `/.worktrees/` → ``** when the parent dir exists. Consequence: many `~/.claude/projects/-home-...workspace-myproject--worktrees-X` folders derive to the same projectPath. Callers must dedupe (see `get-work-files` IPC for the pattern). - **Two-table sidebar payload**: projects are aggregated, but each session row has its own `subagentType` field. A `null`/empty `subagentType` means it's a parent session; anything else (e.g. `'general-purpose'`, `'researcher'`) marks a subagent. - **`fs.watch` debouncing**: the watcher batches per-folder events in a `pendingChanges = Map | true>` for ~200 ms before flushing to `refreshFolder`. A `true` value means "full walk needed" (rare path). -- **A session's title comes from its first *real* user turn, and a transcript without one is not indexed.** `classifyUserText()` in `read-session-file.js` sorts each user record into `prompt` / `command` / `skip`. `skip` is local-command bookkeeping (``, ``, ``, `` — the CLI writes a command's own output back as a `user` record too); `command` is a bare slash-command record (`/clear…`). This matters because **`/clear` and `/model` do not stay in the current transcript — the CLI opens a NEW jsonl and writes only that bookkeeping into it**. Taking a `command` record as the summary therefore (a) titled every session started by `/clear` "`/clear clear ` or ``, plus the `cache_meta` gate of their folders, which makes the next reconcile re-read exactly those files. +- **A session's title comes from its first *real* user turn, and a transcript without one is not indexed.** `classifyUserText()` in `read-session-file.js` sorts each user record into `prompt` / `command` / `skip`. `skip` is local-command bookkeeping (``, ``, ``, `` — the CLI writes a command's own output back as a `user` record too); `command` is a bare slash-command record, recognised by a `` tag next to a `` or `` one — the CLI writes both orders (`` first for `/clear`, `` first for `/auto-compact` and `/pre-compact`), so neither tag can be required to come first. The `skip` test is anchored to the start of the record: a real prompt that *quotes* `` (a pasted transcript excerpt) is a turn, and skipping it can leave a session with no indexable prompt at all. This matters because **`/clear` opens a NEW jsonl and writes only that bookkeeping into it**; `/model`, by contrast, is written into the transcript that is already open, so it is a summary candidate only when it lands before any real prompt. Taking a `command` record as the summary therefore (a) titled every session started by `/clear` "`/clear clear `, `` or `` — from `session_cache`, the three search tables and `session_metrics` (a phantom's file is never re-read, so its metrics would inflate the heatmap and the totals forever) — plus the `cache_meta` gate of their folders, which makes the next reconcile re-read exactly those files. The whole purge runs in one transaction: it cannot be resumed, since the relaunch that follows an interrupted run is already at db_version 9 and no longer matches the rows it dropped. - **Stats `firstSessionDate`** is computed from `MIN(modified)`, not `MIN(created)`. Old sessions touched by recent reads keep their original `created` but their `modified` reflects the latest indexing — by design (the heatmap measures activity, not creation). - **`main.js`'s `ctx.db` is a hand-built allow-list, not a spread of `db.js`.** `main.js` (~line 323) passes `sessionCache.init({ ..., db: { deleteCachedFolder, getCachedByFolder, upsertCachedSessions, ... } })` as an explicit object literal — it does **not** do `db: require('./db')`. If you add a new function to `db.js` and call it from `session-cache.js` via `ctx.db.`, but forget to add it to both this literal *and* the `require('./db')` destructure at the top of `main.js`, `ctx.db.` is `undefined`. The resulting `TypeError` is thrown inside `populateCacheViaWorker`'s `worker.on('message')` handler, which has no `try/catch` — it lands on stderr (not `electron-log`) and silently aborts the cold-start indexing write loop. Symptom: the log shows `Indexing N projects…` but never `Indexed N sessions across …`, and the affected table stays empty. Guarded by `test/main-ctx-db-wiring.test.js` (static source-grep asserting the allow-list ⊇ every `ctx.db.*` dereference in `session-cache.js`) — run it whenever you touch this boundary, but also update the allow-list by hand since the test only catches *missing* entries, not the intent. diff --git a/test/db-initial-scan-marker.test.js b/test/db-initial-scan-marker.test.js index 016ca9a3..00af3dff 100644 --- a/test/db-initial-scan-marker.test.js +++ b/test/db-initial-scan-marker.test.js @@ -122,7 +122,8 @@ test('the backfill runs once: a partial cache after an interrupted post-marker s `, dir); assert.equal(seed.status, 0, seed.stderr); - // Relaunch: db_version is already 9, so migration v8 must not run again. + // Relaunch: db_version is already 9, so no migration runs again -- the v8 + // marker backfill included. // If it did, the partial cache would be blessed as complete and the next // get-projects would take the warm branch straight into the synchronous // reconcile sweep -- the freeze the marker exists to prevent.