diff --git a/FEATURES.md b/FEATURES.md index 840d9468..dc50a9c4 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -22,7 +22,7 @@ ### Agent Runtime 与模型连接 - [x] **直连模型 Runtime**:支持问答、知识总结、受控工具执行和图像生成。 -- [x] **OpenCode 与 Continue**:使用隔离子进程、环境变量白名单、统一配置、取消、总执行时限、有界流式输出和活动记录;共享进程回收逻辑保留 Windows 完整进程树终止,并对采用独立进程组的 POSIX 子进程执行组回收。交互提问只由前台对话回答,定时任务、远程通道和委派等后台执行遇到提问时会立即失败并提示改为前台运行,避免无限等待。 +- [x] **OpenCode 与 Continue**:使用隔离子进程、环境变量白名单、统一配置、取消、总执行时限、有界流式输出和活动记录;共享进程回收逻辑保留 Windows 完整进程树终止,并对采用独立进程组的 POSIX 子进程执行组回收。聊天状态检查使用一次性探测 Runtime 并在返回后立即回收,不进入按模型与项目隔离的执行 Runtime 缓存;执行 Runtime 仍按项目复用,因此不同项目可并行。交互提问只由前台对话回答,定时任务、远程通道和委派等后台执行遇到提问时会立即失败并提示改为前台运行,避免无限等待。 - [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.9。 - [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 在解包时完成一次完整 payload 校验。未完成操作只记录暂存 cleanup 所需的 operation ID;下次更新尽力清理旧暂存后重新 prepare,不保存远端 metadata 副本,也不让 cleanup 失败阻塞新更新或回滚健康环境。已有项目在实际使用 Workspace/Runtime 时按需解析 Host current identity 并执行固定 `attach-or-bootstrap`,注册后的 health、capabilities 和 prompt 启动不扫描完整 payload。详见 [设计说明](./docs/architecture/remote-host-environment-provisioning-design.md)。 - [ ] **SSH Host 环境准备真实 Host 验收**:源码已可使用当前 format v1 包;仍需完成 GitHub、北京镜像、Linux x64/arm64、取消和离线 GoodBuddy 传输的真实 Host 验收。当前状态不表示已发布或已完成真实 Host 测试。 diff --git a/src/main/agent/selected-runtime-manager.test.ts b/src/main/agent/selected-runtime-manager.test.ts index 32a8224a..62a0f1aa 100644 --- a/src/main/agent/selected-runtime-manager.test.ts +++ b/src/main/agent/selected-runtime-manager.test.ts @@ -265,6 +265,29 @@ describe('SelectedRuntimeManager', () => { await manager.dispose() }) + it('disposes a status-probe runtime without caching it', async () => { + const probed = runtime() + const cached = runtime() + const create = vi + .fn() + .mockResolvedValueOnce(probed.value) + .mockResolvedValueOnce(cached.value) + const manager = new SelectedRuntimeManager(create) + const selection = { provider: 'opencode' as const } + + await expect(manager.getStatus(selection)).resolves.toMatchObject({ + available: true + }) + expect(probed.value.getStatus).toHaveBeenCalledOnce() + expect(probed.testConnection).not.toHaveBeenCalled() + expect(probed.dispose).toHaveBeenCalledOnce() + + await manager.getRuntime(selection) + expect(create).toHaveBeenCalledTimes(2) + await manager.dispose() + expect(cached.dispose).toHaveBeenCalledOnce() + }) + it('disposes a native-inventory runtime without caching it', async () => { const inspected = runtime() const cached = runtime() diff --git a/src/main/agent/selected-runtime-manager.ts b/src/main/agent/selected-runtime-manager.ts index 4b61ee9c..dcef8762 100644 --- a/src/main/agent/selected-runtime-manager.ts +++ b/src/main/agent/selected-runtime-manager.ts @@ -154,7 +154,16 @@ export class SelectedRuntimeManager implements SelectedRuntimeResolver { async getStatus( selection: AgentRuntimeSelection ): Promise { - return (await this.getRuntime(selection)).getStatus() + if (this.disposed) { + throw new Error('Agent Runtime 正在关闭') + } + const operation = this.runStatusProbe(selection) + this.tests.add(operation) + try { + return await operation + } finally { + this.tests.delete(operation) + } } async testStatus( @@ -276,6 +285,20 @@ export class SelectedRuntimeManager implements SelectedRuntimeResolver { } } + private async runStatusProbe( + selection: AgentRuntimeSelection + ): Promise { + const runtime = await this.createRuntime(selection) + try { + if (this.disposed) { + throw new Error('Agent Runtime 正在关闭') + } + return await runtime.getStatus() + } finally { + await runtime.dispose() + } + } + private async runNativeSnapshot( selection: AgentRuntimeSelection, executionSpace?: ExecutionSpaceDescriptor diff --git a/src/main/ipc.test.ts b/src/main/ipc.test.ts index 831079b0..61a52845 100644 --- a/src/main/ipc.test.ts +++ b/src/main/ipc.test.ts @@ -6277,6 +6277,23 @@ describe('registerIpcHandlers agent terminal state', () => { expect(selectedRuntimes.getStatus).toHaveBeenCalledWith( secondSelection ) + harness.getResolvedSettings.mockResolvedValueOnce({ + provider: 'opencode', + defaultModelProfileId: firstProfileId, + opencodeBaseUrl: '', + opencodeEmbedded: true, + opencodeModelSource: { kind: 'platform' }, + continueModelSource: { kind: 'platform' } + }) + await expect( + harness.statusHandler?.(event) + ).resolves.toEqual( + expect.objectContaining({ label: 'model-two' }) + ) + expect(selectedRuntimes.getStatus).toHaveBeenLastCalledWith({ + provider: 'opencode' + }) + expect(fallbackRuntime.getStatus).not.toHaveBeenCalled() await Promise.all([ harness.handler?.(event, { diff --git a/src/main/ipc.ts b/src/main/ipc.ts index 7951f8f3..9e27d203 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -2837,12 +2837,18 @@ export function registerIpcHandlers( return tracked }, false) - registerHandler(ipcChannels.agentStatus, (event, input: unknown) => { + registerHandler(ipcChannels.agentStatus, async (event, input: unknown) => { assertTrustedSender(event, window) const selection = agentRuntimeSelectionSchema.optional().parse(input) - return selection && selectedRuntimes - ? selectedRuntimes.getStatus(selection) - : runtime.getStatus() + if (!selectedRuntimes) { + return runtime.getStatus() + } + const effectiveSelection = + selection ?? + getDefaultRuntimeSelection( + await settingsStore.getResolvedSettings() + ) + return selectedRuntimes.getStatus(effectiveSelection) }) registerHandler(ipcChannels.browserStop, async (event, input: unknown) => {