Implement the full ADR-0006 plan: three-layer separation of transaction
correctness, retry policy, and cross-process writer coordination.
Layer 1 — scripts/tx.ts (transaction correctness):
- runWriteTransaction executes work exactly once; no internal retry.
- BEGIN IMMEDIATE takes the write lock up front (avoids SQLITE_BUSY_SNAPSHOT).
- Guarded rollback: checks inTransaction() via adapter before attempting
ROLLBACK; never masks the primary exception.
- WriteTxDiagnostics attached to errors: phase, code, label,
rollbackSucceeded, rollbackError, transactionActive.
- Binding adapters (betterSqliteTransactionAdapter, nodeSqliteTransactionAdapter)
mapping better-sqlite3's `.inTransaction` and node:sqlite's `.isTransaction`.
- configureConnection centralizes WAL + synchronous + busy_timeout.
Layer 2 — scripts/write-coordinator.ts (retry policy):
- runRetryableWriteTransaction: bounded retry with total time budget.
- Only retries when the transaction confirmed ended (transactionActive=false)
and the error is SQLITE_BUSY during work/commit phase.
- BEGIN-phase BUSY = abort entire build (isBeginBusyFailure); the caller
returns `{ deferred: true, reason: 'writer_busy' }` instead of waiting.
- hasUnusableTransaction detects a still-active transaction after failure;
aborts the build immediately, never retries.
Layer 3 — scripts/writer-lease.ts (cross-process coordination):
- acquireWriterLease: dedicated writer.lock.sqlite with busy_timeout=0 +
BEGIN IMMEDIATE. Non-blocking attempt; bounded wait with retryDelayMs.
- writerLockPathFor derives lock path from the target DB path.
- Lease held for the entire build; released on completion or failure.
- Lock DB uses DELETE journal (not WAL); crash/close auto-releases.
- All consumers obey: skill acquires at build start (returns deferred if
unavailable); app daemon (via worker) acquires for its build cycle.
Build semantics changes:
- affectedSessionIds updated only after successful commit.
- BuildIndexResult gains skipped/skippedFiles for observability.
- Skill finalize failure now fails the build (was silently warned).
- Checkpoint changed to PASSIVE (TRUNCATE reserved for maintenance/exit).
- Skill buildIndex returns { deferred, reason } on lease contention;
indexer-service reschedules the build (deferredRetryMs) without publishing
a heartbeat (so the build-deferred state is visible to cross-process
arbitration).
- Service publishes heartbeat immediately on start() for correct arbitration.
Tests:
- tests/write-transaction.test.mjs: single-shot execution, diagnostics
propagation, auto-rolled-back transaction detected, rollback failure
captured as metadata, BEGIN IMMEDIATE semantics.
- tests/writer-lease.test.mjs: acquire/release, contention returns null,
bounded wait with release during budget.
- tests/app-writer-lease.test.mjs: better-sqlite3 adapter integration.
- tests/app-rollback-guard.test.mjs: rewritten — transient BUSY recovered
by coordinator, persistent BUSY skips file, begin-busy aborts build,
live-transaction aborts build, phantom affectedSessionIds prevented.
- tests/daemon-arbitration.test.mjs: skill defers to fresh app heartbeat,
builds when heartbeat is stale.
- tests/app-indexer-service.test.mjs: new cases for deferred-retry
scheduling and immediate heartbeat on start.
- app/tests/electron-concurrency.mjs + child: dual-child IPC structure for
real better-sqlite3 contention (holder acquires lock → build child starts
→ delayed release → result collected; persistent contention bounded).
ADR-0006 updated to reflect the implemented design.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
297 lines
11 KiB
JavaScript
297 lines
11 KiB
JavaScript
// 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 ...").
|
|
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');
|
|
|
|
// 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);
|
|
this.inTxn = false;
|
|
}
|
|
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(); }
|
|
};
|
|
}
|
|
|
|
function twoFileHome(alphaContent, betaContent) {
|
|
const home = mkdtempSync(join(tmpdir(), 'obelisk-tx-'));
|
|
const projectDir = join(home, '.claude', 'projects', '-tmp-proj');
|
|
mkdirSync(projectDir, { recursive: true });
|
|
mkdirSync(join(home, '.obelisk'), { recursive: true });
|
|
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)));
|
|
|
|
let result;
|
|
assert.doesNotThrow(() => { 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, '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);
|
|
});
|