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
@@ -88,9 +88,9 @@ describe('DingTalkChannelDriver', () => {
receive(envelope())
await vi.waitFor(() => expect(failure).toHaveBeenCalledOnce())
expect(failure.mock.calls[0]?.[0]).toMatchObject({ message: '钉钉回复请求失败 (130101)' })
expect(outbox.listUndelivered()[0]).toMatchObject({ state: 'failed', attempts: 1 })
expect((await outbox.listUndelivered())[0]).toMatchObject({ state: 'failed', attempts: 1 })
await vi.waitFor(() => expect(requests).toHaveLength(2), { timeout: 3000 })
await vi.waitFor(() => expect(outbox.listUndelivered()).toEqual([]))
await vi.waitFor(async () => expect(await outbox.listUndelivered()).toEqual([]))
expect(requests[1]).toBe(requests[0])
expect(executor).toHaveBeenCalledOnce()
} finally {
+24 -24
View File
@@ -25,46 +25,46 @@ const message = {
}
describe('SqliteChannelOutbox', () => {
it('marks a permanent failure terminal, releases media and counts the attempt once', () => {
it('marks a permanent failure terminal, releases media and counts the attempt once', async () => {
const outbox = createOutbox()
const entry = outbox.enqueue(message)
outbox.markTerminal(entry.id)
outbox.markTerminal(entry.id)
outbox.markFailed(entry.id)
outbox.markDelivered(entry.id)
expect(outbox.listUndelivered()).toEqual([{
const entry = await outbox.enqueue(message)
await outbox.markTerminal(entry.id)
await outbox.markTerminal(entry.id)
await outbox.markFailed(entry.id)
await outbox.markDelivered(entry.id)
expect(await outbox.listUndelivered()).toEqual([{
...entry, state: 'terminal', attempts: 1,
message: { ...message, attachments: undefined }
}])
expect(outbox.listUndelivered()[0]!.message).not.toHaveProperty('attachments')
expect((await outbox.listUndelivered())[0]!.message).not.toHaveProperty('attachments')
})
it('keeps retryable entries ahead of terminal entries under channel and global limits', () => {
it('keeps retryable entries ahead of terminal entries under channel and global limits', async () => {
const outbox = createOutbox()
const terminal = outbox.enqueue(message)
outbox.markTerminal(terminal.id)
const retry = outbox.enqueue({ ...message, eventId: 'retry' })
outbox.markFailed(retry.id)
outbox.markFailed(retry.id)
const terminal = await outbox.enqueue(message)
await outbox.markTerminal(terminal.id)
const retry = await outbox.enqueue({ ...message, eventId: 'retry' })
await outbox.markFailed(retry.id)
await outbox.markFailed(retry.id)
for (const channel of [undefined, 'telegram']) {
expect(outbox.listUndelivered(channel, 1).map(entry => entry.id)).toEqual([retry.id])
expect(outbox.listUndelivered(channel).map(entry => entry.id)).toEqual([retry.id, terminal.id])
expect((await outbox.listUndelivered(channel, 1)).map(entry => entry.id)).toEqual([retry.id])
expect((await outbox.listUndelivered(channel)).map(entry => entry.id)).toEqual([retry.id, terminal.id])
}
expect(outbox.listUndelivered('weixin')).toEqual([])
expect(await outbox.listUndelivered('weixin')).toEqual([])
})
it('budgets retry bytes after putting terminal results last in both windows', () => {
it('budgets retry bytes after putting terminal results last in both windows', async () => {
const outbox = createOutbox()
// Retained terminal text alone exceeds the 20 MiB retry window.
for (let index = 0; index < 22; index++) {
const entry = outbox.enqueue({ ...message, eventId: `terminal-${index}`, output: 'x'.repeat(1024 * 1024) })
outbox.markTerminal(entry.id)
const entry = await outbox.enqueue({ ...message, eventId: `terminal-${index}`, output: 'x'.repeat(1024 * 1024) })
await outbox.markTerminal(entry.id)
}
const retry = outbox.enqueue({ ...message, eventId: 'retry' })
outbox.markFailed(retry.id)
outbox.markFailed(retry.id)
const retry = await outbox.enqueue({ ...message, eventId: 'retry' })
await outbox.markFailed(retry.id)
await outbox.markFailed(retry.id)
for (const channel of [undefined, 'telegram']) {
const entries = outbox.listUndelivered(channel)
const entries = await outbox.listUndelivered(channel)
expect(entries[0]!.id).toBe(retry.id)
expect(entries.length).toBeLessThan(23)
}
+14 -14
View File
@@ -1,4 +1,4 @@
import type { AssistantDatabase } from '../assistant/assistant-database'
import type { AssistantStoragePort, Awaitable } from '../assistant-storage-port'
import type {
DedupStore,
Outbox,
@@ -7,40 +7,40 @@ import type {
import type { ChannelResultMessage } from '../../shared/channel-contracts'
export class SqliteChannelDedupStore implements DedupStore {
constructor(private readonly database: AssistantDatabase) {}
constructor(private readonly database: Pick<AssistantStoragePort, 'claimChannelEvent' | 'releaseChannelEvent'>) {}
claim(channel: string, accountId: string, eventId: string): boolean {
claim(channel: string, accountId: string, eventId: string): Awaitable<boolean> {
return this.database.claimChannelEvent(channel, accountId, eventId)
}
release(channel: string, accountId: string, eventId: string): void {
this.database.releaseChannelEvent(channel, accountId, eventId)
release(channel: string, accountId: string, eventId: string): Awaitable<void> {
return this.database.releaseChannelEvent(channel, accountId, eventId)
}
}
export class SqliteChannelOutbox implements Outbox {
constructor(private readonly database: AssistantDatabase) {}
constructor(private readonly database: Pick<AssistantStoragePort, 'enqueueChannelResult' | 'markChannelResult' | 'listUndeliveredChannelResults'>) {}
enqueue(message: ChannelResultMessage): OutboxEntry {
enqueue(message: ChannelResultMessage): Awaitable<OutboxEntry> {
return this.database.enqueueChannelResult(message)
}
markDelivered(id: string): void {
this.database.markChannelResult(id, 'delivered')
markDelivered(id: string): Awaitable<void> {
return this.database.markChannelResult(id, 'delivered')
}
markFailed(id: string): void {
this.database.markChannelResult(id, 'failed')
markFailed(id: string): Awaitable<void> {
return this.database.markChannelResult(id, 'failed')
}
markTerminal(id: string): void {
this.database.markChannelResult(id, 'terminal')
markTerminal(id: string): Awaitable<void> {
return this.database.markChannelResult(id, 'terminal')
}
listUndelivered(
channel?: string,
limit?: number
): readonly OutboxEntry[] {
): Awaitable<readonly OutboxEntry[]> {
return this.database.listUndeliveredChannelResults(
channel,
limit
+6 -6
View File
@@ -89,9 +89,9 @@ describe('Telegram delivery through loopback HTTP and SQLite', () => {
await vi.waitFor(async () => expect((await f.manager.snapshot()).telegram.status).toMatchObject({
state: 'error', lastError: expect.stringContaining('polling conflict')
}))
await vi.waitFor(() => expect(f.outbox.listUndelivered().every(entry => entry.state === 'terminal')).toBe(true))
await vi.waitFor(async () => expect((await f.outbox.listUndelivered()).every(entry => entry.state === 'terminal')).toBe(true))
expect(f.state.sends.map(send => send.text)).toEqual(['same-bot'])
expect(f.outbox.listUndelivered().map(entry => [entry.message.eventId, entry.state])).toEqual([['other-bot', 'terminal']])
expect((await f.outbox.listUndelivered()).map(entry => [entry.message.eventId, entry.state])).toEqual([['other-bot', 'terminal']])
expect(f.state.polls).toBe(0)
f.state.webhook = ''
await f.manager.reload('telegram')
@@ -107,7 +107,7 @@ describe('Telegram delivery through loopback HTTP and SQLite', () => {
f.state.sendErrors.push({ code: 403 })
f.inbound(1)
await f.manager.initialize()
await vi.waitFor(() => expect(f.outbox.listUndelivered()[0]).toMatchObject({ state: 'terminal', attempts: 1 }))
await vi.waitFor(async () => expect((await f.outbox.listUndelivered())[0]).toMatchObject({ state: 'terminal', attempts: 1 }))
await vi.waitFor(() => expect(f.state.polls).toBeGreaterThanOrEqual(2))
expect((await f.manager.snapshot()).telegram.status).toEqual({ state: 'running',
lastError: expect.stringMatching(/sending failed.*forbidden/i) })
@@ -129,7 +129,7 @@ describe('Telegram delivery through loopback HTTP and SQLite', () => {
await f.manager.initialize()
await vi.waitFor(() => expect(f.state.sends).toHaveLength(5), { timeout: 10_000 })
expect(f.state.sends[4]!.time - f.state.sends[3]!.time).toBeGreaterThanOrEqual(2995)
await vi.waitFor(() => expect(f.outbox.listUndelivered()).toEqual([]))
await vi.waitFor(async () => expect(await f.outbox.listUndelivered()).toEqual([]))
expect(f.state.sends.every(send => send.text === 'synthetic result')).toBe(true)
expect(f.execute).toHaveBeenCalledOnce()
} finally { await f.close() }
@@ -144,10 +144,10 @@ describe('Telegram delivery through loopback HTTP and SQLite', () => {
await f.manager.initialize()
await vi.waitFor(() => expect(f.state.sends).toHaveLength(4), { timeout: 5000 })
await f.manager.stopAll()
expect(f.outbox.listUndelivered()[0]).toMatchObject({ state: 'pending', attempts: 0 })
expect((await f.outbox.listUndelivered())[0]).toMatchObject({ state: 'pending', attempts: 0 })
expect(f.state.sends).toHaveLength(4)
await f.manager.initialize()
await vi.waitFor(() => expect(f.outbox.listUndelivered()).toEqual([]))
await vi.waitFor(async () => expect(await f.outbox.listUndelivered()).toEqual([]))
expect(f.state.sends).toHaveLength(5)
expect(f.execute).not.toHaveBeenCalled()
} finally { await f.close() }