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>
92 lines
3.4 KiB
TypeScript
92 lines
3.4 KiB
TypeScript
// Obelisk Core (see docs/adr/0003-core-typescript-esm-precompiled.md).
|
|
//
|
|
// The single shared implementation behind every transport. runtime.mjs (skill),
|
|
// and later the CLI and MCP server, are thin shells over these four functions;
|
|
// none of them re-implement retrieval or own the DB lifecycle.
|
|
//
|
|
// Authored in TypeScript with erasable-only syntax so Node can run it directly
|
|
// via type stripping in development, while the skill artifact ships the tsc
|
|
// output (Phase 6). The heavy internals (db/indexer/query) remain .mjs for now
|
|
// and are migrated in later phases; Core is the typed seam over them.
|
|
|
|
import { createContext, runInNewContext } from 'node:vm';
|
|
|
|
import { DB_PATH, openDb, openReadDb, openWriterLeaseDb } from './db.mjs';
|
|
import { buildIndex, shouldSkipBuild } from './indexer.mjs';
|
|
import { createQueryApi, createAttuneApi } from './query.mjs';
|
|
import { acquireWriterLease, writerLockPathFor } from './writer-lease.ts';
|
|
|
|
export { buildIndex, DB_PATH };
|
|
|
|
type SandboxApi = Record<string, unknown>;
|
|
|
|
// Run a user-supplied CodeAct script inside the query/attune sandbox. The script
|
|
// body runs as an async IIFE with a 30s timeout; its `return` value is resolved.
|
|
function runInSandbox(api: SandboxApi, scriptContent: string): Promise<unknown> {
|
|
const sandbox = {
|
|
...api, JSON, Math, Array, Object, Set, Map, Date, RegExp,
|
|
parseInt, parseFloat, String, Number, Boolean, Error, Promise, console, setTimeout,
|
|
};
|
|
const ctx = createContext(sandbox);
|
|
return runInNewContext(`(async()=>{${scriptContent}})()`, ctx, { timeout: 30000 });
|
|
}
|
|
|
|
// FTS search over indexed message text. Refreshes the index, then queries.
|
|
export function searchText(text: string, opts?: Record<string, unknown>): unknown {
|
|
buildIndex();
|
|
const db = openReadDb();
|
|
try {
|
|
return createQueryApi(db).search(text, opts);
|
|
} finally {
|
|
db.close();
|
|
}
|
|
}
|
|
|
|
// Execute a read-only CodeAct query script and resolve its returned value.
|
|
export async function executeQuery(scriptContent: string): Promise<unknown> {
|
|
buildIndex();
|
|
const db = openReadDb();
|
|
try {
|
|
return await runInSandbox(createQueryApi(db), scriptContent);
|
|
} finally {
|
|
db.close();
|
|
}
|
|
}
|
|
|
|
// Execute a memory-mutation CodeAct script (remember/forget only).
|
|
export async function executeAttune(scriptContent: string): Promise<unknown> {
|
|
const build = buildIndex() as { reason?: string } | undefined;
|
|
if (build?.reason === 'daemon_active') {
|
|
throw new Error('Obelisk daemon owns index writes; attune is read-only until the daemon stops');
|
|
}
|
|
if (build?.reason === 'writer_busy' || build?.reason === 'database_busy') {
|
|
throw new Error('Obelisk index writer is busy; attune was not applied');
|
|
}
|
|
const lease = acquireWriterLease({
|
|
lockPath: writerLockPathFor(DB_PATH),
|
|
openDb: openWriterLeaseDb,
|
|
waitMs: 1000,
|
|
});
|
|
if (!lease) throw new Error('Obelisk index writer is busy; attune was not applied');
|
|
try {
|
|
// Close the heartbeat TOCTOU window after acquiring the hard lease.
|
|
const ownershipDb = openReadDb();
|
|
try {
|
|
const ownership = shouldSkipBuild(ownershipDb, { ignoreRecentBuild: true });
|
|
if (ownership.reason === 'daemon_active') {
|
|
throw new Error('Obelisk daemon owns index writes; attune is read-only until the daemon stops');
|
|
}
|
|
} finally {
|
|
ownershipDb.close();
|
|
}
|
|
const db = openDb();
|
|
try {
|
|
return await runInSandbox(createAttuneApi(db), scriptContent);
|
|
} finally {
|
|
db.close();
|
|
}
|
|
} finally {
|
|
lease.release();
|
|
}
|
|
}
|