feat(storage): move desktop SQLite ownership into a storage utility process
Main no longer opens desktop business SQLite. A single utility-process
storage owner holds the Assistant, Knowledge, attachment, document-result,
remote-binding and native model-ledger databases and runs whole domain
transactions; Main and Runtime callers use explicit async domain operations.
- Remove the Main readWithFallback path and the supervision worker's
separate business write connection; manifests are scanned read-only and
committed by the sole writer.
- Remote events are ACKed only after commit; process loss reports
unconfirmed writes instead of replaying them.
- Use native utilityProcess messaging with one in-flight bound (32) and
ordered waiting instead of capacity rejection and retries.
- Start the storage process before the window so startup overlaps.
- Keep short tool output in memory and store full output once in owned
storage; paging, branching, restart and deletion reuse that copy.
- Put required runtime launch files under goodbuddy-prefixed owned temp
directories and reclaim confirmed stale ones; stop writing duplicate
parsed.md.
- Add lint guards for SQLite ownership and architecture, concurrency
acceptance fixtures, and maintenance/architecture documentation.
Validation: typecheck and lint pass. Alternating baseline (edf019aa) vs
candidate runs show no regression on single-session, C01, C02, C04 and a
5-minute C06; Main p99 drops from ~21 ms to ~16.7 ms under concurrency.
Fresh portable copy (1.75M events) upgrade content unchanged; real-model
output paging passes with 4 external requests. Full npm test runs had
1-9 different timing-sensitive failures per run that pass when rerun.
Not covered: C03 review publication, C07 source body, app-level C05.
This commit is contained in:
@@ -4,8 +4,6 @@ import { magicNoteCanvasAnalysisText } from '../../shared/magic-note-canvas-text
|
||||
import { knowledgeReferenceKey } from '../../shared/knowledge-reference'
|
||||
import { appendConversationQuestionBlock } from '../../shared/conversation-question-blocks'
|
||||
import { DatabaseSync } from 'node:sqlite'
|
||||
import { Worker } from 'node:worker_threads'
|
||||
import { ReadonlyQueryReader, readWithFallback } from '../readonly-query-reader'
|
||||
import { ActivityHistoryRepository, migrateActivityHistoryOrder } from './activity-history-repository'
|
||||
import { statSync } from 'node:fs'
|
||||
import { MagicNoteStorage } from '../magic-notes/magic-note-storage'
|
||||
@@ -158,8 +156,6 @@ export const MESSAGE_RECOVERY_CANDIDATE_SQL = `(state = 'streaming'
|
||||
OR instr(metadata_json, '"state":"queued"') > 0
|
||||
OR instr(metadata_json, '"state":"saving"') > 0
|
||||
OR instr(metadata_json, '"state":"cancelling"') > 0)`
|
||||
/** Main's automatic checkpoint threshold while the checkpoint worker runs (16 MB of 4 KB pages). */
|
||||
const WAL_SAFETY_CHECKPOINT_PAGES = 4_000
|
||||
|
||||
export type RemoteTaskEventInput = {
|
||||
taskId: string
|
||||
@@ -1587,6 +1583,7 @@ function reduceRecoveredAgentEvent(
|
||||
summary: event.summary,
|
||||
input: event.input,
|
||||
output: event.output,
|
||||
outputReferences: event.outputReferences,
|
||||
error: event.error
|
||||
}
|
||||
const tools = [...(message.tools ?? [])]
|
||||
@@ -1613,6 +1610,7 @@ function reduceRecoveredAgentEvent(
|
||||
reason: event.reason,
|
||||
progress: event.progress,
|
||||
output: event.output,
|
||||
outputReference: event.outputReference,
|
||||
error: event.error
|
||||
}
|
||||
const subagent: ConversationSubagentActivity =
|
||||
@@ -2070,10 +2068,6 @@ export class AssistantDatabase {
|
||||
private database?: DatabaseSync
|
||||
private activityHistoryRepository?: { database: DatabaseSync; repository: ActivityHistoryRepository }
|
||||
private executionTiming?: ExecutionTiming
|
||||
private readonlyReader?: ReadonlyQueryReader
|
||||
private supervisionWorker?: ReadonlyQueryReader
|
||||
private checkpointWorker?: Worker
|
||||
private readonlyWorkerPath?: string
|
||||
private foldedSearchConnection?: DatabaseSync
|
||||
private readonly noteStorage: MagicNoteStorage
|
||||
private readonly dirtyMagicNotes = new Set<string>()
|
||||
@@ -2508,12 +2502,7 @@ export class AssistantDatabase {
|
||||
}
|
||||
|
||||
close(): void {
|
||||
this.stopWalCheckpointWorker()
|
||||
this.executionTiming = undefined
|
||||
this.readonlyReader?.close()
|
||||
this.readonlyReader = undefined
|
||||
this.supervisionWorker?.close()
|
||||
this.supervisionWorker = undefined
|
||||
this.foldedSearchConnection = undefined
|
||||
this.activityHistoryRepository = undefined
|
||||
this.database?.close()
|
||||
@@ -3125,25 +3114,6 @@ export class AssistantDatabase {
|
||||
return this.readConversationList(new Set(detailIds))
|
||||
}
|
||||
|
||||
/**
|
||||
* Conversation summaries on the readonly worker (PERF-15). Every write runs
|
||||
* synchronously on Main, so once no transaction is open, everything written
|
||||
* before this call is committed and visible to the worker. Inside an open
|
||||
* transaction the synchronous path keeps read-your-writes semantics.
|
||||
*/
|
||||
listConversationSummariesAsync(detailIds: string[] = [], signal?: AbortSignal): Promise<import('../../shared/assistant-contracts').ConversationListSnapshot[]> {
|
||||
const database = this.requireDatabase()
|
||||
return readWithFallback(database.isTransaction ? undefined : this.readonlyQueryReader(),
|
||||
'listConversationSummaries', [detailIds], signal, () => this.listConversationSummaries(detailIds))
|
||||
}
|
||||
|
||||
/** Full conversation history on the readonly worker; same rules as listConversationSummariesAsync. */
|
||||
getConversationAsync(conversationId: string, signal?: AbortSignal): Promise<ConversationSnapshot> {
|
||||
const database = this.requireDatabase()
|
||||
return readWithFallback(database.isTransaction ? undefined : this.readonlyQueryReader(),
|
||||
'getConversation', [conversationId], signal, () => this.getConversation(conversationId))
|
||||
}
|
||||
|
||||
private listConversationRows(): ConversationRow[] {
|
||||
const database = this.requireDatabase()
|
||||
return database
|
||||
@@ -3245,14 +3215,6 @@ export class AssistantDatabase {
|
||||
).all(normalized) as { id: string }[]).map(row => row.id)
|
||||
}
|
||||
|
||||
/** Conversation search on the readonly worker; falls back to the synchronous query. */
|
||||
searchConversationsAsync(query: string, signal?: AbortSignal): Promise<string[]> {
|
||||
this.requireDatabase()
|
||||
if (!query.trim()) return Promise.resolve([])
|
||||
return readWithFallback(this.readonlyQueryReader(), 'searchConversations', [query], signal,
|
||||
() => this.searchConversations(query))
|
||||
}
|
||||
|
||||
getConversation(conversationId: string): ConversationSnapshot {
|
||||
const database = this.requireDatabase()
|
||||
const conversation = database
|
||||
@@ -5102,16 +5064,9 @@ export class AssistantDatabase {
|
||||
this.database = new DatabaseSync(this.databasePath, { readOnly: true, timeout: 5_000 })
|
||||
}
|
||||
|
||||
/** Worker-only connection: no startup recovery, schema migration or Main object sharing. */
|
||||
openSupervisionWorker(): void {
|
||||
if (this.database) throw new Error('Database already open')
|
||||
this.database = new DatabaseSync(this.databasePath, { timeout: 5_000 })
|
||||
this.database.exec('PRAGMA foreign_keys = ON; PRAGMA wal_autocheckpoint = 0')
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens a connection used only to checkpoint the WAL from a worker thread
|
||||
* (PERF-15). It never writes rows; see enableWalCheckpointWorker.
|
||||
* owned by DesktopStorageOwner. It never writes business rows.
|
||||
*/
|
||||
openCheckpointer(): void {
|
||||
if (this.database) throw new Error('Database already open')
|
||||
@@ -5126,56 +5081,9 @@ export class AssistantDatabase {
|
||||
{ busy: number; log: number; checkpointed: number }
|
||||
}
|
||||
|
||||
/**
|
||||
* Moves WAL checkpoints off Main (PERF-15). SQLite's automatic checkpoint
|
||||
* runs inside the commit that crosses 1,000 WAL pages, so on a large
|
||||
* database one ordinary write now and then blocked Main for 100-250 ms
|
||||
* while pages were copied and synced. A worker thread checkpoints on its
|
||||
* own connection instead. While it runs, Main's automatic checkpoint stays
|
||||
* as a safety net at a higher threshold: under sustained writes a passive
|
||||
* checkpoint never catches up exactly, so the WAL could not reset; Main's
|
||||
* checkpoint then finds almost every frame already copied and only
|
||||
* finishes the rest. The default threshold comes back if the worker stops.
|
||||
*/
|
||||
enableWalCheckpointWorker(workerPath: string, intervalMs = 250): void {
|
||||
const database = this.database
|
||||
if (!database || this.checkpointWorker || this.databasePath === ':memory:') return
|
||||
let worker: Worker
|
||||
try {
|
||||
worker = new Worker(workerPath, {
|
||||
workerData: { kind: 'checkpoint', databasePath: this.databasePath, intervalMs }
|
||||
})
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
this.checkpointWorker = worker
|
||||
worker.unref()
|
||||
const restore = (): void => {
|
||||
if (this.checkpointWorker !== worker) return
|
||||
this.checkpointWorker = undefined
|
||||
if (this.database === database) database.exec('PRAGMA wal_autocheckpoint = 1000')
|
||||
}
|
||||
worker.on('message', (message: { ready?: boolean }) => {
|
||||
if (message.ready && this.checkpointWorker === worker && this.database === database) {
|
||||
database.exec(`PRAGMA wal_autocheckpoint = ${WAL_SAFETY_CHECKPOINT_PAGES}`)
|
||||
}
|
||||
})
|
||||
worker.once('error', restore)
|
||||
worker.once('exit', restore)
|
||||
}
|
||||
|
||||
private stopWalCheckpointWorker(): void {
|
||||
const worker = this.checkpointWorker
|
||||
if (!worker) return
|
||||
this.checkpointWorker = undefined
|
||||
this.database?.exec('PRAGMA wal_autocheckpoint = 1000')
|
||||
void worker.terminate()
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs multi-statement reads against one WAL snapshot. Used by the readonly
|
||||
* worker, where Main may commit between statements; on Main itself the
|
||||
* event loop already serializes reads and writes.
|
||||
* worker, where the storage owner may commit between statements.
|
||||
*/
|
||||
readSnapshot<T>(read: () => T): T {
|
||||
const database = this.requireDatabase()
|
||||
@@ -5191,25 +5099,6 @@ export class AssistantDatabase {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Routes heavy read-only queries (story graph, conversation search) through a
|
||||
* read-only worker thread. In-memory databases keep the synchronous path.
|
||||
*/
|
||||
enableReadonlyWorker(workerPath: string): void {
|
||||
this.readonlyWorkerPath = workerPath
|
||||
}
|
||||
|
||||
private readonlyQueryReader(): ReadonlyQueryReader | undefined {
|
||||
if (!this.readonlyWorkerPath || this.databasePath === ':memory:') return undefined
|
||||
this.readonlyReader ??= new ReadonlyQueryReader('assistant', this.databasePath, this.readonlyWorkerPath)
|
||||
return this.readonlyReader
|
||||
}
|
||||
|
||||
/** Test hook for the worker crash and cancellation paths. */
|
||||
get readonlyWorkerForTest(): ReadonlyQueryReader | undefined {
|
||||
return this.readonlyQueryReader()
|
||||
}
|
||||
|
||||
/** Activity history storage and queries (PERF-15); the SQL lives in the repository. */
|
||||
activityHistory(): ActivityHistoryRepository {
|
||||
const database = this.requireDatabase()
|
||||
@@ -5270,19 +5159,6 @@ export class AssistantDatabase {
|
||||
return this.readSnapshot(() => this.activityHistory().summary(input))
|
||||
}
|
||||
|
||||
/** Pages and the summary on the readonly worker; same rules as listConversationSummariesAsync. */
|
||||
getActivityHistoryPageAsync(input: unknown): Promise<ActivityHistoryPage> {
|
||||
const database = this.requireDatabase()
|
||||
return readWithFallback(database.isTransaction ? undefined : this.readonlyQueryReader(),
|
||||
'activityHistoryPage', [input], undefined, () => this.getActivityHistoryPage(input))
|
||||
}
|
||||
|
||||
getActivityHistorySummaryAsync(input: unknown): Promise<ActivityHistorySummary> {
|
||||
const database = this.requireDatabase()
|
||||
return readWithFallback(database.isTransaction ? undefined : this.readonlyQueryReader(),
|
||||
'activityHistorySummary', [input], undefined, () => this.getActivityHistorySummary(input))
|
||||
}
|
||||
|
||||
/** Ends records left running by requests that are no longer active (see the repository). */
|
||||
reconcileActivityHistory(input: unknown): number {
|
||||
return this.activityHistory().reconcile(input)
|
||||
@@ -9271,47 +9147,16 @@ export class AssistantDatabase {
|
||||
return readStoryGraph(this.requireDatabase(), name, input, projectId, signal)
|
||||
}
|
||||
|
||||
/** Story graph read on the readonly worker; falls back to the synchronous read. */
|
||||
readStoryGraphAsync(name: import('../../shared/story-graph-tools').StoryGraphToolName, input: unknown, projectId?: string, signal?: AbortSignal): Promise<Record<string, unknown>> {
|
||||
this.requireDatabase()
|
||||
return readWithFallback(this.readonlyQueryReader(), 'readStoryGraph', [name, input, projectId], signal,
|
||||
() => this.readStoryGraph(name, input, projectId, signal))
|
||||
}
|
||||
|
||||
getSupervisionResult(id: string): { id: string; summary: string } | undefined {
|
||||
return this.requireDatabase().prepare('SELECT id, summary FROM supervision_results WHERE id = ?').get(id) as { id: string; summary: string } | undefined
|
||||
}
|
||||
|
||||
async listSupervisionResultsAsync(limit = 20, target?: SupervisionTarget, resultId?: string): Promise<ReturnType<AssistantDatabase['listSupervisionResults']>> {
|
||||
this.requireDatabase()
|
||||
const reader = this.readonlyQueryReader()
|
||||
if (reader) return reader.call('supervisionOverview', [limit, target, resultId])
|
||||
if (this.databasePath !== ':memory:') throw new Error('Supervision worker is not configured')
|
||||
return this.listSupervisionResults(limit, target, resultId)
|
||||
}
|
||||
|
||||
async getSupervisionGraphAsync(input: SupervisionGraphRequest = {}): Promise<Record<string, unknown>> {
|
||||
this.requireDatabase()
|
||||
const reader = this.readonlyQueryReader()
|
||||
if (reader) return reader.call('supervisionGraph', [input])
|
||||
if (this.databasePath !== ':memory:') throw new Error('Supervision worker is not configured')
|
||||
return this.getSupervisionGraph(input)
|
||||
}
|
||||
|
||||
getSupervisionStories(scope: SupervisionRunRequest['scope']) {
|
||||
const stories = this.supervisionStories()
|
||||
return { stories: stories.list(scope), experiences: this.supervisionExperiences().list(scope.kind === 'projects' ? scope.projectIds : undefined),
|
||||
unassigned: stories.unassignedCount(scope), canUndo: stories.canUndo() }
|
||||
}
|
||||
|
||||
async getSupervisionStoriesAsync(scope: SupervisionRunRequest['scope']): Promise<ReturnType<AssistantDatabase['getSupervisionStories']>> {
|
||||
this.requireDatabase()
|
||||
const reader = this.readonlyQueryReader()
|
||||
if (reader) return reader.call('supervisionStories', [scope])
|
||||
if (this.databasePath !== ':memory:') throw new Error('Supervision worker is not configured')
|
||||
return this.getSupervisionStories(scope)
|
||||
}
|
||||
|
||||
listSupervisionResults(limit = 20, target?: SupervisionTarget, resultId?: string) {
|
||||
const owner = target?.type === 'conversation' ? this.getConversation(target.conversationId)
|
||||
: target?.type === 'task' ? this.getTask(target.taskId) : undefined
|
||||
@@ -12398,43 +12243,6 @@ export class AssistantDatabase {
|
||||
return new SupervisionReviewStore(this.requireDatabase(), signal)
|
||||
}
|
||||
|
||||
async initializeSupervisionReview(runId: string, state: import('./supervision-review-store').ReviewState, signal: AbortSignal): Promise<void> {
|
||||
const store = this.supervisionReviewStore()
|
||||
store.prepareInitialization(runId, state)
|
||||
await this.runSupervisionWorker('initialize', [runId, state], signal)
|
||||
}
|
||||
|
||||
async resumeSupervisionReview(runId: string, signal: AbortSignal): Promise<void> {
|
||||
await this.runSupervisionWorker('resume', [runId], signal)
|
||||
}
|
||||
|
||||
private async runSupervisionWorker(op: 'initialize' | 'resume', args: unknown[], signal: AbortSignal): Promise<void> {
|
||||
if (this.databasePath === ':memory:') {
|
||||
const store = this.supervisionReviewStore()
|
||||
if (op === 'resume') store.resume(String(args[0]), signal)
|
||||
else store.initializeSources(String(args[0]), args[1] as import('./supervision-review-store').ReviewState, signal)
|
||||
return
|
||||
}
|
||||
// Fail explicitly when packaging/startup is broken; never freeze Main as a fallback.
|
||||
if (!this.readonlyWorkerPath) throw new Error('Supervision worker is not configured')
|
||||
this.supervisionWorker ??= new ReadonlyQueryReader('supervision', this.databasePath, this.readonlyWorkerPath)
|
||||
await this.supervisionWorker.call(op, args, signal)
|
||||
}
|
||||
|
||||
async supervisionContext(request: SupervisionRunRequest, signal?: AbortSignal): Promise<{ summary: string | undefined; background: ReviewBatch['evidence'] }> {
|
||||
const reader = this.readonlyQueryReader()
|
||||
if (reader) return reader.call('reviewContext', [request], signal)
|
||||
if (this.databasePath !== ':memory:') throw new Error('Supervision worker is not configured')
|
||||
return { summary: this.reviewSummary(request.scope, 'supervisor'), background: this.reviewBackground(request.scope) }
|
||||
}
|
||||
|
||||
async supervisionCandidates(request: SupervisionRunRequest, batch?: { projectId: string; crossProject: boolean }): Promise<ReturnType<AssistantDatabase['listSupervisionCandidates']>> {
|
||||
const reader = this.readonlyQueryReader()
|
||||
if (reader) return reader.call('reviewCandidates', [request, batch])
|
||||
if (this.databasePath !== ':memory:') throw new Error('Supervision worker is not configured')
|
||||
return this.listSupervisionCandidates(request, batch)
|
||||
}
|
||||
|
||||
supervisionSuggestions(): SupervisionSuggestionStore {
|
||||
return new SupervisionSuggestionStore(this.requireDatabase())
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user