2026-07-10 18:10:45 +08:00
|
|
|
// Regression tests for the write-transaction runner (docs/adr/0006):
|
|
|
|
|
// - a transient BUSY (auto-rolled-back txn) is retried and recovers;
|
|
|
|
|
// - a persistent BUSY exhausts retries, and that file is SKIPPED, not fatal;
|
|
|
|
|
// - the guarded rollback never masks the real error ("cannot rollback ...").
|
2026-07-10 16:36:36 +08:00
|
|
|
import { test } from 'node:test';
|
|
|
|
|
import assert from 'node:assert/strict';
|
|
|
|
|
import { createRequire } from 'node:module';
|
|
|
|
|
import { mkdtempSync, mkdirSync, writeFileSync } from 'node:fs';
|
|
|
|
|
import { tmpdir } from 'node:os';
|
|
|
|
|
import { join } from 'node:path';
|
|
|
|
|
|
|
|
|
|
const require = createRequire(import.meta.url);
|
|
|
|
|
import { buildIndex } from '../app/src/main/indexer.ts';
|
|
|
|
|
const { DatabaseSync } = require('node:sqlite');
|
|
|
|
|
|
2026-07-10 18:10:45 +08:00
|
|
|
// Wraps node:sqlite and simulates SQLite auto-rollback-on-error: a poisoned write
|
|
|
|
|
// throws a BUSY-like error AND ends the real transaction, so a following explicit
|
|
|
|
|
// ROLLBACK errors with "no transaction is active". `shouldPoison(args)` decides
|
|
|
|
|
// which writes are poisoned.
|
|
|
|
|
function makeDbClass(shouldPoison) {
|
|
|
|
|
return class PoisonDatabase {
|
|
|
|
|
constructor(dbPath) {
|
|
|
|
|
this.db = new DatabaseSync(dbPath);
|
2026-07-10 16:36:36 +08:00
|
|
|
this.inTxn = false;
|
|
|
|
|
}
|
2026-07-10 18:10:45 +08:00
|
|
|
get inTransaction() { return this.inTxn; }
|
|
|
|
|
pragma(statement) { this.db.exec(`PRAGMA ${statement}`); }
|
|
|
|
|
exec(sql) {
|
|
|
|
|
const head = sql.trim().slice(0, 8).toUpperCase();
|
|
|
|
|
if (head.startsWith('BEGIN')) { this.inTxn = true; return this.db.exec('BEGIN'); }
|
|
|
|
|
if (head.startsWith('COMMIT')) { this.inTxn = false; return this.db.exec('COMMIT'); }
|
|
|
|
|
if (head.startsWith('ROLLBACK')) {
|
|
|
|
|
if (!this.inTxn) throw new Error('cannot rollback - no transaction is active');
|
|
|
|
|
this.inTxn = false;
|
|
|
|
|
return this.db.exec('ROLLBACK');
|
|
|
|
|
}
|
|
|
|
|
return this.db.exec(sql);
|
|
|
|
|
}
|
|
|
|
|
prepare(sql) {
|
|
|
|
|
const stmt = this.db.prepare(sql);
|
|
|
|
|
const self = this;
|
|
|
|
|
return {
|
|
|
|
|
get: (...args) => stmt.get(...args),
|
|
|
|
|
all: (...args) => stmt.all(...args),
|
|
|
|
|
run: (...args) => {
|
|
|
|
|
if (self.inTxn && shouldPoison(args)) {
|
|
|
|
|
self.db.exec('ROLLBACK'); // SQLite auto-rolled the txn back on the error
|
|
|
|
|
self.inTxn = false;
|
|
|
|
|
throw new Error('SQLITE_BUSY: database is locked');
|
|
|
|
|
}
|
|
|
|
|
return stmt.run(...args);
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
close() { return this.db.close(); }
|
|
|
|
|
};
|
2026-07-10 16:36:36 +08:00
|
|
|
}
|
|
|
|
|
|
2026-07-10 18:10:45 +08:00
|
|
|
function twoFileHome(alphaContent, betaContent) {
|
|
|
|
|
const home = mkdtempSync(join(tmpdir(), 'obelisk-tx-'));
|
2026-07-10 16:36:36 +08:00
|
|
|
const projectDir = join(home, '.claude', 'projects', '-tmp-proj');
|
|
|
|
|
mkdirSync(projectDir, { recursive: true });
|
|
|
|
|
mkdirSync(join(home, '.obelisk'), { recursive: true });
|
2026-07-10 18:10:45 +08:00
|
|
|
const msg = (uuid, content) => JSON.stringify({
|
|
|
|
|
uuid, type: 'user', timestamp: '2026-06-10T10:00:00Z', cwd: '/tmp/proj',
|
|
|
|
|
message: { role: 'user', content },
|
|
|
|
|
}) + '\n';
|
|
|
|
|
writeFileSync(join(projectDir, 'alpha.jsonl'), msg('a1', alphaContent));
|
|
|
|
|
writeFileSync(join(projectDir, 'beta.jsonl'), msg('b1', betaContent));
|
|
|
|
|
return { home, dbPath: join(home, '.obelisk', 'obelisk.sqlite'), projectsDir: join(home, '.claude', 'projects') };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function subagentHome(description = 'first description') {
|
|
|
|
|
const home = mkdtempSync(join(tmpdir(), 'obelisk-meta-tx-'));
|
|
|
|
|
const projectDir = join(home, '.claude', 'projects', '-tmp-proj');
|
|
|
|
|
const subagentDir = join(projectDir, 'session', 'subagents');
|
|
|
|
|
const dbPath = join(home, '.obelisk', 'obelisk.sqlite');
|
|
|
|
|
mkdirSync(subagentDir, { recursive: true });
|
|
|
|
|
mkdirSync(join(home, '.obelisk'), { recursive: true });
|
|
|
|
|
const message = uuid => JSON.stringify({
|
|
|
|
|
uuid,
|
|
|
|
|
type: 'user',
|
|
|
|
|
timestamp: '2026-06-10T10:00:00Z',
|
|
|
|
|
cwd: '/tmp/proj',
|
|
|
|
|
message: { role: 'user', content: `message ${uuid}` },
|
|
|
|
|
}) + '\n';
|
|
|
|
|
writeFileSync(join(projectDir, 'session.jsonl'), message('main-message'));
|
|
|
|
|
writeFileSync(join(subagentDir, 'agent.jsonl'), message('agent-message'));
|
|
|
|
|
const metaPath = join(subagentDir, 'agent.meta.json');
|
|
|
|
|
writeFileSync(metaPath, JSON.stringify({ agentType: 'Explore', description }));
|
|
|
|
|
return {
|
|
|
|
|
home,
|
|
|
|
|
dbPath,
|
|
|
|
|
projectsDir: join(home, '.claude', 'projects'),
|
|
|
|
|
metaPath,
|
|
|
|
|
changedMetaPath: join('-tmp-proj', 'session', 'subagents', 'agent.meta.json'),
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function run(home, dbPath, projectsDir, DatabaseImpl) {
|
|
|
|
|
return buildIndex({
|
|
|
|
|
force: true,
|
|
|
|
|
claudeDir: join(home, '.claude'),
|
|
|
|
|
codexDir: join(home, '.codex'),
|
|
|
|
|
projectsDir,
|
|
|
|
|
dbPath,
|
|
|
|
|
DatabaseImpl,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function makeBeginBusyDbClass(shouldFail) {
|
|
|
|
|
const Base = makeDbClass(() => false);
|
|
|
|
|
return class BeginBusyDatabase extends Base {
|
|
|
|
|
constructor(dbPath) {
|
|
|
|
|
super(dbPath);
|
|
|
|
|
this.isWriterLease = dbPath.endsWith('writer.lock.sqlite');
|
|
|
|
|
this.beginCalls = 0;
|
|
|
|
|
}
|
|
|
|
|
exec(sql) {
|
|
|
|
|
if (!this.isWriterLease && sql.trim().toUpperCase().startsWith('BEGIN')) {
|
|
|
|
|
this.beginCalls += 1;
|
|
|
|
|
if (shouldFail(this.beginCalls)) {
|
|
|
|
|
throw Object.assign(new Error('database is locked'), { code: 'SQLITE_BUSY' });
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return super.exec(sql);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
test('a failed changed file is not reported as an affected session', () => {
|
|
|
|
|
const { home, dbPath, projectsDir } = twoFileHome('POISON alpha', 'hello beta');
|
|
|
|
|
const Db = makeDbClass((args) => args.some(a => typeof a === 'string' && a.includes('POISON')));
|
|
|
|
|
|
|
|
|
|
const result = buildIndex({
|
|
|
|
|
force: false,
|
|
|
|
|
claudeDir: join(home, '.claude'),
|
|
|
|
|
codexDir: join(home, '.codex'),
|
|
|
|
|
projectsDir,
|
|
|
|
|
dbPath,
|
|
|
|
|
DatabaseImpl: Db,
|
|
|
|
|
changedPaths: ['-tmp-proj/alpha.jsonl'],
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
assert.deepEqual(result.affectedSessionIds, []);
|
|
|
|
|
assert.equal(result.skipped, 1);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('a transient BUSY during force cleanup is retried and recovers', () => {
|
|
|
|
|
const { home, dbPath, projectsDir } = twoFileHome('hello alpha', 'hello beta');
|
|
|
|
|
// Fire exactly once, on the first write inside a transaction, then never again.
|
|
|
|
|
let fired = false;
|
|
|
|
|
const Db = makeDbClass(() => (fired ? false : (fired = true)));
|
2026-07-10 16:36:36 +08:00
|
|
|
|
|
|
|
|
let result;
|
2026-07-10 18:10:45 +08:00
|
|
|
assert.doesNotThrow(() => { result = run(home, dbPath, projectsDir, Db); });
|
2026-07-10 16:36:36 +08:00
|
|
|
|
|
|
|
|
const check = new DatabaseSync(dbPath);
|
|
|
|
|
const sessions = check.prepare('SELECT COUNT(*) AS c FROM sessions').get().c;
|
|
|
|
|
check.close();
|
2026-07-10 18:10:45 +08:00
|
|
|
assert.equal(sessions, 2, 'the retried file recovered; both files indexed');
|
|
|
|
|
assert.equal(result.skipped, 0, 'nothing was skipped');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('a transient BUSY during a file transaction is retried and recovers', () => {
|
|
|
|
|
const { home, dbPath, projectsDir } = twoFileHome('hello alpha', 'hello beta');
|
|
|
|
|
let fired = false;
|
|
|
|
|
const Db = makeDbClass((args) => {
|
|
|
|
|
const isAlphaWrite = args.some(arg => typeof arg === 'string' && arg.includes('hello alpha'));
|
|
|
|
|
if (!isAlphaWrite || fired) return false;
|
|
|
|
|
fired = true;
|
|
|
|
|
return true;
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const result = run(home, dbPath, projectsDir, Db);
|
|
|
|
|
const check = new DatabaseSync(dbPath);
|
|
|
|
|
const sessions = check.prepare('SELECT COUNT(*) AS c FROM sessions').get().c;
|
|
|
|
|
check.close();
|
|
|
|
|
assert.equal(sessions, 2);
|
|
|
|
|
assert.equal(result.skipped, 0);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('a persistent BUSY exhausts retries and skips just that file, not the build', () => {
|
|
|
|
|
const { home, dbPath, projectsDir } = twoFileHome('POISON alpha', 'hello beta');
|
|
|
|
|
// Always poison writes that carry alpha's marker text; beta is untouched.
|
|
|
|
|
const Db = makeDbClass((args) => args.some(a => typeof a === 'string' && a.includes('POISON')));
|
|
|
|
|
|
|
|
|
|
let result;
|
|
|
|
|
assert.doesNotThrow(() => { result = run(home, dbPath, projectsDir, Db); });
|
|
|
|
|
|
|
|
|
|
const check = new DatabaseSync(dbPath);
|
|
|
|
|
const sessions = check.prepare('SELECT id FROM sessions ORDER BY id').all().map(r => r.id);
|
|
|
|
|
check.close();
|
|
|
|
|
assert.deepEqual(sessions, ['beta'], 'the persistently-failing file is skipped; the other indexes');
|
|
|
|
|
assert.equal(result.skipped, 1, 'the skipped file is reported in the build result');
|
|
|
|
|
assert.equal(result.skippedFiles[0].diagnostics?.phase, 'work', 'diagnostics record the failing phase');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('BEGIN contention during force cleanup defers the build', () => {
|
|
|
|
|
const { home, dbPath, projectsDir } = twoFileHome('hello alpha', 'hello beta');
|
|
|
|
|
const Db = makeBeginBusyDbClass(beginCall => beginCall === 1);
|
|
|
|
|
|
|
|
|
|
const result = run(home, dbPath, projectsDir, Db);
|
|
|
|
|
assert.equal(result.deferred, true);
|
|
|
|
|
assert.equal(result.reason, 'database_busy');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('BEGIN contention during finalize defers the build', () => {
|
|
|
|
|
const { home, dbPath, projectsDir } = twoFileHome('hello alpha', 'hello beta');
|
|
|
|
|
const Db = makeBeginBusyDbClass(beginCall => beginCall === 3);
|
|
|
|
|
|
|
|
|
|
const result = buildIndex({
|
|
|
|
|
force: false,
|
|
|
|
|
claudeDir: join(home, '.claude'),
|
|
|
|
|
codexDir: join(home, '.codex'),
|
|
|
|
|
projectsDir,
|
|
|
|
|
dbPath,
|
|
|
|
|
DatabaseImpl: Db,
|
|
|
|
|
});
|
|
|
|
|
assert.equal(result.deferred, true);
|
|
|
|
|
assert.equal(result.reason, 'database_busy');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('a finalize database error is propagated instead of swallowed as malformed input', () => {
|
|
|
|
|
const { home, dbPath, projectsDir } = twoFileHome('hello alpha', 'hello beta');
|
|
|
|
|
const workflowDir = join(projectsDir, '-tmp-proj', 'alpha', 'workflows');
|
|
|
|
|
mkdirSync(workflowDir, { recursive: true });
|
|
|
|
|
writeFileSync(join(workflowDir, 'run.json'), JSON.stringify({
|
|
|
|
|
runId: 'workflow-1',
|
|
|
|
|
workflowName: 'POISON WORKFLOW',
|
|
|
|
|
}));
|
|
|
|
|
const Db = makeDbClass(args => args.some(arg => typeof arg === 'string' && arg.includes('POISON WORKFLOW')));
|
|
|
|
|
|
|
|
|
|
assert.throws(() => buildIndex({
|
|
|
|
|
force: false,
|
|
|
|
|
claudeDir: join(home, '.claude'),
|
|
|
|
|
codexDir: join(home, '.codex'),
|
|
|
|
|
projectsDir,
|
|
|
|
|
dbPath,
|
|
|
|
|
DatabaseImpl: Db,
|
|
|
|
|
}), /SQLITE_BUSY/);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('a changed subagent meta file is applied and reported only after its file transaction commits', () => {
|
|
|
|
|
const { home, dbPath, projectsDir, metaPath, changedMetaPath } = subagentHome();
|
|
|
|
|
const Db = makeDbClass(() => false);
|
|
|
|
|
buildIndex({
|
|
|
|
|
force: true,
|
|
|
|
|
claudeDir: join(home, '.claude'),
|
|
|
|
|
codexDir: join(home, '.codex'),
|
|
|
|
|
projectsDir,
|
|
|
|
|
dbPath,
|
|
|
|
|
DatabaseImpl: Db,
|
|
|
|
|
});
|
|
|
|
|
writeFileSync(metaPath, JSON.stringify({ agentType: 'Explore', description: 'updated description' }));
|
|
|
|
|
|
|
|
|
|
const result = buildIndex({
|
|
|
|
|
force: false,
|
|
|
|
|
changedPaths: [changedMetaPath],
|
|
|
|
|
claudeDir: join(home, '.claude'),
|
|
|
|
|
codexDir: join(home, '.codex'),
|
|
|
|
|
projectsDir,
|
|
|
|
|
dbPath,
|
|
|
|
|
DatabaseImpl: Db,
|
|
|
|
|
});
|
|
|
|
|
const check = new DatabaseSync(dbPath, { readOnly: true });
|
|
|
|
|
assert.equal(check.prepare('SELECT description FROM subagents WHERE agent_id=?').get('agent').description, 'updated description');
|
|
|
|
|
check.close();
|
|
|
|
|
assert.deepEqual(result.affectedSessionIds, ['session']);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('a failed subagent meta transaction does not report its session as affected', () => {
|
|
|
|
|
const { home, dbPath, projectsDir, metaPath, changedMetaPath } = subagentHome();
|
|
|
|
|
buildIndex({
|
|
|
|
|
force: true,
|
|
|
|
|
claudeDir: join(home, '.claude'),
|
|
|
|
|
codexDir: join(home, '.codex'),
|
|
|
|
|
projectsDir,
|
|
|
|
|
dbPath,
|
|
|
|
|
DatabaseImpl: makeDbClass(() => false),
|
|
|
|
|
});
|
|
|
|
|
writeFileSync(metaPath, JSON.stringify({ agentType: 'Explore', description: 'POISON META' }));
|
|
|
|
|
const FailingDb = makeDbClass(args => args.some(arg => typeof arg === 'string' && arg.includes('POISON META')));
|
|
|
|
|
|
|
|
|
|
const result = buildIndex({
|
|
|
|
|
force: false,
|
|
|
|
|
changedPaths: [changedMetaPath],
|
|
|
|
|
claudeDir: join(home, '.claude'),
|
|
|
|
|
codexDir: join(home, '.codex'),
|
|
|
|
|
projectsDir,
|
|
|
|
|
dbPath,
|
|
|
|
|
DatabaseImpl: FailingDb,
|
|
|
|
|
});
|
|
|
|
|
assert.deepEqual(result.affectedSessionIds, []);
|
|
|
|
|
assert.equal(result.skipped, 1);
|
2026-07-10 16:36:36 +08:00
|
|
|
});
|