fix: stop subagent history from exhausting disk space
GoodBuddy Agents / Validate Agent source (push) Canceled after 0s
GoodBuddy Agents / Build darwin arm64 Agent (push) Canceled after 0s
GoodBuddy Agents / Build linux arm64 Agent (push) Canceled after 0s
GoodBuddy Agents / Build linux x64 Agent (push) Canceled after 0s
Cross-platform packages / Validate source (push) Canceled after 0s
Cross-platform packages / Authorize desktop packaging (push) Canceled after 0s
Cross-platform packages / linux arm64 (push) Canceled after 0s
Cross-platform packages / macos arm64 (push) Canceled after 0s
Cross-platform packages / windows arm64 (push) Canceled after 0s
Cross-platform packages / linux x64 (push) Canceled after 0s
Cross-platform packages / macos x64 (push) Canceled after 0s
Cross-platform packages / windows x64 (push) Canceled after 0s
Cross-platform packages / Publish GitHub and OSS release (push) Canceled after 0s

Repeated full subagent progress snapshots could grow the desktop database to
many gigabytes during ordinary long-running tasks. Local and managed SSH
writes now retain lossless progress changes while preserving conversation
snapshots, event identities, remote replay deduplication and transaction rollback.

Upgrade existing history in bounded worker-thread batches, show progress and
retry controls, and reclaim freed disk space after conversion. Schema 34 cannot
be opened by older clients; a pre-upgrade backup is required for client rollback.
Document reusable database migration guidance and the verified large-database,
portable, local Runtime and Linux Host evidence.

Prepare Desktop 0.13.1 with the approved bilingual notes and aligned feature
lists. Agent remains 0.11.24. Final candidate validation and production builds
will run on main CI, as requested, without additional local release testing.

Release note: 修复长任务和子任务造成的数据库异常膨胀;升级时自动整理旧记录并回收空间,保留聊天、执行详情和结果,并支持退出后继续处理。
This commit is contained in:
mesalogo
2026-09-13 20:50:33 +08:00
parent 1c22e1a06d
commit 49377206cc
32 changed files with 1207 additions and 27 deletions
+117
View File
@@ -0,0 +1,117 @@
import { app, ipcMain, type BrowserWindow } from 'electron'
import { stat } from 'node:fs/promises'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { Worker } from 'node:worker_threads'
import {
assistantStorageActionSchema,
assistantStorageProgressSchema,
type AssistantStorageProgress
} from '../shared/assistant-storage-contracts'
import { ipcChannels } from '../shared/ipc-channels'
import { assertTrustedSender } from './trusted-ipc-sender'
import { loadMainWindow } from './window'
export async function prepareAssistantStorage(
window: BrowserWindow,
databasePath: string,
signal: AbortSignal
): Promise<void> {
const file = await stat(databasePath).catch((error: NodeJS.ErrnoException) => {
if (error.code === 'ENOENT') return undefined
throw error
})
if (!file) return
let progress: AssistantStorageProgress = {
stage: 'scanning', processed: 0, total: 0, bytesBefore: file.size
}
let shown = false
let retry: (() => void) | undefined
ipcMain.handle(ipcChannels.storageUpgradeProgress, (event) => {
assertTrustedSender(event, window)
return progress
})
ipcMain.handle(ipcChannels.storageUpgradeAction, (event, input: unknown) => {
assertTrustedSender(event, window)
const action = assistantStorageActionSchema.parse(input)
if (action === 'quit') app.quit()
else if (progress.stage === 'failed') retry?.()
})
const publish = (next: AssistantStorageProgress): void => {
progress = assistantStorageProgressSchema.parse(next)
if (window.isDestroyed()) return
if (!shown) {
shown = true
loadMainWindow(window, true)
}
window.setProgressBar(next.stage === 'failed' ? -1 :
next.total > 0 && next.stage === 'converting'
? next.processed / next.total : 2)
}
try {
while (!signal.aborted) {
try {
await runStorageWorker(databasePath, signal, publish)
return
} catch (error) {
if (signal.aborted) return
publish({
...progress, stage: 'failed',
error: error instanceof Error ? error.message : '历史执行记录优化失败'
})
await new Promise<void>((resolve) => {
const finish = (): void => {
signal.removeEventListener('abort', finish)
retry = undefined
resolve()
}
retry = finish
signal.addEventListener('abort', finish, { once: true })
})
}
}
} finally {
ipcMain.removeHandler(ipcChannels.storageUpgradeProgress)
ipcMain.removeHandler(ipcChannels.storageUpgradeAction)
if (!window.isDestroyed()) window.setProgressBar(-1)
}
}
function runStorageWorker(
databasePath: string,
signal: AbortSignal,
publish: (progress: AssistantStorageProgress) => void
): Promise<void> {
const cancellation = new SharedArrayBuffer(4)
const cancel = (): void => {
Atomics.store(new Int32Array(cancellation), 0, 1)
}
if (signal.aborted) return Promise.resolve()
signal.addEventListener('abort', cancel, { once: true })
return new Promise<void>((resolve, reject) => {
const worker = new Worker(join(
dirname(fileURLToPath(import.meta.url)), 'assistant-storage-worker.js'
), { workerData: { databasePath, cancellation } })
let failure: Error | undefined
let done = false
worker.on('message', (message: {
progress?: AssistantStorageProgress
error?: string
cancelled?: boolean
done?: boolean
}) => {
if (message.progress) publish(message.progress)
if (message.error && !message.cancelled) failure = new Error(message.error)
if (message.done || message.cancelled) done = true
})
worker.once('error', () => {
failure = new Error('无法启动历史执行记录优化,请重启 GoodBuddy 后重试。')
})
worker.once('exit', (code) => {
signal.removeEventListener('abort', cancel)
if (failure) reject(failure)
else if (done && code === 0) resolve()
else reject(new Error('历史执行记录优化意外结束,请重试。'))
})
})
}
+23
View File
@@ -0,0 +1,23 @@
import { parentPort, workerData } from 'node:worker_threads'
import { upgradeAssistantStorage } from './assistant/assistant-storage-upgrade'
const input = workerData as {
databasePath: string
cancellation: SharedArrayBuffer
}
const cancellation = new Int32Array(input.cancellation)
try {
upgradeAssistantStorage(
input.databasePath,
(progress) => parentPort?.postMessage({ progress }),
() => Atomics.load(cancellation, 0) !== 0
)
parentPort?.postMessage({ done: true })
} catch (error) {
parentPort?.postMessage({
error: error instanceof Error && /full|space/iu.test(error.message)
? '磁盘空间不足,无法完成空间回收。请释放一些空间后重试。'
: '历史执行记录优化未完成。请重试;若仍失败,请备份数据并联系支持。',
cancelled: error instanceof Error && error.name === 'AbortError'
})
}
+12 -9
View File
@@ -10,7 +10,10 @@ import {
conversationSnapshotSchema,
isUntouchedBuiltInDefaultProject
} from '../../shared/assistant-contracts'
import { AssistantDatabase } from './assistant-database'
import {
AssistantDatabase,
ASSISTANT_DATABASE_SCHEMA_VERSION
} from './assistant-database'
import { agentRuntimeSelectionKey } from '../../shared/runtime-selection-contracts'
const temporaryDirectories: string[] = []
@@ -641,7 +644,7 @@ describe('AssistantDatabase', () => {
database.close()
})
it('migrates existing databases to schema version 33', async () => {
it('migrates existing databases to the current schema version', async () => {
const directory = await mkdtemp(
join(tmpdir(), 'goodbuddy-assistant-migration-')
)
@@ -670,7 +673,7 @@ describe('AssistantDatabase', () => {
user_version: number
}
).user_version
).toBe(33)
).toBe(ASSISTANT_DATABASE_SCHEMA_VERSION)
expect(
current
.prepare(
@@ -840,7 +843,7 @@ describe('AssistantDatabase', () => {
enableForeignKeyConstraints: true
})
expect(inspected.prepare('PRAGMA user_version').get()).toEqual({
user_version: 33
user_version: ASSISTANT_DATABASE_SCHEMA_VERSION
})
expect(
inspected.prepare('SELECT * FROM projects ORDER BY rowid').all()
@@ -938,7 +941,7 @@ describe('AssistantDatabase', () => {
const inspected = new DatabaseSync(databasePath)
expect(inspected.prepare('PRAGMA user_version').get()).toEqual({
user_version: 33
user_version: ASSISTANT_DATABASE_SCHEMA_VERSION
})
expect(
inspected
@@ -1015,7 +1018,7 @@ describe('AssistantDatabase', () => {
const inspected = new DatabaseSync(databasePath)
expect(inspected.prepare('PRAGMA user_version').get()).toEqual({
user_version: 33
user_version: ASSISTANT_DATABASE_SCHEMA_VERSION
})
expect(
inspected
@@ -1265,7 +1268,7 @@ describe('AssistantDatabase', () => {
user_version: number
}
).user_version
).toBe(33)
).toBe(ASSISTANT_DATABASE_SCHEMA_VERSION)
expect(
current
.prepare(
@@ -1403,7 +1406,7 @@ describe('AssistantDatabase', () => {
const inspected = new DatabaseSync(databasePath)
expect(
inspected.prepare('PRAGMA user_version').get()
).toEqual({ user_version: 33 })
).toEqual({ user_version: ASSISTANT_DATABASE_SCHEMA_VERSION })
expect(
inspected
.prepare(
@@ -3102,7 +3105,7 @@ describe('AssistantDatabase', () => {
const inspected = new DatabaseSync(databasePath)
expect(inspected.prepare('PRAGMA user_version').get()).toEqual({
user_version: 33
user_version: ASSISTANT_DATABASE_SCHEMA_VERSION
})
expect(
inspected.prepare('PRAGMA table_info(task_events)').all()
+34 -6
View File
@@ -95,8 +95,9 @@ import {
setMagicNoteChecklistCompletion
} from '../magic-notes/rich-content'
import { computeNextHeartbeatRun } from './heartbeat-recurrence'
import { SubagentProgressStorage } from './subagent-progress-storage'
export const ASSISTANT_DATABASE_SCHEMA_VERSION = 33
export const ASSISTANT_DATABASE_SCHEMA_VERSION = 34
export type RemoteTaskEventInput = {
taskId: string
@@ -1750,6 +1751,7 @@ function deleteProjectRecords(
export class AssistantDatabase {
private database?: DatabaseSync
private subagentProgress?: SubagentProgressStorage
private channelEventWrites = 0
private channelOutboxWrites = 0
@@ -1780,6 +1782,7 @@ export class AssistantDatabase {
`)
this.migrate(database)
this.database = database
this.subagentProgress = new SubagentProgressStorage(database)
this.channelEventWrites = (
database
.prepare('SELECT COUNT(*) AS count FROM channel_events')
@@ -2015,6 +2018,7 @@ export class AssistantDatabase {
close(): void {
this.database?.close()
this.database = undefined
this.subagentProgress = undefined
}
clearAssistantData(): void {
@@ -4931,7 +4935,9 @@ export class AssistantDatabase {
kind: string,
payload: unknown
): void {
this.requireDatabase()
const database = this.requireDatabase()
const payloadJson = this.subagentProgress!.serialize(taskId, kind, payload)
const result = database
.prepare(
`INSERT INTO task_events
(task_id, run_id, kind, payload_json, created_at)
@@ -4940,9 +4946,12 @@ export class AssistantDatabase {
.run(
taskId,
kind.slice(0, 64),
JSON.stringify(payload),
payloadJson,
new Date().toISOString()
)
this.subagentProgress!.inserted(
taskId, kind, Number(result.lastInsertRowid), payloadJson
)
}
appendRemoteTaskEventOnce(input: RemoteTaskEventInput): boolean {
@@ -5335,6 +5344,9 @@ export class AssistantDatabase {
database: DatabaseSync,
event: ValidatedRemoteTaskEvent
): boolean {
const payloadJson = this.subagentProgress!.serialize(
event.taskId, event.kind, JSON.parse(event.payloadJson)
)
const result = database
.prepare(
`INSERT OR IGNORE INTO task_events
@@ -5346,7 +5358,7 @@ export class AssistantDatabase {
.run(
event.taskId,
event.kind,
event.payloadJson,
payloadJson,
new Date().toISOString(),
event.bindingId,
event.operationId,
@@ -5354,11 +5366,14 @@ export class AssistantDatabase {
event.eventIndex
)
if (result.changes === 1) {
this.subagentProgress!.inserted(
event.taskId, event.kind, Number(result.lastInsertRowid), payloadJson
)
return true
}
const existing = database
.prepare(
`SELECT task_id, kind, payload_json
`SELECT id, task_id, kind, payload_json
FROM task_events
WHERE remote_binding_id = ?
AND remote_operation_id = ?
@@ -5372,6 +5387,7 @@ export class AssistantDatabase {
event.eventIndex
) as
| {
id: number
task_id: string
kind: string
payload_json: string
@@ -5380,7 +5396,10 @@ export class AssistantDatabase {
if (
existing?.task_id === event.taskId &&
existing.kind === event.kind &&
existing.payload_json === event.payloadJson
(existing.payload_json === event.payloadJson ||
(event.kind === 'subagent' && this.subagentProgress!.matches(
event.taskId, existing.id, JSON.parse(event.payloadJson)
)))
) {
return false
}
@@ -9653,6 +9672,15 @@ export class AssistantDatabase {
COMMIT;
`)
}
if (version.user_version < 34) {
database.exec(`
BEGIN IMMEDIATE;
CREATE INDEX IF NOT EXISTS task_events_subagent_idx
ON task_events(task_id, id) WHERE kind = 'subagent';
PRAGMA user_version = 34;
COMMIT;
`)
}
}
private requireDatabase(): DatabaseSync {
@@ -0,0 +1,267 @@
// @vitest-environment node
import { randomUUID } from 'node:crypto'
import { mkdtemp, rm, stat } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { DatabaseSync } from 'node:sqlite'
import { afterEach, describe, expect, it } from 'vitest'
import type { ConversationMessageBlock } from '../../shared/assistant-contracts'
import type { SubagentEvent } from '../../shared/contracts'
import { AssistantDatabase } from './assistant-database'
import { upgradeAssistantStorage } from './assistant-storage-upgrade'
import {
compactSubagentPayload,
restoreSubagentPayload
} from './subagent-progress-storage'
const directories: string[] = []
const databases: AssistantDatabase[] = []
afterEach(async () => {
for (const database of databases.splice(0)) database.close()
await Promise.all(directories.splice(0).map(
(path) => rm(path, { recursive: true, force: true })
))
})
async function fixture(): Promise<{
path: string; database: AssistantDatabase; taskId: string
}> {
const directory = await mkdtemp(join(tmpdir(), 'goodbuddy-storage-upgrade-'))
directories.push(directory)
const path = join(directory, 'assistant.sqlite')
const database = new AssistantDatabase(path)
databases.push(database)
database.initialize(directory)
const taskId = randomUUID()
database.createTask({
id: taskId, title: 'Storage regression', instructions: 'Synthetic fixture',
workMode: 'execute'
})
return { path, database, taskId }
}
function subagent(requestId: string): SubagentEvent {
return {
requestId, type: 'subagent', childTaskId: randomUUID(),
expertId: randomUUID(), expertName: 'general', routingMode: 'native',
runtimeCallId: 'test-call', state: 'running', progress: []
}
}
describe('subagent progress storage', () => {
it('roundtrips every snapshot including corrections, reordered blocks and empty progress', () => {
const event = subagent(randomUUID())
const textId = randomUUID()
const toolId = randomUUID()
const text: ConversationMessageBlock = { id: textId, type: 'text', content: 'seed' }
const tool: ConversationMessageBlock = {
id: toolId, type: 'tool', tool: { name: 'read', state: 'completed', summary: 'Read', output: 'seed' }
}
const encoder = new Map<string, ConversationMessageBlock[]>()
const decoder = new Map<string, ConversationMessageBlock[]>()
for (const progress of [
[], [text], [text, tool],
[{ ...text, content: 'seed tail' }, tool],
[{ ...text, content: 'corrected' }, tool],
[tool, text], [], [text]
]) {
const full = { ...event, progress }
const compact = compactSubagentPayload(full, encoder)
restoreSubagentPayload(full, encoder)
expect(restoreSubagentPayload(compact, decoder)).toEqual(full)
}
})
it('stores small changes without repeating large tool outputs, including across reopen', async () => {
const fixtureData = await fixture()
let database = fixtureData.database
const event = subagent(fixtureData.taskId)
const tool: ConversationMessageBlock = {
id: randomUUID(), type: 'tool',
tool: { name: 'read', summary: 'Read', state: 'completed', output: 'x'.repeat(200_000) }
}
const textId = randomUUID()
try {
for (let index = 0; index < 200; index++) {
event.progress = [tool, { id: textId, type: 'text', content: `seed${'中'.repeat(index)}` }]
database.appendTaskEvent(fixtureData.taskId, 'subagent', event)
if (index === 99) {
database.close()
database = new AssistantDatabase(fixtureData.path)
database.initialize(tmpdir())
}
}
database.close()
const inspection = new DatabaseSync(fixtureData.path)
try {
const rows = inspection.prepare(
`SELECT payload_json FROM task_events WHERE kind = 'subagent' ORDER BY id`
).all() as Array<{ payload_json: string }>
expect(rows).toHaveLength(200)
expect(rows.reduce((sum, row) => sum + row.payload_json.length, 0)).toBeLessThan(400_000)
const state = new Map<string, ConversationMessageBlock[]>()
let restored: unknown
for (const row of rows) restored = restoreSubagentPayload(JSON.parse(row.payload_json), state)
expect(restored).toEqual(event)
} finally { inspection.close() }
} finally { database.close() }
})
it('keeps remote replay deduplication and conflicting payload checks after compaction', async () => {
const { database, taskId, path } = await fixture()
const event = subagent(taskId)
const textId = randomUUID()
const provenance = { taskId, bindingId: 'binding', operationId: 'operation', eventIndex: 0, kind: 'subagent' }
const first = { ...event, progress: [{ id: textId, type: 'text' as const, content: 'seed' }] }
const second = { ...event, progress: [{ id: textId, type: 'text' as const, content: 'seed tail' }] }
try {
expect(database.appendRemoteTaskEventOnce({ ...provenance, semanticSequence: '1', payload: first })).toBe(true)
expect(database.appendRemoteTaskEventOnce({ ...provenance, semanticSequence: '2', payload: second })).toBe(true)
expect(database.appendRemoteTaskEventOnce({ ...provenance, semanticSequence: '1', payload: first })).toBe(false)
expect(() => database.appendRemoteTaskEventOnce({ ...provenance, semanticSequence: '1', payload: second })).toThrow('conflicts')
} finally { database.close() }
const reopened = new AssistantDatabase(path)
reopened.initialize(tmpdir())
try {
expect(reopened.appendRemoteTaskEventOnce({ ...provenance, semanticSequence: '2', payload: second })).toBe(false)
expect(reopened.getHighestCommittedRemoteTaskEventSequence('binding', 'operation')).toBe('2')
} finally { reopened.close() }
})
it('rebuilds progress after a remote batch rolls back', async () => {
const { database, taskId } = await fixture()
const event = subagent(taskId)
const textId = randomUUID()
const base = { taskId, bindingId: 'binding', operationId: 'operation', eventIndex: 0, kind: 'subagent' }
const first = { ...event, progress: [{ id: textId, type: 'text' as const, content: 'seed' }] }
try {
database.appendRemoteTaskEventOnce({ ...base, semanticSequence: '2', payload: first })
expect(() => database.appendRemoteTaskEventsBatch([
{ ...base, semanticSequence: '1', payload: { ...first, progress: [] } },
{ ...base, semanticSequence: '2', payload: { ...first, output: 'conflict' } }
])).toThrow('conflicts')
expect(database.appendRemoteTaskEventOnce({ ...base, semanticSequence: '3', payload: first })).toBe(true)
expect(database.appendRemoteTaskEventOnce({ ...base, semanticSequence: '3', payload: first })).toBe(false)
} finally { database.close() }
})
it('keeps full remote conversation progress atomic with compact events', async () => {
const { database, path } = await fixture()
const hostId = randomUUID()
const project = database.createSshProject({
project: {
name: 'Remote regression', description: '', rootPath: '/tmp/goodbuddy-storage-test',
defaultWorkMode: 'execute', runtimeSelection: { provider: 'opencode' }
},
executionSpace: { kind: 'ssh', hostId, remoteRootPath: '/tmp/goodbuddy-storage-test' },
assertCurrent: () => undefined
})
const conversationId = randomUUID()
const userMessageId = randomUUID()
const assistantMessageId = randomUUID()
database.saveLocalConversations([{
header: { id: conversationId, projectId: project.id, title: 'Atomic regression', updatedAt: Date.now() },
messages: []
}])
const remoteTaskId = randomUUID()
database.createTask({
id: remoteTaskId, projectId: project.id, conversationId,
title: 'Atomic remote', instructions: 'Synthetic', workMode: 'execute',
remoteRecovery: {
recoverable: true, currentUserMessageId: userMessageId,
currentAssistantMessageId: assistantMessageId
}
})
const event = subagent(remoteTaskId)
const textId = randomUUID()
const first = { ...event, progress: [{ id: textId, type: 'text' as const, content: 'seed' }] }
const second = { ...event, state: 'completed' as const, output: 'Complete', progress: [
{ id: textId, type: 'text' as const, content: 'seed tail' }
] }
const owner = {
taskId: remoteTaskId, conversationId, assistantMessageId,
bindingId: 'atomic-binding', operationId: 'atomic-operation', eventIndex: 0
}
const inspection = new DatabaseSync(path)
try {
database.appendRemoteConversationTaskEventOnce({ ...owner, semanticSequence: '1', event: first })
inspection.exec(`CREATE TRIGGER reject_message BEFORE UPDATE ON messages
BEGIN SELECT RAISE(ABORT, 'fixture message write failed'); END;`)
expect(() => database.appendRemoteConversationTaskEventOnce({
...owner, semanticSequence: '2', event: second
})).toThrow('fixture message write failed')
expect(database.getHighestCommittedRemoteTaskEventSequence('atomic-binding', 'atomic-operation')).toBe('1')
inspection.exec('DROP TRIGGER reject_message')
expect(database.appendRemoteConversationTaskEventOnce({
...owner, semanticSequence: '2', event: second
})).toBe(true)
expect(database.appendRemoteConversationTaskEventOnce({
...owner, semanticSequence: '2', event: second
})).toBe(false)
const message = database.getConversation(conversationId).messages.find((row) => row.id === assistantMessageId)
expect(message?.subagents?.[0]).toMatchObject({
state: 'completed', progress: second.progress, output: 'Complete'
})
expect(inspection.prepare(
`SELECT COUNT(*) AS count FROM task_events WHERE task_id = ? AND kind = 'subagent'`
).get(remoteTaskId)).toEqual({ count: 2 })
} finally { inspection.close(); database.close() }
})
it('converts legacy rows in batches, resumes after cancellation and actually shrinks the file', async () => {
const { database, taskId, path } = await fixture()
database.close()
const event = subagent(taskId)
const tool: ConversationMessageBlock = {
id: randomUUID(), type: 'tool',
tool: { name: 'read', summary: 'Read', state: 'completed', output: 'x'.repeat(300_000) }
}
const textId = randomUUID()
const legacy = new DatabaseSync(path)
const expected: SubagentEvent[] = []
try {
legacy.exec('PRAGMA user_version = 33; BEGIN')
const insert = legacy.prepare(
`INSERT INTO task_events(task_id, kind, payload_json, created_at)
VALUES (?, 'subagent', ?, ?)`
)
for (let index = 0; index < 150; index++) {
const full = {
...event,
progress: [tool, { id: textId, type: 'text' as const, content: `seed${'中'.repeat(index)}` }]
}
expected.push(full)
insert.run(taskId, JSON.stringify(full), '2026-09-13T00:00:00Z')
}
legacy.exec('COMMIT')
} finally { legacy.close() }
const before = (await stat(path)).size
expect(before).toBeGreaterThan(40_000_000)
let cancelled = false
expect(() => upgradeAssistantStorage(path, (progress) => {
if (progress.processed >= 32) cancelled = true
}, () => cancelled)).toThrow('cancelled')
upgradeAssistantStorage(path, () => undefined)
const after = (await stat(path)).size
expect(after).toBeLessThan(before / 20)
const inspected = new DatabaseSync(path)
try {
expect(inspected.prepare('PRAGMA integrity_check').get()).toEqual({ integrity_check: 'ok' })
const rows = inspected.prepare(
`SELECT payload_json, created_at FROM task_events WHERE kind = 'subagent' ORDER BY id`
).all() as Array<{ payload_json: string; created_at: string }>
expect(rows).toHaveLength(expected.length)
const blocks = new Map<string, ConversationMessageBlock[]>()
rows.forEach((row, index) => {
expect(restoreSubagentPayload(JSON.parse(row.payload_json), blocks)).toEqual(expected[index])
expect(row.created_at).toBe('2026-09-13T00:00:00Z')
})
} finally { inspected.close() }
const reopened = new AssistantDatabase(path)
reopened.initialize(tmpdir())
reopened.close()
const messages: unknown[] = []
upgradeAssistantStorage(path, (value) => messages.push(value))
expect(messages).toEqual([])
}, 30_000)
})
@@ -0,0 +1,102 @@
import { statSync } from 'node:fs'
import { DatabaseSync } from 'node:sqlite'
import type { ConversationMessageBlock } from '../../shared/assistant-contracts'
import type { AssistantStorageProgress } from '../../shared/assistant-storage-contracts'
import {
compactSubagentPayload,
restoreSubagentPayload
} from './subagent-progress-storage'
export function upgradeAssistantStorage(
databasePath: string,
onProgress: (progress: AssistantStorageProgress) => void,
isCancelled: () => boolean = () => false
): void {
const database = new DatabaseSync(databasePath, { timeout: 5_000 })
const checkCancelled = (): void => {
if (isCancelled()) throw new DOMException('Upgrade cancelled', 'AbortError')
}
const bytesBefore = statSync(databasePath).size
try {
const version = database.prepare('PRAGMA user_version').get() as {
user_version: number
}
if (version.user_version < 1 || version.user_version >= 34) return
checkCancelled()
const progress: AssistantStorageProgress = {
stage: 'scanning', processed: 0, total: 0, bytesBefore
}
onProgress({ ...progress })
database.exec(`
PRAGMA journal_mode = WAL;
PRAGMA busy_timeout = 5000;
PRAGMA secure_delete = OFF;
PRAGMA cache_size = -65536;
CREATE INDEX IF NOT EXISTS task_events_subagent_idx
ON task_events(task_id, id) WHERE kind = 'subagent';
`)
const tasks = database.prepare(
`SELECT task_id, COUNT(*) AS count FROM task_events
WHERE kind = 'subagent' GROUP BY task_id`
).all() as Array<{ task_id: string; count: number }>
progress.total = tasks.reduce((sum, task) => sum + task.count, 0)
progress.stage = 'converting'
const select = database.prepare(
`SELECT id, payload_json FROM task_events
WHERE task_id = ? AND kind = 'subagent' AND id > ?
ORDER BY id LIMIT 32`
)
const update = database.prepare(
'UPDATE task_events SET payload_json = ? WHERE id = ?'
)
for (const task of tasks) {
const blocks = new Map<string, ConversationMessageBlock[]>()
let afterId = 0
for (;;) {
checkCancelled()
const rows = select.all(task.task_id, afterId) as Array<{
id: number; payload_json: string
}>
if (!rows.length) break
database.exec('BEGIN IMMEDIATE')
try {
for (const row of rows) {
const payload: unknown = JSON.parse(row.payload_json)
const compact = compactSubagentPayload(payload, blocks)
// Replay mixed old/new rows on retry. Never delete events or provenance.
restoreSubagentPayload(payload, blocks)
if (compact !== payload) {
update.run(JSON.stringify(compact), row.id)
}
afterId = row.id
}
database.exec('COMMIT')
} catch (error) {
database.exec('ROLLBACK')
throw error
}
progress.processed += rows.length
onProgress({ ...progress })
}
database.exec('PRAGMA wal_checkpoint(TRUNCATE)')
}
checkCancelled()
const free = database.prepare('PRAGMA freelist_count').get() as {
freelist_count: number
}
if (free.freelist_count > 0) {
progress.stage = 'compacting'
onProgress({ ...progress })
// Rebuild the now-small live database, not a second copy of the bloated input.
database.exec('PRAGMA wal_checkpoint(TRUNCATE); VACUUM;')
database.exec('PRAGMA wal_checkpoint(TRUNCATE)')
}
checkCancelled()
onProgress({
...progress, stage: 'complete',
bytesAfter: statSync(databasePath).size
})
} finally {
database.close()
}
}
@@ -0,0 +1,207 @@
import { z } from 'zod'
import { isDeepStrictEqual } from 'node:util'
import type { DatabaseSync } from 'node:sqlite'
import {
assistantIdSchema,
conversationMessageBlockSchema,
type ConversationMessageBlock
} from '../../shared/assistant-contracts'
export const subagentProgressUpdateSchema = z.discriminatedUnion('type', [
z.object({
type: z.literal('append'),
id: assistantIdSchema,
blockType: z.enum(['text', 'reasoning']),
delta: z.string()
}).strict(),
z.object({
type: z.literal('upsert'),
block: conversationMessageBlockSchema
}).strict(),
z.object({
type: z.literal('reset'),
blocks: z.array(conversationMessageBlockSchema)
}).strict()
])
export type SubagentProgressUpdate = z.infer<
typeof subagentProgressUpdateSchema
>
type ProgressEvent = {
progress?: ConversationMessageBlock[]
progressUpdates?: SubagentProgressUpdate[]
}
export function applySubagentProgress(
previous: ConversationMessageBlock[] | undefined,
event: ProgressEvent
): ConversationMessageBlock[] | undefined {
let blocks = event.progress ?? previous
if (!event.progressUpdates?.length) return blocks
blocks = [...(blocks ?? [])]
for (const update of event.progressUpdates) {
if (update.type === 'reset') {
blocks = [...update.blocks]
continue
}
const id = update.type === 'upsert' ? update.block.id : update.id
const index = blocks.findIndex((block) => block.id === id)
const prior = blocks[index]
const block: ConversationMessageBlock = update.type === 'upsert'
? update.block
: {
id,
type: update.blockType,
content: (
prior?.type === update.blockType ? prior.content : ''
) + update.delta
}
if (index < 0) blocks.push(block)
else blocks[index] = block
}
return blocks
}
export function diffSubagentProgress(
previous: readonly ConversationMessageBlock[],
next: readonly ConversationMessageBlock[]
): SubagentProgressUpdate[] {
if (
previous.length > next.length ||
previous.some((block, index) => block.id !== next[index]?.id)
) {
return [{ type: 'reset', blocks: [...next] }]
}
const updates: SubagentProgressUpdate[] = []
for (let index = 0; index < next.length; index += 1) {
const block = next[index]!
const prior = previous[index]
if (block === prior) continue
if (
(block.type === 'text' || block.type === 'reasoning') &&
prior?.type === block.type &&
block.content.startsWith(prior.content)
) {
const delta = block.content.slice(prior.content.length)
if (delta) {
updates.push({
type: 'append', id: block.id, blockType: block.type, delta
})
}
} else if (!prior || !isDeepStrictEqual(prior, block)) {
updates.push({ type: 'upsert', block })
}
}
return updates
}
type StoredSubagent = ProgressEvent & {
childTaskId: string
[key: string]: unknown
}
function asSubagent(payload: unknown): StoredSubagent | undefined {
if (
typeof payload !== 'object' || payload === null ||
!('childTaskId' in payload) || typeof payload.childTaskId !== 'string'
) return undefined
return payload as StoredSubagent
}
export function compactSubagentPayload(
payload: unknown,
progress: ReadonlyMap<string, ConversationMessageBlock[]>
): unknown {
const event = asSubagent(payload)
if (!event || !Array.isArray(event.progress)) return payload
const compact = { ...event }
delete compact.progress
compact.progressUpdates = diffSubagentProgress(
progress.get(event.childTaskId) ?? [],
event.progress
)
return compact
}
export function restoreSubagentPayload(
payload: unknown,
progress: Map<string, ConversationMessageBlock[]>
): unknown {
const event = asSubagent(payload)
if (!event) return payload
const blocks = applySubagentProgress(
progress.get(event.childTaskId), event
)
if (blocks) progress.set(event.childTaskId, blocks)
if (!event.progressUpdates) return payload
const restored = { ...event, progress: blocks ?? [] }
delete restored.progressUpdates
return restored
}
type TaskProgress = {
lastId: number
blocks: Map<string, ConversationMessageBlock[]>
}
/** The conversation keeps the display snapshot; its event history stores changes. */
export class SubagentProgressStorage {
private readonly tasks = new Map<string, TaskProgress>()
constructor(private readonly database: DatabaseSync) {}
serialize(taskId: string, kind: string, payload: unknown): string {
return JSON.stringify(kind === 'subagent'
? compactSubagentPayload(payload, this.current(taskId).blocks)
: payload)
}
inserted(taskId: string, kind: string, id: number, payloadJson: string): void {
if (kind !== 'subagent') return
const task = this.tasks.get(taskId)
if (!task) return
restoreSubagentPayload(JSON.parse(payloadJson), task.blocks)
task.lastId = id
}
matches(taskId: string, eventId: number, payload: unknown): boolean {
const blocks = new Map<string, ConversationMessageBlock[]>()
const rows = this.database.prepare(
`SELECT id, payload_json FROM task_events
WHERE task_id = ? AND kind = 'subagent' AND id <= ?
ORDER BY id`
).iterate(taskId, eventId)
for (const row of rows) {
const restored = restoreSubagentPayload(
JSON.parse(row.payload_json as string), blocks
)
if (row.id === eventId) return isDeepStrictEqual(restored, payload)
}
return false
}
private current(taskId: string): TaskProgress {
const last = this.database.prepare(
`SELECT id FROM task_events WHERE task_id = ? AND kind = 'subagent'
ORDER BY id DESC LIMIT 1`
).get(taskId) as { id: number } | undefined
let task = this.tasks.get(taskId)
if (task?.lastId === (last?.id ?? 0)) return task
// A rolled-back transaction or reopened task is rebuilt from committed rows.
task = { lastId: last?.id ?? 0, blocks: new Map() }
const rows = this.database.prepare(
`SELECT payload_json FROM task_events
WHERE task_id = ? AND kind = 'subagent' ORDER BY id`
).iterate(taskId)
for (const row of rows) {
restoreSubagentPayload(JSON.parse(row.payload_json as string), task.blocks)
}
this.tasks.delete(taskId)
this.tasks.set(taskId, task)
if (this.tasks.size > 16) {
this.tasks.delete(this.tasks.keys().next().value!)
}
return task
}
}
+13
View File
@@ -37,6 +37,7 @@ import {
} from './ipc'
import { KnowledgeService } from './knowledge/knowledge-service'
import { AssistantDatabase } from './assistant/assistant-database'
import { prepareAssistantStorage } from './assistant-storage-startup'
import { createModelGraphExtractor } from './knowledge/model-extractor'
import { OpenAIEmbeddingClient } from './knowledge/openai-embedding-client'
import { embeddingProviderFingerprint } from './knowledge/embedding-provider-key'
@@ -512,6 +513,9 @@ function buildTray(): Tray {
return nextTray
}
const storageUpgradeController = new AbortController()
let storageUpgrade: Promise<void> | undefined
if (hasSingleInstanceLock) {
app.on('second-instance', () => {
if (mainWindow) {
@@ -544,6 +548,13 @@ if (hasSingleInstanceLock) {
mainWindow = createMainWindow(() => isQuitting)
registerDesktopNotificationActivation(mainWindow)
tray = buildTray()
storageUpgrade = prepareAssistantStorage(
mainWindow,
join(app.getPath('userData'), 'assistant.sqlite'),
storageUpgradeController.signal
)
await storageUpgrade
if (storageUpgradeController.signal.aborted) return
const defaultWorkspace = process.env.GOODBUDDY_WORKSPACE ?? homedir()
const secureCipher = {
isAvailable: () =>
@@ -1478,6 +1489,7 @@ let cleanupComplete = false
app.on('before-quit', (event) => {
isQuitting = true
storageUpgradeController.abort()
if (cleanupComplete) {
return
}
@@ -1490,6 +1502,7 @@ app.on('before-quit', (event) => {
try {
const cleanup = settleCleanupPhases([
[
() => storageUpgrade,
() => feedbackService?.dispose(),
() => removeFeedbackIpcHandler?.(),
() => dshExtensionInstaller?.dispose(),
+11 -3
View File
@@ -105,11 +105,19 @@ export function createMainWindow(shouldQuit: () => boolean): BrowserWindow {
return window
}
export function loadMainWindow(window: BrowserWindow): void {
export function loadMainWindow(
window: BrowserWindow,
storageUpgrade = false
): void {
if (process.env.ELECTRON_RENDERER_URL) {
void window.loadURL(process.env.ELECTRON_RENDERER_URL)
const url = new URL(process.env.ELECTRON_RENDERER_URL)
if (storageUpgrade) url.searchParams.set('storageUpgrade', '1')
void window.loadURL(url.href)
} else {
void window.loadFile(join(currentDirectory, '../renderer/index.html'))
void window.loadFile(
join(currentDirectory, '../renderer/index.html'),
storageUpgrade ? { query: { storageUpgrade: '1' } } : undefined
)
}
}
+6
View File
@@ -205,6 +205,12 @@ import type {
const desktopApi: DesktopApi = {
storageUpgrade: {
getProgress: () => ipcRenderer.invoke(ipcChannels.storageUpgradeProgress),
act: async (action) => {
await ipcRenderer.invoke(ipcChannels.storageUpgradeAction, action)
}
},
app: {
getInfo: () => ipcRenderer.invoke(ipcChannels.appInfo) as Promise<AppInfo>,
show: async () => {
+4
View File
@@ -181,6 +181,10 @@ const terminalSnapshot: TerminalSnapshot = {
};
const api: DesktopApi = {
storageUpgrade: {
getProgress: vi.fn(),
act: vi.fn(async () => undefined)
},
app: {
getInfo: vi.fn(async () => ({
name: "GoodBuddy",
+18
View File
@@ -0,0 +1,18 @@
.storage-upgrade {
display: flex;
width: 100%;
min-height: 100vh;
background: var(--surface-raised);
}
.storage-upgrade > .page-shell {
flex: 1;
min-height: 100vh;
}
.storage-upgrade progress {
display: block;
width: 100%;
height: var(--space-2);
accent-color: var(--accent-solid);
}
+36
View File
@@ -0,0 +1,36 @@
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { afterEach, expect, it, vi } from 'vitest'
import { StorageUpgrade } from './StorageUpgrade'
import type { DesktopApi } from '../../shared/contracts'
afterEach(() => { cleanup(); vi.unstubAllGlobals() })
it('shows real progress and lets the user quit without deleting records', async () => {
const act = vi.fn(async () => undefined)
vi.stubGlobal('goodbuddy', { storageUpgrade: {
getProgress: vi.fn(async () => ({
stage: 'converting', processed: 32, total: 100, bytesBefore: 90_000_000_000
})),
act
} } as unknown as DesktopApi)
render(<StorageUpgrade />)
await waitFor(() => expect(screen.getByRole('progressbar')).toHaveAttribute('value', '32'))
expect(screen.getByText(/保留聊天、执行详情和结果/)).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: '退出,稍后继续' }))
await waitFor(() => expect(act).toHaveBeenCalledWith('quit'))
})
it('keeps a failed upgrade actionable through retry', async () => {
const act = vi.fn(async () => undefined)
vi.stubGlobal('goodbuddy', { storageUpgrade: {
getProgress: vi.fn(async () => ({
stage: 'failed', processed: 32, total: 100, bytesBefore: 90_000_000_000,
error: '磁盘空间不足'
})),
act
} } as unknown as DesktopApi)
render(<StorageUpgrade />)
expect(await screen.findByRole('alert')).toHaveTextContent('磁盘空间不足')
fireEvent.click(screen.getByRole('button', { name: '重试' }))
await waitFor(() => expect(act).toHaveBeenCalledWith('retry'))
})
+94
View File
@@ -0,0 +1,94 @@
import { useEffect, useState } from 'react'
import type { AssistantStorageProgress } from '../../shared/assistant-storage-contracts'
import { PageHeader, PageShell } from './WorkspacePrimitives'
import { useUiLocale } from './i18n/UiLocaleProvider'
import './StorageUpgrade.css'
export function StorageUpgrade(): React.JSX.Element {
const { resolvedLocale } = useUiLocale()
const en = resolvedLocale === 'en-US'
const [progress, setProgress] = useState<AssistantStorageProgress>()
const [rate, setRate] = useState<number>()
const [actionError, setActionError] = useState('')
useEffect(() => {
let active = true
let timer: ReturnType<typeof setTimeout>
let sample: { count: number; time: number } | undefined
const poll = async (): Promise<void> => {
try {
const next = await window.goodbuddy.storageUpgrade.getProgress()
if (active) {
setProgress(next)
if (next.stage === 'converting') {
const now = performance.now()
if (sample && now - sample.time >= 2_000) {
setRate(Math.max(0, (next.processed - sample.count) * 1_000 / (now - sample.time)))
sample = { count: next.processed, time: now }
} else if (!sample) {
sample = { count: next.processed, time: now }
}
} else {
sample = undefined
setRate(undefined)
}
}
} catch {
// The normal application replaces this page when startup continues.
}
if (active) timer = setTimeout(() => void poll(), 500)
}
void poll()
return () => { active = false; clearTimeout(timer) }
}, [])
const title = en ? 'Optimizing saved execution history' : '正在优化历史执行记录'
const stage = progress?.stage
const status = stage === 'failed'
? progress?.error
: stage === 'compacting'
? en ? 'Reclaiming disk space…' : '正在回收磁盘空间…'
: stage === 'complete'
? en ? 'Optimization complete. Starting GoodBuddy…' : '优化完成,正在启动 GoodBuddy…'
: progress?.stage === 'converting'
? `${en ? 'Processed' : '已处理'} ${progress.processed.toLocaleString()} / ${progress.total.toLocaleString()}`
: en ? 'Checking saved execution history…' : '正在检查历史执行记录…'
const act = async (action: 'retry' | 'quit'): Promise<void> => {
try {
setActionError('')
await window.goodbuddy.storageUpgrade.act(action)
} catch {
setActionError(en ? 'The action failed. Please try again.' : '操作未完成,请重试。')
}
}
return (
<main className="storage-upgrade" aria-labelledby="storage-upgrade-title">
<PageShell variant="standard">
<PageHeader
headingId="storage-upgrade-title"
title={title}
description={en
? 'This update removes repeated copies of subagent progress. Your chats, execution details and results are preserved. Time depends on the history size and disk speed.'
: '本次更新清理子任务进度的重复副本,保留聊天、执行详情和结果。处理时间取决于历史数据量和磁盘速度。'}
/>
<p role={stage === 'failed' ? 'alert' : 'status'}>{status}</p>
{progress?.stage === 'converting' && progress.total > 0 && (
<progress aria-label={title} max={progress.total} value={progress.processed} />
)}
{stage === 'converting' && rate !== undefined && (
<p>{en ? 'Processing speed' : '处理速度'}:{Math.round(rate).toLocaleString()} {en ? 'events/s' : '条/秒'}</p>
)}
<p>{en
? 'You can quit and retry on the next launch. Completed batches are preserved.'
: '可以退出并在下次启动时继续处理,已完成的批次会保留。'}</p>
{actionError && <p role="alert">{actionError}</p>}
{stage === 'failed' && (
<button className="primary-button" onClick={() => void act('retry')}>
{en ? 'Retry' : '重试'}
</button>
)}
<button className="secondary-button" onClick={() => void act('quit')}>
{en ? 'Quit and continue later' : '退出,稍后继续'}
</button>
</PageShell>
</main>
)
}
+4 -1
View File
@@ -4,6 +4,7 @@ import '@fontsource-variable/noto-sans-sc/wght.css'
import 'katex/dist/katex.min.css'
import './markdown-math.css'
import App from './App'
import { StorageUpgrade } from './StorageUpgrade'
import { installBundledUiFonts } from './fonts'
import { changeUiLocale } from './i18n'
import {
@@ -53,7 +54,9 @@ createRoot(root).render(
<UiLocaleProvider
initialPreference={initialUiLocalePreference}
>
<App />
{new URLSearchParams(window.location.search).get('storageUpgrade') === '1'
? <StorageUpgrade />
: <App />}
</UiLocaleProvider>
</StrictMode>
)
+15
View File
@@ -0,0 +1,15 @@
import { z } from 'zod'
export const assistantStorageProgressSchema = z.object({
stage: z.enum(['scanning', 'converting', 'compacting', 'complete', 'failed']),
processed: z.number().int().nonnegative(),
total: z.number().int().nonnegative(),
bytesBefore: z.number().nonnegative(),
bytesAfter: z.number().nonnegative().optional(),
error: z.string().optional()
}).strict()
export type AssistantStorageProgress = z.infer<
typeof assistantStorageProgressSchema
>
export const assistantStorageActionSchema = z.enum(['retry', 'quit'])
+4
View File
@@ -1747,6 +1747,10 @@ export type KnowledgeSearchReference = {
}
export type DesktopApi = {
storageUpgrade: {
getProgress: () => Promise<import('./assistant-storage-contracts').AssistantStorageProgress>
act: (action: 'retry' | 'quit') => Promise<void>
}
app: {
getInfo: () => Promise<AppInfo>
show: () => Promise<void>
+2
View File
@@ -1,4 +1,6 @@
export const ipcChannels = {
storageUpgradeProgress: 'app:storage-upgrade:progress',
storageUpgradeAction: 'app:storage-upgrade:action',
externalInstancesList: 'knowledge:external-instances:list',
externalInstancesSave: 'knowledge:external-instances:save',
externalInstancesTest: 'knowledge:external-instances:test',