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:
mesalogo
2026-10-06 15:40:18 +08:00
parent bc677a3718
commit d80d42b032
229 changed files with 13632 additions and 3800 deletions
+97 -19
View File
@@ -47,6 +47,8 @@ import {
directModelSubagentInputSchema
} from '../assistant/direct-model-subagent-service'
import type { LaunchEnvironmentProvider } from '../local-tool-environment'
import type { AttachmentStorageAccess } from '../desktop-storage-files'
import type { PagedOutputReference, PagedOutputStoreOptions } from './paged-output-store'
import type { BrowserTabId } from '../../shared/contracts'
import { ripgrepInputSchema, searchWorkspaceWithRipgrep } from './direct-model-ripgrep'
import { applyWorkspacePatch } from './workspace-apply-patch'
@@ -254,12 +256,14 @@ export type ModelToolResultPart =
export type ModelToolResult = {
parts: ModelToolResultPart[]
contextBytes: number
outputReferences?: PagedOutputReference[]
}
export type ModelToolCallContext = {
imageToolBinding?: ImageToolBinding
toolCallId?: string
conversationId: string
outputHistory?: { conversationId: string; messageId: string }
browserTabId?: BrowserTabId
browserConversationId?: string
requestId?: string
@@ -271,6 +275,7 @@ export type ModelToolCallContext = {
}
export type ModelSubagentRequestContext = {
outputHistory?: { conversationId: string; messageId: string }
authorize?: RuntimeAuthorizer
browserTabId?: BrowserTabId
browserConversationId?: string
@@ -283,6 +288,8 @@ export type ModelSubagentBridge = {
}
export type ModelToolProviderProgrammingOptions = {
outputStore?: PagedOutputStoreOptions
outputAdopt?: AttachmentStorageAccess['outputAdopt']
runtimeTarget?: RuntimeTarget
processService?: DirectModelProcessService
subagentService?: DirectModelSubagentService<ModelSubagentRequestContext>
@@ -632,6 +639,11 @@ export class ModelToolProvider implements ModelToolProviderLike {
private readonly clients = new Set<Client>()
private readonly customMcpClients = new Set<Client>()
private readonly webSearchClients = new Set<Client>()
private readonly outputCalls = new Set<{
ownerId: string
controller: AbortController
settled: Promise<ModelToolResult>
}>()
constructor(
workspace: string | WorkspaceAccess,
@@ -643,13 +655,16 @@ export class ModelToolProvider implements ModelToolProviderLike {
ModelToolProviderProgrammingOptions = {},
private readonly launchEnvironmentProvider?: LaunchEnvironmentProvider
) {
this.ripgrepService = new LocalDirectModelProcessService({
outputPrefix: 'rg', outputStore: programming.outputStore
})
this.workspaceAccess =
typeof workspace === 'string'
? new LocalWorkspaceAccess(workspace)
: workspace
}
private readonly ripgrepService = new LocalDirectModelProcessService({ outputPrefix: 'rg' })
private readonly ripgrepService: LocalDirectModelProcessService
private readonly workspaceAccess: WorkspaceAccess
private getScopedTools(
@@ -1373,6 +1388,29 @@ export class ModelToolProvider implements ModelToolProviderLike {
argumentsValue: Record<string, unknown>,
signal: AbortSignal,
context: ModelToolCallContext
): Promise<ModelToolResult> {
if (!['process_execute', 'workspace_rg', 'subagent_delegate'].includes(name)) {
return this.callToolOnce(name, argumentsValue, signal, context)
}
const controller = new AbortController()
const call = {
ownerId: context.conversationId,
controller,
settled: this.callToolOnce(name, argumentsValue, AbortSignal.any([signal, controller.signal]), context)
}
this.outputCalls.add(call)
try {
return await call.settled
} finally {
this.outputCalls.delete(call)
}
}
private async callToolOnce(
name: string,
argumentsValue: Record<string, unknown>,
signal: AbortSignal,
context: ModelToolCallContext
): Promise<ModelToolResult> {
signal.throwIfAborted()
if (name.startsWith('story_graph_') && scopedDataToolByName.has(name as ScopedDataToolName)) {
@@ -1410,7 +1448,7 @@ export class ModelToolProvider implements ModelToolProviderLike {
return createTextToolResult(
boundedJson(
{
libraries: this.knowledgeGateway.listLibraries(
libraries: await this.knowledgeGateway.listLibraries(
context.knowledgeCapabilityToken,
argumentsValue
)
@@ -1449,7 +1487,7 @@ export class ModelToolProvider implements ModelToolProviderLike {
return createTextToolResult(
boundedJson(
{
notes: this.knowledgeGateway.searchMagicNotes(
notes: await this.knowledgeGateway.searchMagicNotes(
context.knowledgeCapabilityToken,
argumentsValue,
signal
@@ -1469,7 +1507,7 @@ export class ModelToolProvider implements ModelToolProviderLike {
return createTextToolResult(
boundedJson(
{
notes: this.knowledgeGateway.listMagicNotes(
notes: await this.knowledgeGateway.listMagicNotes(
context.knowledgeCapabilityToken,
argumentsValue
)
@@ -1488,7 +1526,7 @@ export class ModelToolProvider implements ModelToolProviderLike {
return createTextToolResult(
boundedJson(
{
note: this.knowledgeGateway.getMagicNote(
note: await this.knowledgeGateway.getMagicNote(
context.knowledgeCapabilityToken,
argumentsValue
)
@@ -1507,7 +1545,7 @@ export class ModelToolProvider implements ModelToolProviderLike {
return createTextToolResult(
boundedJson(
{
note: this.knowledgeGateway.createMagicNote(
note: await this.knowledgeGateway.createMagicNote(
context.knowledgeCapabilityToken,
argumentsValue
)
@@ -1526,7 +1564,7 @@ export class ModelToolProvider implements ModelToolProviderLike {
return createTextToolResult(
boundedJson(
{
note: this.knowledgeGateway.updateMagicNote(
note: await this.knowledgeGateway.updateMagicNote(
context.knowledgeCapabilityToken,
argumentsValue
)
@@ -1545,7 +1583,7 @@ export class ModelToolProvider implements ModelToolProviderLike {
return createTextToolResult(
boundedJson(
{
note: this.knowledgeGateway.createMagicNoteEntry(
note: await this.knowledgeGateway.createMagicNoteEntry(
context.knowledgeCapabilityToken,
argumentsValue
)
@@ -1564,7 +1602,7 @@ export class ModelToolProvider implements ModelToolProviderLike {
return createTextToolResult(
boundedJson(
{
note: this.knowledgeGateway.updateMagicNoteEntry(
note: await this.knowledgeGateway.updateMagicNoteEntry(
context.knowledgeCapabilityToken,
argumentsValue
)
@@ -1583,7 +1621,7 @@ export class ModelToolProvider implements ModelToolProviderLike {
return createTextToolResult(
boundedJson(
{
note: this.knowledgeGateway.deleteMagicNoteEntry(
note: await this.knowledgeGateway.deleteMagicNoteEntry(
context.knowledgeCapabilityToken,
argumentsValue
)
@@ -1601,7 +1639,7 @@ export class ModelToolProvider implements ModelToolProviderLike {
}
return createTextToolResult(
boundedJson(
this.knowledgeGateway.deleteMagicNote(
await this.knowledgeGateway.deleteMagicNote(
context.knowledgeCapabilityToken,
argumentsValue
),
@@ -1699,7 +1737,7 @@ export class ModelToolProvider implements ModelToolProviderLike {
throw new Error('GoodBuddy 内置 ripgrep 不可用')
}
const input = ripgrepInputSchema.parse(argumentsValue)
return createBoundedJsonObjectToolResult(
return await this.createOutputResult(
await searchWorkspaceWithRipgrep(
this.programming.ripgrepExecutablePath,
input,
@@ -1709,7 +1747,8 @@ export class ModelToolProvider implements ModelToolProviderLike {
context.conversationId
),
['stdout', 'stderr'],
'ripgrep output could not be serialized'
'ripgrep output could not be serialized',
context
)
}
const input = readInputSchema.parse(argumentsValue)
@@ -1802,7 +1841,7 @@ export class ModelToolProvider implements ModelToolProviderLike {
) {
throw new Error('进程执行空间与当前工作区不匹配')
}
return createBoundedJsonObjectToolResult(
return this.createOutputResult(
await this.programming.processService.execute(
processExecuteInputSchema.parse(argumentsValue),
{
@@ -1812,7 +1851,8 @@ export class ModelToolProvider implements ModelToolProviderLike {
}
),
['stdout', 'stderr'],
'进程执行结果无法序列化'
'进程执行结果无法序列化',
context
)
}
if (name === 'subagent_delegate') {
@@ -1827,6 +1867,7 @@ export class ModelToolProvider implements ModelToolProviderLike {
throw new Error('当前请求不允许编程 Subagent 委派')
}
const input = directModelSubagentInputSchema.parse(argumentsValue)
let finalEvent: DirectModelSubagentEvent | undefined
const result = await this.programming.subagentService.run({
ownerId: context.conversationId,
task: input.task,
@@ -1835,22 +1876,30 @@ export class ModelToolProvider implements ModelToolProviderLike {
requestContext: context.subagentBridge.requestContext
},
signal,
onEvent: (event) =>
onEvent: (event) => {
if (event.outputReference) {
finalEvent = event
return
}
emitDirectModelSubagentEvent(
event,
context.subagentBridge!.requestContext
),
)
},
onModelUsage: (event) =>
emitDirectModelSubagentUsage(
event,
context.subagentBridge!.requestContext
)
})
return createBoundedJsonObjectToolResult(
const toolResult = await this.createOutputResult(
result,
['output', 'error'],
'编程 Subagent 结果无法序列化'
'编程 Subagent 结果无法序列化',
context
)
if (finalEvent) emitDirectModelSubagentEvent(finalEvent, context.subagentBridge.requestContext)
return toolResult
}
// Calls use the discovered catalog; only listTools retries failed servers.
@@ -1906,7 +1955,31 @@ export class ModelToolProvider implements ModelToolProviderLike {
}
}
private async createOutputResult(
value: { stdoutReference?: PagedOutputReference; stderrReference?: PagedOutputReference; outputReference?: PagedOutputReference },
textKeys: string[],
errorMessage: string,
context: ModelToolCallContext
): Promise<ModelToolResult> {
const outputReferences = [value.stdoutReference, value.stderrReference, value.outputReference]
.filter((reference): reference is PagedOutputReference => reference !== undefined)
if (context.outputHistory && outputReferences.length) {
if (!this.programming.outputAdopt) throw new Error('Output history storage owner is not configured')
for (const reference of outputReferences) {
await this.programming.outputAdopt(context.conversationId, reference.handle,
context.outputHistory.conversationId, 'message', context.outputHistory.messageId)
}
}
return {
...createBoundedJsonObjectToolResult(value, textKeys, errorMessage),
...(outputReferences.length ? { outputReferences } : {})
}
}
async dispose(): Promise<void> {
const calls = [...this.outputCalls]
for (const call of calls) call.controller.abort(new Error('Tool provider disposed'))
await Promise.allSettled(calls.map(call => call.settled))
const clients = [...this.clients]
this.clients.clear()
this.customMcpClients.clear()
@@ -1924,6 +1997,10 @@ export class ModelToolProvider implements ModelToolProviderLike {
}
async releaseConversation(conversationId: string): Promise<void> {
const calls = [...this.outputCalls].filter(call => call.ownerId === conversationId)
for (const call of calls) call.controller.abort(new Error('Conversation released'))
// Adoption is part of the producing call; releasing capture first loses history.
await Promise.allSettled(calls.map(call => call.settled))
await Promise.allSettled([
this.ripgrepService.releaseConversation(conversationId),
this.browserService?.releaseConversation(conversationId),
@@ -1963,6 +2040,7 @@ function emitDirectModelSubagentEvent(
state: event.state,
reason: event.reason,
...(output !== undefined ? { output } : {}),
...(event.outputReference ? { outputReference: event.outputReference } : {}),
...(error !== undefined
? { error: error.slice(0, 1_000) }
: {})