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
@@ -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)
})