fix(indexer): force build purges stale sessions instead of only clearing index_state

The skill's `--build` (always a force build) cleared `index_state` and
re-indexed existing files, but never removed rows for transcripts that no
longer exist on disk, so deleted sessions accumulated forever (the dogfood
showed 328 indexed sessions vs 283 current files). A force build is meant to
be a clean rebuild, matching what the app already does.

Drop every derived table (messages, tool_calls, tool_results, sessions,
summaries, subagents, workflows, workflow_agents) in the force path, then
re-index from the current files. `memories` is the durable, human-approved
layer and is never cleared; messages_fts is repopulated by the existing
'rebuild' command in finalize.

Add a test that builds two sessions, deletes one transcript, force-rebuilds,
and asserts the stale session is purged while a seeded memory survives.
Verified the test is discriminating: without the fix it reports
['gone','keep'] instead of ['keep'].

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
tommy0103
2026-07-10 03:18:52 +08:00
co-authored by Claude Opus 4.8
parent 01a390fa10
commit c964653362
2 changed files with 42 additions and 1 deletions
+8
View File
@@ -148,6 +148,14 @@ function buildIndex({ force = false } = {}) {
if (force) { if (force) {
db.prepare("DELETE FROM index_state WHERE jsonl_path != '__last_build__'").run(); db.prepare("DELETE FROM index_state WHERE jsonl_path != '__last_build__'").run();
// Clearing index_state alone re-indexes existing files but leaves rows for
// files that no longer exist on disk (stale sessions accumulate). A force
// build is a clean rebuild: drop every derived table, then re-index from the
// current files. `memories` is the durable, human-approved layer and is never
// cleared; messages_fts is repopulated by the 'rebuild' command in finalize.
for (const table of ['messages', 'tool_calls', 'tool_results', 'sessions', 'summaries', 'subagents', 'workflows', 'workflow_agents']) {
db.prepare(`DELETE FROM ${table}`).run();
}
} }
const files = [ const files = [
+34 -1
View File
@@ -8,7 +8,7 @@
import { test } from 'node:test'; import { test } from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { createRequire } from 'node:module'; import { createRequire } from 'node:module';
import { mkdtempSync, mkdirSync, writeFileSync, appendFileSync, utimesSync, statSync } from 'node:fs'; import { mkdtempSync, mkdirSync, writeFileSync, appendFileSync, utimesSync, statSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os'; import { tmpdir } from 'node:os';
import { dirname, join, resolve } from 'node:path'; import { dirname, join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
@@ -68,3 +68,36 @@ test('incremental buildIndex resumes from cursor and accumulates message_count',
assert.equal(afterAppend.n, 4, 'exactly four messages, no duplicates'); assert.equal(afterAppend.n, 4, 'exactly four messages, no duplicates');
assert.equal(afterAppend.lp, 4, 'cursor advanced to 4 lines'); assert.equal(afterAppend.lp, 4, 'cursor advanced to 4 lines');
}); });
test('force build purges sessions for deleted files and preserves memories', () => {
const home = mkdtempSync(join(tmpdir(), 'obelisk-force-'));
const projDir = join(home, '.claude', 'projects', '-tmp-proj');
mkdirSync(projDir, { recursive: true });
const keep = join(projDir, 'keep.jsonl');
const gone = join(projDir, 'gone.jsonl');
writeFileSync(keep, line('k1', 'user', '2026-06-10T10:00:00Z') + '\n');
writeFileSync(gone, line('g1', 'user', '2026-06-10T10:00:00Z') + '\n');
assert.equal(runRuntime(['--build'], home).status, 0);
const dbPath = join(home, '.obelisk', 'obelisk.sqlite');
let db = new DatabaseSync(dbPath);
// Seed a durable memory that must survive a clean rebuild.
db.prepare("INSERT INTO memories (id, path, summary, created_at) VALUES ('mem-keep', '/tmp/proj/.obelisk/memories/x.md', 'durable note', '2026-06-10T10:00:00Z')").run();
assert.equal(db.prepare('SELECT COUNT(*) c FROM sessions').get().c, 2, 'both sessions indexed initially');
db.close();
// Delete one transcript, then force a clean rebuild (`--build` is always force).
rmSync(gone);
clearBuildDebounce(home);
assert.equal(runRuntime(['--build'], home).status, 0);
db = new DatabaseSync(dbPath);
const sessionIds = db.prepare('SELECT id FROM sessions ORDER BY id').all().map(r => r.id);
const messageCount = db.prepare('SELECT COUNT(*) c FROM messages').get().c;
const memoryAlive = db.prepare("SELECT COUNT(*) c FROM memories WHERE id='mem-keep' AND deleted_at IS NULL").get().c;
db.close();
assert.deepEqual(sessionIds, ['keep'], 'stale session for the deleted file is purged');
assert.equal(messageCount, 1, 'only the surviving file\'s message remains');
assert.equal(memoryAlive, 1, 'the durable memory survived the force rebuild');
});