diff --git a/BUILD.md b/BUILD.md index f88e9b31..ce3b01e4 100644 --- a/BUILD.md +++ b/BUILD.md @@ -161,9 +161,11 @@ JSON。签名目录、包描述符、manifest、签名和 payload 的原始字 有界重定向链和固定 SSH prepare channel。GoodBuddy 桌面控制面通过该通道发送有界 one-shot installer;Host 校验目录绑定的归档大小和 SHA-256 后,由包内固定 Node 解码 并运行 installer。 -- “GoodBuddy 传输”在本地缺少精确候选时,于同一次用户操作下载、校验 SHA-256 与签名、 - 缓存并取得 lease,再以有界流式 SFTP 上传一个 compound archive 和归档内已验证的 - bootstrap Node;约 294 MiB 的整包不会一次读入 Main `Buffer`,Host 仍再次校验。 +- 显式“GoodBuddy 传输”在在线目录不可用,或本地已验证包版本不低于在线候选时优先使用 + 本地包,包括版本更高的离线导入;线上存在更新且可用时仍取得线上新包。需要下载时, + 同一次用户操作会校验 SHA-256 与签名、缓存并取得 lease,再以有界流式 SFTP 上传一个 + compound archive 和归档内已验证的 bootstrap Node;约 294 MiB 的整包不会一次读入 + Main `Buffer`,Host 仍再次校验。 - “自动”只在 operation/prepare 前执行 Host capability probe;直连明确可用才选择 Host 下载,否则选择 GoodBuddy 传输。显式选择保持有效,prepare、commit 或 adoption 失败后不跨 acquisition 自动 fallback。 diff --git a/FEATURES.md b/FEATURES.md index b1d92427..ab67d551 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -23,7 +23,7 @@ - [x] **直连模型 Runtime**:支持问答、知识总结、受控工具执行和图像生成。 - [x] **OpenCode 与 Continue**:使用隔离子进程、环境变量白名单、统一配置、取消、总执行时限、有界流式输出和活动记录;共享进程回收逻辑保留 Windows 完整进程树终止,并对采用独立进程组的 POSIX 子进程执行组回收。交互提问只由前台对话回答,定时任务、远程通道和委派等后台执行遇到提问时会立即失败并提示改为前台运行,避免无限等待。 -- [x] **托管 SSH OpenCode 闭环(技术预览)**:该能力由“设置 > 平台功能”中的独立“远程项目(技术预览)”页签控制并默认关闭;关闭时不影响任何本地项目、普通桌面功能或桌面发布。启用后可管理固定 Host Key 的 SSH Host、浏览有界远端目录并创建 Ask/Execute 项目;Ask 在 Runtime 边界通过 bubblewrap 保持 Workspace 只读,Execute 使用所选 SSH 账号的完整权限。Main 通过签名 Agent、私有 Unix socket、ACP v3、持久恢复 cursor 和 Main-only 模型桥维持长任务、重连与会话恢复,模型凭据不进入远端。远端组件不嵌入桌面包,项目切换只更新本地选择且不会连接或下载;设置页只读取小型签名目录并按 Linux x64/arm64 显示本地版本、在线最新兼容版本和“有更新/已是最新”,用户点击后才下载签名 `.gbagent`,也可导入/导出离线包。每个复合包包含 Agent、固定 Node 与由桌面源码统一维护适配的 OpenCode Runtime;在线来源跟随“关于与更新”的 GitHub/北京 OSS 选择,签名累计目录绑定最低桌面版本、Agent 协议、架构、大小、SHA-256 和固定 URL。公钥 registry 接受等价 JSON 空白和换行,但仍严格校验 schema、Ed25519 key、环境与撤销;目录、包、manifest、payload 签名和流式 SHA-256 不变。缺少某架构包只使该架构托管 SSH 不可用。项目只保存 Host、远端路径、Runtime 选择和模式;首次实际使用 Workspace/Runtime 时读取 Host current identity,同一进程内其他项目复用已确认 identity 和 Agent 连接。托管 SSH 会话只显示支持的 OpenCode 选择。同一 Host 可并行运行多个项目和对话;活动请求沿精确 resume/replay 恢复,controller 换代遗留的空闲 binding 只在 takeover 已证明且没有活动请求时换新 channel epoch 并恢复原 ACP session。Windows 到 Linux x64 的安装、provider-free 模型桥、断线重放、Session 续接和零残留清理已完成真实 Host 验证;签名 Agent 0.11.2-e2e.12 与 OpenCode Runtime 1.18.9 已完成一次只提交一遍、只调用一个 Ask 原生 read、无标题轮次且精确返回证据内容的有界真实模型验收,当前源码 lock 已更新为待发布 Agent 0.11.6。 +- [x] **托管 SSH OpenCode 闭环(技术预览)**:该能力由“设置 > 平台功能”中的独立“远程项目(技术预览)”页签控制并默认关闭;关闭时不影响任何本地项目、普通桌面功能或桌面发布。启用后可管理固定 Host Key 的 SSH Host、浏览有界远端目录并创建 Ask/Execute 项目;Ask 在 Runtime 边界通过 bubblewrap 保持 Workspace 只读,Execute 使用所选 SSH 账号的完整权限。Main 通过签名 Agent、私有 Unix socket、ACP v3、持久恢复 cursor 和 Main-only 模型桥维持长任务、重连与会话恢复,模型凭据不进入远端。远端组件不嵌入桌面包,项目切换只更新本地选择且不会连接或下载;设置页只读取小型签名目录并按 Linux x64/arm64 显示本地版本、在线最新兼容版本和“有更新/已是最新”,用户点击后才下载签名 `.gbagent`,也可导入/导出离线包。每个复合包包含 Agent、固定 Node 与由桌面源码统一维护适配的 OpenCode Runtime;在线来源跟随“关于与更新”的 GitHub/北京 OSS 选择,签名累计目录绑定最低桌面版本、Agent 协议、架构、大小、SHA-256 和固定 URL。公钥 registry 接受等价 JSON 空白和换行,但仍严格校验 schema、Ed25519 key、环境与撤销;目录、包、manifest、payload 签名和流式 SHA-256 不变。缺少某架构包只使该架构托管 SSH 不可用。项目只保存 Host、远端路径、Runtime 选择和模式;首次实际使用 Workspace/Runtime 时读取 Host current identity,同一进程内其他项目复用已确认 identity 和 Agent 连接。托管 SSH 会话只显示支持的 OpenCode 选择。同一 Host 可并行运行多个项目和对话;活动请求沿精确 resume/replay 恢复,controller 换代遗留的空闲 binding 只在 takeover 已证明且没有活动请求时换新 channel epoch 并恢复原 ACP session。Windows 到 Linux x64 的安装、provider-free 模型桥、断线重放、Session 续接和零残留清理已完成真实 Host 验证;签名 Agent 0.11.2-e2e.12 与 OpenCode Runtime 1.18.9 已完成一次只提交一遍、只调用一个 Ask 原生 read、无标题轮次且精确返回证据内容的有界真实模型验收,当前源码 lock 已更新为待发布 Agent 0.11.7。 - [x] **SSH Host 手动环境准备源码链路**:Host Key、认证和系统探针成功后先保存 Host,并只读探测共享 Agent/Runtime;保存 Host、打开项目都不自动安装。Host 卡片只有一个按版本事实显示“安装远程环境”“更新远程环境”或“重新安装”的主按钮,次级 SegmentedControl 选择默认且不持久化的“自动”、Host 下载或 GoodBuddy 传输;“版本匹配”badge 不等同环境健康。自动模式只在 operation/prepare 前探测并择一,显式选择保持有效,任何 prepare、commit 或 adoption 失败都不跨 acquisition 自动 fallback。两种方式把同一签名 compound `.gbagent` 交付到固定 staging 后,共用 control-plane prepare、持久化 commit、Agent activate/health、Runtime activate、finalize 与显式 cleanup。GoodBuddy 路径可在同一次操作下载并验证缺失候选、缓存并取得 lease,再有界流式 SFTP 上传一个归档和其中已验证的 bootstrap Node,不把约 294 MiB 整包读入 Main `Buffer`;Host 再次校验。通道丢失通过只读 `commit-status` 恢复而不重放 commit;组件 adoption 失败恢复 Agent/Runtime 五个 metadata 文件的原字节或原缺失状态,已发布 side-by-side payload 不成为 current。已有项目在实际使用 Workspace/Runtime 时按需解析 Host current identity 并执行固定 `attach-or-bootstrap`,不安装或扫描 payload。详见 [设计说明](./docs/architecture/remote-host-environment-provisioning-design.md)。 - [ ] **SSH Host 环境准备真实 Host 验收**:源码已可使用当前 format v1 包;仍需完成 GitHub、北京镜像、Linux x64/arm64、取消和离线 GoodBuddy 传输的真实 Host 验收。当前状态不表示已发布或已完成真实 Host 测试。 - [x] **DeepSeek Harness(预览)**:使用 GoodBuddy 固定 Host 和 OpenAI 兼容模型连接;Ask 只允许调用 Host 中真实注册的 `read`、`skill` 以及 Main 管理的 Web Search/Fetch 代理,拒绝插件同名冒充,Execute 放行全部已启用内置及插件工具,并以当前用户权限运行。图像输入跟随所选模型连接的能力声明,文本模型在 Host 或模型调用前拒绝图片,图片模型通过有界内联内容和临时 Attachment Store 接收 JPEG/PNG。 diff --git a/agent-runtime-lock.json b/agent-runtime-lock.json index 514200e6..9ed961a7 100644 --- a/agent-runtime-lock.json +++ b/agent-runtime-lock.json @@ -1,6 +1,6 @@ { "formatVersion": 1, - "agentVersion": "0.11.6", + "agentVersion": "0.11.7", "protocol": { "major": 2, "minor": 0 diff --git a/docs/architecture/remote-host-and-goodbuddy-agent-design.md b/docs/architecture/remote-host-and-goodbuddy-agent-design.md index 73539008..38a6f531 100644 --- a/docs/architecture/remote-host-and-goodbuddy-agent-design.md +++ b/docs/architecture/remote-host-and-goodbuddy-agent-design.md @@ -13,7 +13,7 @@ Windows 到 Linux x64 的既有安装、 Main-only 模型桥、断线恢复、输出重放、同一 OpenCode Session 续接与终态清理已经在真实 Host 上完成 provider-free 验证。上一轮 Linux x64 验收使用 Agent `0.11.2-e2e.12` 与 OpenCode Runtime `1.18.9` 完成了一次有界真实模型工具调用;加入每 helper 随机 loopback -路径 capability 后,当前源码 lock 已更新为待发布 Agent `0.11.6`。失败的 `agent-v0.11.3` +路径 capability 后,当前源码 lock 已更新为待发布 Agent `0.11.7`。失败的 `agent-v0.11.3` 保持不可变且未发布。远程 OpenCode 功能仍处于发布前验证阶段;公开签名 key registry 已供应,当前发布门槛是通过独立 Agent workflow 正式发布并公开验证 Linux x64/arm64 复合包和签名累计目录。 @@ -103,10 +103,11 @@ Detached GoodBuddy Agent 可用才选 Host 下载,否则选 GoodBuddy 传输;显式选择不被改写。prepare、commit 或 adoption 失败后都不跨 acquisition 自动 fallback。 - Host 下载由桌面控制面通过固定 SSH prepare channel 和结构化 stdin 发送候选信息, - Host 取得 compound `.gbagent` 并核对大小与 SHA-256。GoodBuddy 传输在本地缺少精确 - 候选时,于同一次操作下载、验 SHA-256 与签名、缓存并取得 lease,再以有界流式 SFTP - 上传一个 compound archive 和归档中已验证的 bootstrap Node;约 294 MiB 完整包不会 - 一次读入 Main `Buffer`,Host 仍再次校验完整包。 + Host 取得 compound `.gbagent` 并核对大小与 SHA-256。显式 GoodBuddy 传输在在线目录 + 不可用,或本地已验证包版本不低于在线候选时优先租用本地包,包括更高版本离线导入; + 线上存在更新且可用时仍取得线上新包。需要下载时,同一次操作会验 SHA-256 与签名、 + 缓存并取得 lease,再以有界流式 SFTP 上传一个 compound archive 和归档中已验证的 + bootstrap Node;约 294 MiB 完整包不会一次读入 Main `Buffer`,Host 仍再次校验完整包。 - 两种 acquisition 交付到固定 operation staging 后,完全共用 control-plane `prepare → commit → Agent activate/health → Runtime activate → finalize → cleanup`。 commit 终态持久化;通道丢失只用 `commit-status` 只读恢复,不重放 commit。prepare @@ -244,7 +245,7 @@ Execute 不经过 Ask 的 bubblewrap profile: - 2026-08 的本地 fixture 完整验证 Linux x64 Agent `0.11.2-e2e.12`、Node `24.19.0` 和 Agent protocol `2.0`;当时没有 arm64 fixture,因此该记录不能作为当前独立发布 - 的双架构验收。当前源码 lock 是待发布版本 `0.11.6`,需要由新的复合包发布流程另行验证。 + 的双架构验收。当前源码 lock 是待发布版本 `0.11.7`,需要由新的复合包发布流程另行验证。 - 正常 Host 更新路径把 Linux x64 Host 的 Agent 更新为 `0.11.2-e2e.12`,并确认 OpenCode Runtime 已安装版本与所需版本均为 `1.18.9`。 - 一条新的 Ask 用户操作只提交一次。OpenCode 先在 build 模型轮次请求一个原生 diff --git a/docs/architecture/remote-host-environment-provisioning-design.md b/docs/architecture/remote-host-environment-provisioning-design.md index 8bc30558..cac865b5 100644 --- a/docs/architecture/remote-host-environment-provisioning-design.md +++ b/docs/architecture/remote-host-environment-provisioning-design.md @@ -140,10 +140,11 @@ daemon 参与。两种 acquisition 的差别只在于如何把同一个签名 co - **Host 下载**:控制面通过固定 SSH prepare channel 发送签名目录绑定的 URL、大小和 SHA-256;Host 有界下载并校验完整归档。 -- **GoodBuddy 传输**:若本地缺少精确候选,同一次用户操作先下载、验证 SHA-256 和签名、 - 写入缓存并取得 lease;随后以有界流式 SFTP 上传一个 compound archive,以及从该归档中 - 已验证的 bootstrap Node。约 294 MiB 的完整包不会一次读入 Main `Buffer`,Host 收到后 - 仍再次校验完整包。 +- **GoodBuddy 传输**:显式选择时,在线目录不可用或本地已验证包版本不低于在线候选, + 则优先使用本地包,包括版本更高的离线导入;线上存在更新且可用时仍取得线上新包。 + 需要下载时,同一次用户操作会验证 SHA-256 和签名、写入缓存并取得 lease。随后以有界 + 流式 SFTP 上传一个 compound archive,以及从该归档中已验证的 bootstrap Node。约 + 294 MiB 的完整包不会一次读入 Main `Buffer`,Host 收到后仍再次校验完整包。 交付后两种 acquisition 完全共用一个 control-plane 事务: diff --git a/docs/prd/task-and-job/scheduled-task-prd.md b/docs/prd/task-and-job/scheduled-task-prd.md index 5a5e5a95..64e7530b 100644 --- a/docs/prd/task-and-job/scheduled-task-prd.md +++ b/docs/prd/task-and-job/scheduled-task-prd.md @@ -363,7 +363,8 @@ Task Center 显示 Scheduled Task 的范围、关联 Conversation、状态、模 - [x] 手动运行不改变下次计划时间。 - [x] Scheduled Task 与普通消息共用 Conversation 级队列,不并发写入同一时间线。 - [x] 当前回复期间可以继续发送普通消息,并在 Composer 上沿查看、删除或提升待发送项。 -- [x] 应用重启恢复尚未执行的队列项和有界附件上下文。 +- [x] 应用重启恢复尚未执行的队列项和有界附件上下文;已写入 Conversation 的用户输入 + 通过队列项身份完成恢复对账,不会再次进入待发送队列。 - [x] 文本结果只写入 Conversation,独立交付物才进入成果。 - [ ] Task Center 和桌面通知可以打开正确 Conversation 并定位 Task。 - [ ] 应用重启不自动重放结果未知的副作用。 diff --git a/package-lock.json b/package-lock.json index 50c0ca1c..e37b04c3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "goodbuddy", - "version": "0.11.6", + "version": "0.11.7", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "goodbuddy", - "version": "0.11.6", + "version": "0.11.7", "license": "0BSD", "dependencies": { "@agentclientprotocol/sdk": "0.25.1", diff --git a/package.json b/package.json index 625df5ef..0ea3890b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "goodbuddy", - "version": "0.11.6", + "version": "0.11.7", "private": true, "description": "Secure desktop AI workspace with controlled Agent Runtimes", "desktopName": "GoodBuddy", diff --git a/src/main/assistant/assistant-database.test.ts b/src/main/assistant/assistant-database.test.ts index a1739c33..4ed608b6 100644 --- a/src/main/assistant/assistant-database.test.ts +++ b/src/main/assistant/assistant-database.test.ts @@ -2690,6 +2690,92 @@ describe('AssistantDatabase', () => { recovered.close() }) + it('does not replay a dispatched user queue item whose message was already persisted', async () => { + const directory = await mkdtemp( + join(tmpdir(), 'goodbuddy-conversation-queue-recovery-') + ) + temporaryDirectories.push(directory) + const databasePath = join(directory, 'assistant.sqlite') + const initial = new AssistantDatabase(databasePath) + initial.initialize('C:\\Workspace') + const project = initial.listProjects()[0]! + const conversationId = + '00000000-0000-4000-8000-000000000911' + const userMessageId = + '00000000-0000-4000-8000-000000000912' + const assistantMessageId = + '00000000-0000-4000-8000-000000000913' + initial.replaceConversations([ + { + id: conversationId, + projectId: project.id, + title: '崩溃恢复对话', + updatedAt: 1, + messages: [] + } + ]) + const queued = initial.enqueueConversationUserInput({ + conversationId, + label: '只发送一次', + payloadJson: JSON.stringify({ prompt: '只发送一次' }) + }) + expect( + initial.claimConversationQueueItem(conversationId, queued.id) + ).toMatchObject({ + source: 'user', + item: { id: queued.id } + }) + initial.saveLocalConversations([ + { + header: { + id: conversationId, + projectId: project.id, + title: '崩溃恢复对话', + updatedAt: 2 + }, + messages: [ + { + id: userMessageId, + queueItemId: queued.id, + role: 'user', + content: '只发送一次', + createdAt: 2, + state: 'complete' + }, + { + id: assistantMessageId, + role: 'assistant', + content: '', + createdAt: 3, + state: 'streaming' + } + ] + } + ]) + initial.close() + + const recovered = new AssistantDatabase(databasePath) + recovered.initialize('C:\\Workspace') + expect( + recovered.listConversationQueueItems(conversationId) + ).toEqual([]) + expect(recovered.getConversation(conversationId).messages).toEqual([ + expect.objectContaining({ + id: userMessageId, + queueItemId: queued.id, + role: 'user', + content: '只发送一次' + }), + expect.objectContaining({ + id: assistantMessageId, + role: 'assistant', + state: 'error', + status: '上次运行意外中断,可以重新发送问题' + }) + ]) + recovered.close() + }) + it('materializes due and manual schedule runs in the conversation queue', async () => { const database = await createDatabase() const schedule = database.createSchedule({ @@ -3408,6 +3494,8 @@ describe('AssistantDatabase', () => { messages: [ { id: sourceMessageIds[0]!, + queueItemId: + '00000000-0000-4000-8000-000000000551', role: 'user', content: '比较两个发布方案', createdAt: 1_775_000_000_000, @@ -3516,6 +3604,7 @@ describe('AssistantDatabase', () => { expect(branch.messages[0]?.attachments).toEqual( sourceBefore.messages[0]?.attachments ) + expect(branch.messages[0]?.queueItemId).toBeUndefined() expect(branch.messages[1]?.task).toBeUndefined() expect(branch.messages[1]?.artifactIds).toBeUndefined() expect(database.getConversation(sourceConversationId)).toEqual( @@ -3549,6 +3638,16 @@ describe('AssistantDatabase', () => { expect(branchMetadata).not.toHaveProperty('artifactIds') expect(branchMetadata).not.toHaveProperty('subagents') expect(branchMetadata).not.toHaveProperty('task') + const branchUserMetadataRow = durable + .prepare( + `SELECT metadata_json + FROM messages + WHERE conversation_id = ? AND sequence = 0` + ) + .get(branch.id) as { metadata_json: string } + expect( + JSON.parse(branchUserMetadataRow.metadata_json) + ).not.toHaveProperty('queueItemId') durable.close() database.saveLocalConversations([ diff --git a/src/main/assistant/assistant-database.ts b/src/main/assistant/assistant-database.ts index 3943d67c..e1d179d1 100644 --- a/src/main/assistant/assistant-database.ts +++ b/src/main/assistant/assistant-database.ts @@ -208,6 +208,7 @@ type MagicTodoRow = { type MessageMetadata = { createdAt?: number + queueItemId?: string status?: string reasoning?: ConversationSnapshot['messages'][number]['reasoning'] blocks?: ConversationSnapshot['messages'][number]['blocks'] @@ -998,6 +999,7 @@ function toConversationSnapshot( const interrupted = message.state === 'streaming' return { id: message.id, + queueItemId: metadata.queueItemId, role: message.role, content: message.content, reasoning: metadata.reasoning, @@ -1031,6 +1033,7 @@ function serializeConversationMessageMetadata( ): string { return JSON.stringify({ createdAt: message.createdAt, + queueItemId: message.queueItemId, status: message.status, reasoning: message.reasoning, blocks: message.blocks, @@ -1340,6 +1343,23 @@ export class AssistantDatabase { WHERE status = 'dispatching'` ) .run() + database + .prepare( + `DELETE FROM conversation_queue_items + WHERE source = 'user' + AND EXISTS ( + SELECT 1 + FROM messages + WHERE messages.conversation_id = + conversation_queue_items.conversation_id + AND messages.role = 'user' + AND json_extract( + messages.metadata_json, + '$.queueItemId' + ) = conversation_queue_items.id + )` + ) + .run() database.exec(` INSERT OR IGNORE INTO conversation_queue_items (id, conversation_id, source, label, payload_json, @@ -2496,7 +2516,8 @@ export class AssistantDatabase { metadata_json, '$.artifactIds', '$.subagents', - '$.task' + '$.task', + '$.queueItemId' ), created_at FROM messages diff --git a/src/main/remote-agent/agent-package-manager.test.ts b/src/main/remote-agent/agent-package-manager.test.ts index 63156adf..0880b887 100644 --- a/src/main/remote-agent/agent-package-manager.test.ts +++ b/src/main/remote-agent/agent-package-manager.test.ts @@ -73,9 +73,12 @@ vi.mock('./agent-package-verifier', async (importOriginal) => { const { readFile: readFixture } = await import('node:fs/promises') const archive = await readFixture(options.archivePath) - const version = archive.toString('utf8') === '2.1.0' - ? '2.1.0' - : '2.0.0' + const archiveVersion = archive.toString('utf8') + const version = + archiveVersion === '1.9.0' || + archiveVersion === '2.1.0' + ? archiveVersion + : '2.0.0' await mkdir(join(options.destinationDirectory, 'agent'), { recursive: true }) @@ -492,6 +495,107 @@ describe('AgentPackageManager remote install candidates', () => { lease.release() }) + it('prefers a verified local package over an older available online catalog for GoodBuddy transfer', async () => { + const fixture = await createFixture('mirror') + const transport = vi.fn(async (input) => { + const url = input.toString() + if (url.endsWith('latest.json')) { + return response(fixture.pointer) + } + if (url.endsWith('agent-catalog.json')) { + return response(fixture.catalogBytes) + } + if (url.endsWith('agent-catalog.sig')) { + return response(fixture.signature) + } + throw new Error(`Archive must not be requested: ${url}`) + }) + const importDirectory = await mkdtemp( + join(tmpdir(), 'goodbuddy-agent-local-newer-') + ) + temporaryDirectories.push(importDirectory) + const localArchive = join( + importDirectory, + 'goodbuddy-agent-2.1.0-linux-x64.gbagent' + ) + await writeFile(localArchive, '2.1.0') + + const manager = fixture.manager(transport) + await manager.importArchive(localArchive) + await manager.getSnapshot() + const lease = await manager.acquireGoodBuddyInstallArchive('x64') + + expect(lease.candidate).toMatchObject({ + architecture: 'x64', + version: '2.1.0', + urls: [] + }) + expect( + transport.mock.calls.some(([input]) => + input.toString().endsWith(fixture.archive) + ) + ).toBe(false) + await expect(readFile(lease.path)).resolves.toEqual( + Buffer.from('2.1.0') + ) + lease.release() + }) + + it('downloads a newer online package instead of transferring a stale local package', async () => { + const onlineArchive = Buffer.from('online-2.0.0') + const fixture = await createFixture( + 'mirror', + undefined, + onlineArchive + ) + const entry = fixture.catalog.entries[0]! + let archiveRequests = 0 + const transport = vi.fn(async (input) => { + const url = input.toString() + if (url.endsWith('latest.json')) { + return response(fixture.pointer) + } + if (url.endsWith('agent-catalog.json')) { + return response(fixture.catalogBytes) + } + if (url.endsWith('agent-catalog.sig')) { + return response(fixture.signature) + } + if (url.endsWith(entry.archive)) { + archiveRequests += 1 + return response(onlineArchive, { + headers: { + 'content-length': String(onlineArchive.byteLength) + } + }) + } + throw new Error(`Unexpected URL ${url}`) + }) + const importDirectory = await mkdtemp( + join(tmpdir(), 'goodbuddy-agent-local-stale-') + ) + temporaryDirectories.push(importDirectory) + const localArchive = join( + importDirectory, + 'goodbuddy-agent-1.9.0-linux-x64.gbagent' + ) + await writeFile(localArchive, '1.9.0') + + const manager = fixture.manager(transport) + await manager.importArchive(localArchive) + await manager.getSnapshot() + const lease = await manager.acquireGoodBuddyInstallArchive('x64') + + expect(lease.candidate).toMatchObject({ + architecture: 'x64', + version: '2.0.0', + sha256: entry.sha256 + }) + expect(archiveRequests).toBe(1) + await expect(readFile(lease.path)).resolves.toEqual(onlineArchive) + lease.release() + }) + it('propagates cancellation into an active archive download', async () => { const archiveBytes = Buffer.from('package') const fixture = await createFixture( diff --git a/src/main/remote-agent/agent-package-manager.ts b/src/main/remote-agent/agent-package-manager.ts index e1aa74d2..7b4fd891 100644 --- a/src/main/remote-agent/agent-package-manager.ts +++ b/src/main/remote-agent/agent-package-manager.ts @@ -540,26 +540,43 @@ export class AgentPackageManager { options.signal?.throwIfAborted() const source = await this.#getUpdateSource() options.signal?.throwIfAborted() - let entry: AgentPackageCatalogEntry | undefined + const record = await this.#loadInstalled(architecture) + const leaseRecord = async ( + installed: InstalledRecord + ): Promise => { + const status = await lstat(installed.archivePath) + return this.#archiveLease( + installed, + candidateFromVerifiedRecord( + installed, + source, + status.size + ) + ) + } + if ( + record && + this.#catalogState?.state !== 'available' + ) { + return leaseRecord(record) + } + let entry: AgentPackageCatalogEntry if (this.#catalogState?.state === 'available') { entry = selectLatestCompatibleEntry( this.#catalogState.catalog, architecture, this.#desktopVersion ) - } else { - const record = await this.#loadInstalled(architecture) - if (record) { - const status = await lstat(record.archivePath) - return this.#archiveLease( - record, - candidateFromVerifiedRecord( - record, - source, - status.size - ) - ) + if ( + record && + compareSemanticVersions( + record.verified.descriptor.version, + entry.version + ) >= 0 + ) { + return leaseRecord(record) } + } else { if (this.#catalogState?.state === 'unavailable') { throw new Error(this.#catalogState.error) } @@ -577,14 +594,14 @@ export class AgentPackageManager { entry, source ) - const record = await this.#ensureInstalledCandidate( + const installed = await this.#ensureInstalledCandidate( architecture, entry, source, options.onProgress, options.signal ) - return this.#archiveLease(record, candidate) + return this.#archiveLease(installed, candidate) } ) } diff --git a/src/preload/preload-sandbox.test.ts b/src/preload/preload-sandbox.test.ts index 05170476..72c1ab15 100644 --- a/src/preload/preload-sandbox.test.ts +++ b/src/preload/preload-sandbox.test.ts @@ -62,7 +62,7 @@ describe('sandboxed preload', () => { ) const embeddings = source.match( - /embeddings: \{(?[\s\S]*?)\n {2}\},\n {2}documentParsing:/u + /embeddings: \{(?[\s\S]*?)\r?\n {2}\},\r?\n {2}documentParsing:/u )?.groups?.body ?? '' expect(embeddings).toContain('getSnapshot:') expect(embeddings).toContain('getModelProgress:') @@ -82,8 +82,8 @@ describe('sandboxed preload', () => { expect(source).toContain( 'test: (purpose: DocumentParsingTestPurpose)' ) - expect(source).toContain( - 'ipcChannels.documentParsingTest,\n { purpose }' + expect(source).toMatch( + /ipcChannels\.documentParsingTest,\r?\n {8}\{ purpose \}/u ) }) @@ -125,7 +125,7 @@ describe('sandboxed preload', () => { ) const feedback = source.match( - /feedback: \{(?[\s\S]*?)\n {2}\},\n {2}shortcuts:/u + /feedback: \{(?[\s\S]*?)\r?\n {2}\},\r?\n {2}shortcuts:/u )?.groups?.body ?? '' expect(feedback).toContain('submit: (input: FeedbackSubmitInput)') expect(feedback).toContain('ipcChannels.feedbackSubmit') diff --git a/src/renderer/src/App.test.tsx b/src/renderer/src/App.test.tsx index f9e076b9..e550aa4f 100644 --- a/src/renderer/src/App.test.tsx +++ b/src/renderer/src/App.test.tsx @@ -4802,6 +4802,45 @@ describe('App', () => { ).not.toBeInTheDocument() }) + it('persists the claimed queue identity with a dispatched user message', async () => { + render() + + fireEvent.change(screen.getByLabelText('向 GoodBuddy 提问'), { + target: { value: '只发送一次' } + }) + fireEvent.click(await screen.findByLabelText('发送')) + await waitFor(() => expect(run).toHaveBeenCalledOnce()) + const request = run.mock.calls[0]?.[0] + if (!request?.queueItemId) { + throw new Error('Missing queued request') + } + + act(() => { + agentListener?.({ + requestId: request.requestId, + type: 'error', + status: 'failed', + message: 'Remote Runtime failed' + }) + }) + + await waitFor(() => + expect( + vi.mocked(api.conversations.saveLocal).mock.calls.some( + ([batch]) => + batch.some((conversation) => + conversation.messages.some( + (message) => + message.role === 'user' && + message.content === '只发送一次' && + message.queueItemId === request.queueItemId + ) + ) + ) + ).toBe(true) + ) + }) + it('keeps sent documents and images in conversation history', async () => { const documentAttachment = { id: '00000000-0000-4000-8000-000000000301', diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 4aa349cf..da88537f 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -120,6 +120,7 @@ import type { WorkspaceChanges } from '../../shared/assistant-contracts' import { + assistantIdSchema, conversationAttachmentSchema, conversationBranchSchema, conversationContextCompressionMarkerSchema, @@ -1182,6 +1183,8 @@ function isConversation(value: unknown): value is Conversation { const entry = message as Record return ( typeof entry.id === 'string' && + (entry.queueItemId === undefined || + assistantIdSchema.safeParse(entry.queueItemId).success) && (entry.role === 'user' || entry.role === 'assistant') && typeof entry.content === 'string' && entry.content.length <= 1_000_000 && @@ -1262,6 +1265,7 @@ function toConversationSnapshots( function toConversationMessage(message: Message): ConversationMessage { return { id: message.id, + queueItemId: message.queueItemId, role: message.role, content: message.content, reasoning: message.reasoning, @@ -6519,6 +6523,7 @@ function App(): React.JSX.Element { setConversationActivity(conversationId, true) const userMessage: Message = { id: crypto.randomUUID(), + queueItemId: queuedDispatch.item.id, role: 'user', content: prompt, createdAt: Date.now(), diff --git a/src/renderer/src/ChatTimeline.tsx b/src/renderer/src/ChatTimeline.tsx index c359b874..6d991010 100644 --- a/src/renderer/src/ChatTimeline.tsx +++ b/src/renderer/src/ChatTimeline.tsx @@ -41,6 +41,7 @@ export type KnowledgeRetrievalStatus = Omit< export type Message = { id: string + queueItemId?: ConversationMessage['queueItemId'] role: 'user' | 'assistant' content: string reasoning?: string diff --git a/src/shared/assistant-contracts.ts b/src/shared/assistant-contracts.ts index 5872051b..13fdf908 100644 --- a/src/shared/assistant-contracts.ts +++ b/src/shared/assistant-contracts.ts @@ -226,6 +226,7 @@ export type ConversationContextCompressionMarker = z.infer< export const conversationMessageSchema = z .object({ id: assistantIdSchema, + queueItemId: assistantIdSchema.optional(), role: z.enum(['user', 'assistant']), content: z.string().max(1_000_000), reasoning: z.string().optional(),