fix: stop repeated history migration after restart

Desktop 0.13.8 still treated SQLite freelist pages left by normal chat and note writes as evidence of pending migration when a file-backed note existed. Migration detection now checks only the schema version and legacy note payloads, while the startup worker reuses its confirmed pending decision during retries so actual migrations can still finish compaction.

Release note: 修复已有笔记用户正常聊天后重启仍重复显示历史迁移的问题;已完成迁移的数据继续按新格式保存,普通写入产生的空闲页不再触发迁移。
This commit is contained in:
mesalogo
2026-09-21 12:31:14 +08:00
parent 8dce1d7007
commit 38d0923d79
11 changed files with 117 additions and 31 deletions
+6 -3
View File
@@ -24,7 +24,7 @@ records are listed separately and do not introduce another feature status.
packages target compatible x64 and arm64 environments, including UOS, Kylin,
Hygon, Zhaoxin, Kunpeng, and Phytium systems. This is not vendor certification.
LoongArch has a separate experimental loong64 preview outside standard
releases and automatic updates; no preview is planned for 0.13.8. See the
releases and automatic updates; no preview is planned for 0.13.9. See the
[preview boundaries](./docs/development/loongarch-preview-build.md).
- [x] **Configurable global shortcut**: Enable, disable, or record an Electron
accelerator under Platform Features / General. The default remains
@@ -315,7 +315,7 @@ records are listed separately and do not introduce another feature status.
failure, Agent `SIGKILL`/restart, and recovery from a reopened Desktop SQLite
database. Successful tool START/END events appear exactly once, with no
Prompt, provider, or tool replay observed. The current Agent source lock is
`0.13.2`, while the current Desktop release candidate is `0.13.8`; formal
`0.13.2`, while the current Desktop release candidate is `0.13.9`; formal
publication status follows the separate Agent and Desktop
release channels. Previous macOS validation covered native package installation,
detached lifecycle, Attach, real Ask/Execute, and cancellation of tools in
@@ -825,9 +825,12 @@ records are listed separately and do not introduce another feature status.
### Open source, builds, and releases
- Current source candidates are Desktop `0.13.8` and Agent `0.13.2`, with
- Current source candidates are Desktop `0.13.9` and Agent `0.13.2`, with
OpenCode pinned to `1.18.29` and Continue to `1.5.47`. Publication status follows the independent
Desktop and Agent release channels.
- Desktop `0.13.9` fixes repeated startup migration after normal writes when
file-backed notes already exist. Agent and storage formats remain unchanged;
candidate main-branch CI and native release packaging are still required.
- Agent `0.13.2` packages both OpenCode and Continue and delivers native execution
checklists. Its packages require Desktop `0.13.8`;
update Desktop first, then the Host environment. Node remains `24.19.0`.
+2 -1
View File
@@ -140,7 +140,8 @@
### 开源、构建与发布
- 当前源码候选为 Desktop `0.13.8`、Agent `0.13.2`,OpenCode 固定为 `1.18.29`、Continue 为 `1.5.47`;正式发布状态以独立发布渠道为准。
- 当前源码候选为 Desktop `0.13.9`、Agent `0.13.2`,OpenCode 固定为 `1.18.29`、Continue 为 `1.5.47`;正式发布状态以独立发布渠道为准。
- Desktop `0.13.9` 修复已有文件格式笔记时,正常写入后重启仍重复进入迁移的问题。Agent 与存储格式均不变;候选仍需主分支 CI 和原生发布打包,本次不构建 LoongArch 预览。
- Agent `0.13.2` 同时携带 OpenCode 和 Continue,并交付原生执行清单;复合包要求 Desktop `0.13.8`,请先升级 Desktop,再更新 Host 环境。Node 保持 `24.19.0`。
- 验证记录与功能状态分开。存储已有本地及 Linux x64 Host 真实工具任务、多项目多会话、取消和无损迁移验证;进程复用有 Windows 完整 App 与 Linux x64 Host 证据,PPTX OCR 与空闲内存释放有 Windows 真实文件证据。图片工具已有真实服务商生成/编辑及 Host 传输证据,这些记录未覆盖完整界面自然语言调用和本地/远程切换。候选仍需主分支 CI 及原生发布打包,详见[Runtime 验证记录](./docs/features/assistant-workbar/progress.md)与[图片工具验证记录](./docs/features/conversation-media-generation/progress.md)。
- 发布准备前,`9836a4c` 已通过本地 4,858 项测试(67 项跳过)、完整类型检查和 lint;真实服务商画布分析仍未验证。该提交的三个原生 Agent CI 构建通过;桌面 CI `35485936807` 为 4,875 项通过、8 项 Electron 测试失败,均因 Linux 沙箱辅助程序未配置,在页面断言前退出。候选配置该程序并在 Xvfb 下运行测试,不关闭沙箱;候选本地验证、主分支 CI 与原生标签打包分别记录,不在发布准备中运行本地生产构建或打包。
@@ -81,6 +81,11 @@ Renderer 合并时独立接收 Main 的置顶属性,即使本地正文更新
## 旧库升级
启动检查只根据旧 schema 或仍未转换的笔记正文决定是否进入升级流程;正常写入、更新
与删除产生的 SQLite 空闲页不是旧格式证据。迁移后新增数据使用当前格式,不因正常使用
再次迁移。同一次启动已进入的迁移流程可重试压缩;若正文已转换、仅压缩中断,退出后
重开不单凭空闲页重新进入迁移,剩余页由 SQLite 后续写入复用。不新增持久化迁移标记。
桌面在打开业务数据库之前启动独立 Node 工作线程,使用同一套编码和还原函数转换旧库。
按任务有序读取,每批最多 32 条,提交后报告进度;SQLite 保留事务原子性及正常 WAL
自动 checkpoint,每个任务结束时截断 WAL。读取缓存为 64 MiB,避免 1 MiB 级历史
+12 -9
View File
@@ -1,15 +1,18 @@
# 工作栏实现与验证进度
## 2026-09-21 修复重复历史迁移
## 2026-09-21 重复历史迁移补修(Desktop 0.13.9 候选)
- 启动时历史优化 Worker 之前只判断 `assistant.sqlite` 是否存在,没有先判断
`PRAGMA user_version` 是否已经达到当前 Assistant schema,因此已完成迁移的数据库仍会
重复进入启动准备流程。现在仅对低于当前 schema 的数据库启动 Worker;已完成迁移的
数据库直接进入正常启动,聊天、退出和再次打开不会重新显示历史迁移页面。
- 该问题来自 `49377206` 引入的启动迁移入口;本次修复不改变旧数据库的实际迁移逻辑,
仍保留首次升级、取消后重试和已提交批次续跑行为。
- `npx vitest run src/main/assistant-storage-startup.test.ts src/main/assistant/assistant-storage-upgrade.test.ts src/main/assistant/assistant-database.test.ts`:
**115 项通过**;`npm run typecheck`、`npm run lint`:通过。
- 0.13.8 的检查仍在已有笔记时把 `freelist_count > 0` 当作迁移依据;正常聊天更新
也会释放页。此前无笔记的回归提前返回,漏掉这一场景,不能证明该版本已修复。
- 启动检查现在只依据旧 schema 或旧笔记 payload;空闲页只用于已进入迁移流程的
压缩步骤。同一启动流程的 Worker 重试显式复用已确认的迁移决定,不持久化额外标记。
若正文已转换但仅压缩中断,退出后重新启动不因剩余空闲页再次迁移,SQLite 可复用这些页。
- 新回归先复现旧实现错误,再覆盖真实 SQLite 的旧笔记迁移、新建与编辑笔记、
聊天释放页、连续三次重开、内容一致性和完整性;断言没有进度回调或 VACUUM。
- 此次仅修改 Desktop 启动流程;新写入格式、schema、Agent 和远程协议不变。
- 迁移、Magic Notes、数据库专项复跑 **135 项通过**(30 秒单测上限);此前一个
listTasks 测试超时,独立复跑通过。加强后的迁移与笔记专项 **32 项通过**。
全量测试此前被取消,候选全量验证和 CI 尚待完成。
## 2026-09-19 问答位置持久化
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "goodbuddy",
"version": "0.13.8",
"version": "0.13.9",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "goodbuddy",
"version": "0.13.8",
"version": "0.13.9",
"license": "0BSD",
"dependencies": {
"@agentclientprotocol/sdk": "0.25.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "goodbuddy",
"version": "0.13.8",
"version": "0.13.9",
"private": true,
"description": "Secure desktop AI workspace with controlled Agent Runtimes",
"desktopName": "GoodBuddy",
+26
View File
@@ -1,6 +1,32 @@
{
"formatVersion": 1,
"releases": [
{
"version": "0.13.9",
"releasedAt": "2026-09-21",
"notes": {
"zh-CN": {
"highlights": [],
"features": [],
"fixes": [
"**修复重启后重复显示历史迁移**:修复已有笔记的用户正常聊天后,再次打开应用仍进入迁移页面的问题。已完成升级的数据继续按新格式保存,正常读写产生的数据库空闲页不再触发迁移;真正的旧数据仍会正常升级。"
],
"notices": [
"本次仅更新 Desktop,Agent 保持 0.13.2,无需因此更新 Host 环境。"
]
},
"en-US": {
"highlights": [],
"features": [],
"fixes": [
"**Stop repeated history migration after restart**: Fixed users with existing notes seeing the migration screen again after normal chat activity and reopening the app. Upgraded data continues to use the new storage format, and free database pages from normal writes no longer trigger migration; genuinely older data still upgrades normally."
],
"notices": [
"This release updates Desktop only. Agent remains at 0.13.2, with no Host environment update required for this fix."
]
}
}
},
{
"version": "0.13.8",
"releasedAt": "2026-09-20",
+3 -1
View File
@@ -7,10 +7,12 @@ const input = workerData as {
}
const cancellation = new Int32Array(input.cancellation)
try {
// Startup checks for legacy data before spawning this worker, including retries.
upgradeAssistantStorage(
input.databasePath,
(progress) => parentPort?.postMessage({ progress }),
() => Atomics.load(cancellation, 0) !== 0
() => Atomics.load(cancellation, 0) !== 0,
{ pendingUpgradeConfirmed: true }
)
parentPort?.postMessage({ done: true })
} catch (error) {
@@ -2,7 +2,7 @@
import { randomUUID } from 'node:crypto'
import { mkdtemp, rm, stat } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { dirname, join } from 'node:path'
import { DatabaseSync } from 'node:sqlite'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { ConversationMessageBlock } from '../../shared/assistant-contracts'
@@ -88,6 +88,58 @@ describe('subagent progress storage', () => {
} finally { sql.close() }
})
it('does not re-show migration after a note is already migrated and later chats free pages', async () => {
const { path, database } = await fixture()
const note = database.createMagicNote({ title: 'Legacy note', content: { version: 1, ops: [{ insert: 'note body\n' }] } })
database.close()
const legacy = new DatabaseSync(path)
try {
legacy.prepare('UPDATE magic_note_entries SET content_json = ? WHERE id = ?')
.run(JSON.stringify(note.entries[0]!.content), note.entries[0]!.id)
legacy.exec('PRAGMA user_version = 37')
} finally { legacy.close() }
expect(hasPendingAssistantStorageUpgrade(path)).toBe(true)
upgradeAssistantStorage(path, () => undefined)
expect(hasPendingAssistantStorageUpgrade(path)).toBe(false)
database.initialize(dirname(path))
expect(database.getMagicNote(note.id)).toEqual(note)
const entry = database.createMagicNoteEntry({
noteId: note.id, content: { version: 1, ops: [{ insert: 'New entry\n' }] }, plainText: ''
}).entries[1]!
database.updateMagicNoteEntry({
entryId: entry.id, expectedRevision: entry.revision,
content: { version: 1, ops: [{ insert: 'Edited entry\n' }] }, plainText: ''
})
const expectedNote = database.getMagicNote(note.id)
const header = { id: randomUUID(), title: 'Chat after note', updatedAt: 1000 }
const message = {
id: randomUUID(), role: 'assistant' as const, state: 'complete' as const,
content: 'Long draft'.repeat(100_000), createdAt: 1000
}
database.saveLocalConversations([{ header, messages: [message] }])
database.saveLocalConversations([{ header, messages: [{ ...message, content: 'Final response' }] }])
const expectedConversation = database.getConversation(header.id)
database.close()
const sql = new DatabaseSync(path)
try {
expect(sql.prepare('PRAGMA freelist_count').get()!.freelist_count).toBeGreaterThan(0)
expect(sql.prepare("SELECT COUNT(*) AS count FROM magic_note_entries WHERE json_extract(content_json, '$.storage') = 'file'").get()!.count).toBe(2)
const exec = vi.spyOn(DatabaseSync.prototype, 'exec')
for (let restart = 0; restart < 3; restart++) {
expect(hasPendingAssistantStorageUpgrade(path)).toBe(false)
const progress = vi.fn()
upgradeAssistantStorage(path, progress)
expect(progress).not.toHaveBeenCalled()
database.initialize(dirname(path))
expect(database.getMagicNote(note.id)).toEqual(expectedNote)
expect(database.getConversation(header.id)).toEqual(expectedConversation)
database.close()
}
expect(exec.mock.calls.some(([statement]) => /VACUUM/i.test(statement))).toBe(false)
expect(sql.prepare('PRAGMA integrity_check').get()).toEqual({ integrity_check: 'ok' })
} finally { sql.close() }
})
it('roundtrips every snapshot including corrections, reordered blocks and empty progress', () => {
const event = subagent(randomUUID())
const textId = randomUUID()
@@ -33,22 +33,13 @@ export function hasPendingAssistantStorageUpgrade(
LIMIT 1`
).get()
if (!noteTable) return false
const noteEntry = database.prepare(
'SELECT 1 FROM magic_note_entries LIMIT 1'
).get()
if (!noteEntry) return false
const hasLegacyPayload = Boolean(database.prepare(
return Boolean(database.prepare(
`SELECT 1
FROM magic_note_entries
WHERE json_extract(content_json, '$.storage') IS NULL
OR json_extract(content_json, '$.storage') <> 'file'
LIMIT 1`
).get())
if (hasLegacyPayload) return true
const free = database.prepare('PRAGMA freelist_count').get() as {
freelist_count: number
}
return free.freelist_count > 0
} finally {
database.close()
}
@@ -57,9 +48,10 @@ export function hasPendingAssistantStorageUpgrade(
export function upgradeAssistantStorage(
databasePath: string,
onProgress: (progress: AssistantStorageProgress) => void,
isCancelled: () => boolean = () => false
isCancelled: () => boolean = () => false,
options: { pendingUpgradeConfirmed?: boolean } = {}
): void {
if (!hasPendingAssistantStorageUpgrade(databasePath)) return
if (!options.pendingUpgradeConfirmed && !hasPendingAssistantStorageUpgrade(databasePath)) return
upgradeSubagentStorage(databasePath, onProgress, isCancelled)
new AssistantDatabase(databasePath).upgradeMagicNoteStorage(onProgress, isCancelled)
}
@@ -163,7 +163,9 @@ describe('Magic note SQLite and filesystem storage', () => {
expect(sql.prepare("SELECT json_extract(content_json, '$.storage') AS storage FROM magic_note_entries").get()!.storage).toBe('file')
expect(sql.prepare('PRAGMA freelist_count').get()!.freelist_count).toBeGreaterThan(0)
const stages: string[] = []
upgradeAssistantStorage(path, (progress) => stages.push(progress.stage))
upgradeAssistantStorage(path, (progress) => stages.push(progress.stage), () => false, {
pendingUpgradeConfirmed: true
})
expect(stages).toContain('compacting')
expect(sql.prepare('PRAGMA freelist_count').get()!.freelist_count).toBe(0)
})