fix: reclaim idle remote runtimes and retire replaced agents
Remote tasks could leave idle Runtime processes behind, and replaced Agent installations lacked a drain-and-exit lifecycle. Keep the current Agent resident, let displaced Agents finish existing work and reconnect for its results, then retire them without restarting obsolete installations. Reclaim Runtime processes when their last active task finishes, preserve pending questions and shared active tasks, and restore conversation history when the next turn starts. Remove the Remote Projects technical preview label from settings, errors, tests, and current documentation; the feature remains disabled by default. Validation recorded during implementation: 213 lifecycle tests and 28 focused UI/IPC tests passed, as did SettingsPanel and lint. Linux x64 real-Host checks used 8 provider requests. Broader tests and final typecheck encountered unrelated concurrent changes; details and coverage limits are documented. Release note: 远程任务结束后自动释放空闲运行进程,下一轮对话恢复历史;更新后的旧 Agent 等待已有任务完成再退出,当前 Agent 保持常驻。远程项目移除“技术预览”标识,仍默认关闭。
This commit is contained in:
@@ -172,7 +172,7 @@ npm run dist:linux:arm64
|
||||
可安装的远端 Agent 或远端 Runtime payload。没有使用托管 SSH 时,无需下载任何额外
|
||||
组件,缺少远端包也不能阻塞普通桌面打包或发布。
|
||||
|
||||
“设置 > 平台功能 > 远程项目(技术预览)”列出用户数据目录中已经下载或导入的
|
||||
“设置 > 平台功能 > 远程项目”列出用户数据目录中已经下载或导入的
|
||||
Linux x64、Linux arm64 和 macOS arm64 复合 Agent 包。首次打开和手动刷新会从“关于与更新”所选来源读取
|
||||
小型签名目录,显示本地版本、在线最新兼容版本以及 `有更新 / 已是最新 / 未下载 /
|
||||
校验失败`;此检查不会下载 `.gbagent`。只有用户点击“下载”或“更新到”后才传输包,
|
||||
|
||||
+6
-2
@@ -266,8 +266,8 @@ records are listed separately and do not introduce another feature status.
|
||||
first; invalid paths fail at actual launch. On managed Linux ARM Hosts,
|
||||
Runtime activation reuses the registry and manifest verified during setup
|
||||
instead of rehashing or rechecking the complete OpenCode binary.
|
||||
- [x] **Managed SSH OpenCode and Continue (technical preview)**: Controlled by the
|
||||
separate Remote Projects (Technical Preview) tab under Settings / Platform
|
||||
- [x] **Managed SSH OpenCode and Continue**: Controlled by the
|
||||
separate Remote Projects tab under Settings / Platform
|
||||
Features and disabled by default. Disabling it does not affect local
|
||||
projects, ordinary desktop capabilities, or desktop releases. When enabled,
|
||||
users can manage SSH Hosts with pinned Host Keys, browse bounded remote
|
||||
@@ -279,6 +279,10 @@ records are listed separately and do not introduce another feature status.
|
||||
provider/tool rounds, Runtime processes, a stable model ledger, and a bounded
|
||||
semantic transcript over a private Unix socket and ACP v5. Work continues on
|
||||
the Host after Desktop exit, network loss, or local-process termination.
|
||||
The current Agent may remain resident; a superseded Agent drains active work
|
||||
before exiting. Idle Runtime processes are reclaimed after tasks finish,
|
||||
while shared processes retain other active tasks, including pending questions.
|
||||
Subsequent turns recreate the Runtime and restore conversation history.
|
||||
Reconnection attaches only to the original controller, binding, and
|
||||
operation, then atomically merges provenance into the original conversation
|
||||
without resending the Prompt. Model profiles and API keys enter Agent memory
|
||||
|
||||
+1
-1
@@ -50,7 +50,7 @@
|
||||
- [x] **高效直连模型工作区工具**:直连模型使用随包 ripgrep 紧凑地发现文件和搜索内容,按行分页读取大型 UTF-8 文件,并在 Execute 中应用多文件补丁。Ask 可以搜索和读取,但不能应用补丁或运行命令;用户无需另行安装 ripgrep。可处理的读取或搜索错误允许模型调整参数后继续,部分搜索结果保留并明确提示覆盖不完整。
|
||||
- [x] **长上下文与工具输出分页**:历史消息、长回复和文档解析文本不再采用旧的固定截断限制。直连模型可分页续读已保存的命令和 Subagent 输出;所选模型的上下文窗口及传输边界仍然有效。
|
||||
- [x] **OpenCode 与 Continue**:使用隔离子进程、环境变量白名单、统一配置、取消、启动与控制请求时限、有界流式输出和活动记录;共享进程回收逻辑保留 Windows 完整进程树终止,并对采用独立进程组的 POSIX 子进程执行组回收。聊天状态检查使用一次性探测 Runtime 并在返回后立即回收,不进入按模型与项目隔离的执行 Runtime 缓存;本机 GoodBuddy 管理的 OpenCode 在该被动检查中只确认所选路径和模型凭据,不启动用后即毁的 Server,设置中的显式连接测试和原生能力清单仍执行完整启动及健康检查,原生能力清单随后与首次实际请求复用同一个执行 Runtime,不再重复冷启动;执行 Runtime 仍按项目复用,但 OpenCode 配置依赖和按内容摘要生成的 Skill 快照在所有本机项目间全局复用,会话数据、工具输出及请求级 MCP 状态继续隔离。本机 GoodBuddy 管理的 OpenCode 只对同一会话内的请求保持顺序,不同会话即使属于同一项目也可并行,不同项目同样可并行;请求级动态 MCP 通过默认通配禁用和当前请求精确放行保持隔离。托管的本机和远端 OpenCode 跳过界面未使用的自动 Git 快照,避免同步差异计算拖住会话,文件工具、子代理与工作区 Git 差异保留;Continue 持续排空不用展示的宿主 stdout,防止控制台输出填满管道。这些调整随新启动的 Runtime 生效,不改写外部 OpenCode Server 配置。OpenCode 与 Continue 不再按固定工具调用数或活动数中止单次运行,已观察到的工具与 Subagent 活动全部保留在本地会话,不再在原生任务继续执行时丢弃后续详情。交互提问只由前台对话回答,定时任务、远程通道和委派等后台执行遇到提问时会立即失败并提示改为前台运行,避免无限等待。本机 OpenCode 启动时直接使用已选择的绝对路径,不预先检查文件或运行 `--version`,路径失效时由实际启动返回错误;托管 Linux ARM Host 激活 Runtime 时复用准备阶段已验证的 registry 和 manifest,不重新哈希或检查完整 OpenCode 二进制。
|
||||
- [x] **托管 SSH OpenCode 与 Continue(技术预览)**:该能力由“设置 > 平台功能”中的独立“远程项目(技术预览)”页签控制并默认关闭;关闭时不影响本地项目、普通桌面功能或桌面发布。支持 Linux x64、Linux arm64 和 macOS arm64 Host,不支持 Intel Mac;通过 SSH 按需启动 detached Agent,不要求开机服务。Ask 在 Runtime 工具边界保持只读,Execute 使用所选 SSH 账号的完整权限。Agent 持有 Prompt、模型和工具轮次、Runtime 进程及语义 transcript,Desktop 断开后重新 Attach 原操作而不重发 Prompt;模型凭据仅在当前操作内存中使用,不写入 Renderer、SSH 参数、环境或磁盘。设置页按平台和架构显示包状态,用户显式下载、更新或导入/导出包含 Agent、固定 Node、OpenCode 和 Continue 的签名 `.gbagent`;三个目标共用一个签名 catalog,继续校验签名和 SHA-256。Continue 需要更新后的联合包与配套 Desktop。首次发布混合 catalog 前需先升级 Desktop,旧版 Linux-only 目录读取器无法解析包含 Darwin 的目录。历史 macOS OpenCode 原生安装、生命周期、Attach、真实 Ask/Execute 和独立进程组工具取消已在真实 Host 验证,不代表 Continue 的同等实机覆盖或新版本已经发布。当前源码候选为 Agent `0.13.2`、Desktop `0.13.8`;详细运行边界和 Linux 历史回归证据见[远程主机技术设计](docs/features/remote-host/technical-design.md),正式发布状态以独立发布渠道为准。
|
||||
- [x] **托管 SSH OpenCode 与 Continue**:该能力由“设置 > 平台功能”中的独立“远程项目”页签控制并默认关闭;关闭时不影响本地项目、普通桌面功能或桌面发布。支持 Linux x64、Linux arm64 和 macOS arm64 Host,不支持 Intel Mac;通过 SSH 按需启动 detached Agent,不要求开机服务。Ask 在 Runtime 工具边界保持只读,Execute 使用所选 SSH 账号的完整权限。Agent 持有 Prompt、模型和工具轮次、Runtime 进程及语义 transcript,Desktop 断开后重新 Attach 原操作而不重发 Prompt;模型凭据仅在当前操作内存中使用,不写入 Renderer、SSH 参数、环境或磁盘。当前 Agent 可以常驻,更新替换的旧 Agent 排空活动任务后退出。任务完成后回收空闲 Runtime,共享进程仍有任务或待答问题时保留;下一轮重建 Runtime 并恢复对话历史。设置页按平台和架构显示包状态,用户显式下载、更新或导入/导出包含 Agent、固定 Node、OpenCode 和 Continue 的签名 `.gbagent`;三个目标共用一个签名 catalog,继续校验签名和 SHA-256。Continue 需要更新后的联合包与配套 Desktop。首次发布混合 catalog 前需先升级 Desktop,旧版 Linux-only 目录读取器无法解析包含 Darwin 的目录。历史 macOS OpenCode 原生安装、生命周期、Attach、真实 Ask/Execute 和独立进程组工具取消已在真实 Host 验证,不代表 Continue 的同等实机覆盖或新版本已经发布。当前源码候选为 Agent `0.13.2`、Desktop `0.13.8`;详细运行边界和 Linux 历史回归证据见[远程主机技术设计](docs/features/remote-host/technical-design.md),正式发布状态以独立发布渠道为准。
|
||||
- [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/features/remote-host/environment-provisioning-technical-design.md)。
|
||||
- **SSH Host 环境准备验证记录**:当前源码已在隔离 Linux x64 环境通过包安装、Ask/Execute、原生子代理工作区外写入、重连及 stop/bootstrap。现有记录未覆盖 Host 卡片的完整 GitHub、北京镜像、Linux x64/arm64、取消和离线 GoodBuddy 传输矩阵,候选 CI/原生打包与系统休眠唤醒也尚未验证;开发记录不代表版本已经发布。
|
||||
- [x] **远程工作区与长任务更新**:Agent `0.11.23` 新增远程文件/Git 管理和可选模型限额,随包 Runtime 不再施加固定十分钟 Prompt 时限;无限请求时长仍保留独立连接超时。请先升级 Desktop 至 `0.12.11`,再下载并更新 Host 上的 Agent。
|
||||
|
||||
+2
-2
@@ -446,7 +446,7 @@
|
||||
尚未完成而禁用该入口。选择入口只展开流程,验证成功前不提交实际值;不得自动采用第一个
|
||||
候选。取消保持原值,验证错误在对应操作附近显示。
|
||||
- 项目选择器属于富信息单选菜单。收起时保持当前项目名称紧凑可见;展开后每项至少显示项目名称,并按数据可用性补充目录、远程来源或其他能帮助辨认项目的信息。托管 SSH 项目在“远程项目”内按已保存 SSH Host 做一层轻量分组,Host 标题旁显示真实 Agent 连接状态;项目副标题只显示远端路径,不重复“托管 SSH”类型。
|
||||
- 项目菜单区分“本地项目 / 远程项目 / 远程通道”,不把消息通道命名为远程项目。“远程项目(技术预览)”关闭时完整移除远程项目分组,而不是显示不可操作的残留选项;本地项目和远程通道继续可用。Agent 连接状态使用“未连接 / 连接中 / 就绪 / 异常”等文字标签,读取状态不得发起连接,且不能只依赖图标或颜色;新增项目来源时不得退回仅列名称的原生 `select`。
|
||||
- 项目菜单区分“本地项目 / 远程项目 / 远程通道”,不把消息通道命名为远程项目。“远程项目”关闭时完整移除远程项目分组,而不是显示不可操作的残留选项;本地项目和远程通道继续可用。Agent 连接状态使用“未连接 / 连接中 / 就绪 / 异常”等文字标签,读取状态不得发起连接,且不能只依赖图标或颜色;新增项目来源时不得退回仅列名称的原生 `select`。
|
||||
|
||||
### 6.12 应用通知与就地反馈
|
||||
|
||||
@@ -796,7 +796,7 @@ GoodBuddy 是可调整窗口大小的桌面应用。响应式设计优先保证
|
||||
- “平台功能 / 通用设置”同时提供默认开启的“在会话中渲染 HTML”持久 Switch。说明必须
|
||||
明确该能力只生成禁用脚本、联网、表单提交和窗口操作的静态预览;保存成功后当前会话立即
|
||||
更新,保存失败时保留最后确认状态。
|
||||
- “平台功能”使用共享 `PageTabs` 区分“通用设置”和“远程项目(技术预览)”,默认进入通用设置。“远程项目”页将持久 Switch 与用户数据目录中的 Linux x64、Linux arm64、macOS arm64 Agent 包清单分成独立卡片;每张卡片与下载进度按平台和架构区分,不能把两个 arm64 目标合并。清单延迟到首次打开该页签时由 Main 完整校验,并读取所选更新源的小型签名目录,显示本地版本、在线最新兼容版本以及“有更新”“已是最新”“未下载”“校验失败”。打开页签或刷新不得自动下载 `.gbagent`;只有用户点击带目标版本的下载/更新按钮后才传输包。签名目录不可用时继续显示并允许导出已验证本地包,提供就地重试错误;清单不混入 Host 已安装状态、缓存路径或敏感签名细节。下载和导入期间保留上下文进度,成功进入全局通知视口。全局模型下载源使用 `fieldset`、持久 `legend` 与整行可点击的原生 Radio 卡片;选中状态同时依靠 Radio、边框和背景表达,读取失败时不得用默认值伪装为已保存选择。
|
||||
- “平台功能”使用共享 `PageTabs` 区分“通用设置”和“远程项目”,默认进入通用设置。“远程项目”页将持久 Switch 与用户数据目录中的 Linux x64、Linux arm64、macOS arm64 Agent 包清单分成独立卡片;每张卡片与下载进度按平台和架构区分,不能把两个 arm64 目标合并。清单延迟到首次打开该页签时由 Main 完整校验,并读取所选更新源的小型签名目录,显示本地版本、在线最新兼容版本以及“有更新”“已是最新”“未下载”“校验失败”。打开页签或刷新不得自动下载 `.gbagent`;只有用户点击带目标版本的下载/更新按钮后才传输包。签名目录不可用时继续显示并允许导出已验证本地包,提供就地重试错误;清单不混入 Host 已安装状态、缓存路径或敏感签名细节。下载和导入期间保留上下文进度,成功进入全局通知视口。全局模型下载源使用 `fieldset`、持久 `legend` 与整行可点击的原生 Radio 卡片;选中状态同时依靠 Radio、边框和背景表达,读取失败时不得用默认值伪装为已保存选择。
|
||||
- “关于与更新”的更新源位于“启动时检查新版本”开关下方,常规宽度下将标签、同行帮助和原生单选下拉框放在同一行,用途说明收进 `InlineHelp`,并复用设置表单的统一控件样式;关闭启动检查后仍可选择更新源,手动检查继续使用当前选择。选项显示“GitHub(默认)”和中性的“镜像节点”。该选择同时控制手动检查、启动时检查、下载页和 Agent 包在线下载,不显示底层服务商名称。
|
||||
- “关于与更新”在版本信息之后提供应用内反馈卡片。反馈对话框使用原生字段、明确隐私说明和一张可选截图;另提供默认关闭的原生 Checkbox,让用户主动附加最近桌面诊断摘要。Checkbox 附近必须持续说明摘要只含时间、组件、阶段、稳定错误码、错误类型和固定短消息,不含对话、Prompt、凭据、文件内容、路径、Provider 原始响应或远端 Agent 日志。勾选时为诊断摘要预留描述字符预算,超长草稿必须保留并显示字段错误,不得静默截断。失败时保留草稿、勾选状态和请求编号并提供就地重试,成功时在对话框内保留可复制编号而不重复发送全局通知;对话框遵循统一 Modal 的背景隔离、初始焦点、Tab 循环、Escape、焦点恢复和窄窗口布局。
|
||||
- Agent Runtime 分类页头的“保存设置”同时保存 Runtime 基础配置与 Runtime 原生定制,不在原生定制卡片内提供第二个保存入口。原生定制存在未保存更改时持续显示状态和撤销入口;切换设置分类或 Runtime 不丢弃草稿,关闭设置中心前必须先保存或撤销。
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
|
||||
1. “平台功能”保留现有一级设置分类,在分类内部增加共享 `PageTabs`:
|
||||
- **通用设置**
|
||||
- **远程项目(技术预览)**
|
||||
- **远程项目**
|
||||
2. “通用设置”首个功能为“模型下载源”。
|
||||
3. 首期提供两个互斥选项:
|
||||
- `modelscope`,用户文案为 **ModelScope**,默认值。
|
||||
@@ -60,7 +60,7 @@
|
||||
“平台功能”现已使用两个二级页签:
|
||||
|
||||
- “通用设置”承载全局模型下载源。
|
||||
- “远程项目(技术预览)”承载远程项目开关与 Agent 包管理。
|
||||
- “远程项目”承载远程项目开关与 Agent 包管理。
|
||||
|
||||
页签复用共享 `PageTabs`,默认打开“通用设置”,不会把当前页签写入应用设置。
|
||||
|
||||
@@ -166,7 +166,7 @@
|
||||
└─ 平台功能
|
||||
├─ 通用设置
|
||||
│ └─ 模型下载源
|
||||
└─ 远程项目(技术预览)
|
||||
└─ 远程项目
|
||||
├─ 远程项目开关
|
||||
└─ Agent 包管理
|
||||
```
|
||||
@@ -185,7 +185,7 @@
|
||||
onChange={setActiveSection}
|
||||
tabs={[
|
||||
{ id: 'general', label: '通用设置' },
|
||||
{ id: 'remote-projects', label: '远程项目(技术预览)' }
|
||||
{ id: 'remote-projects', label: '远程项目' }
|
||||
]}
|
||||
value={activeSection}
|
||||
variant="segmented"
|
||||
@@ -213,7 +213,7 @@
|
||||
|
||||
未来新增页签时:
|
||||
|
||||
- 必须与“通用设置”和“远程项目(技术预览)”处于同级。
|
||||
- 必须与“通用设置”和“远程项目”处于同级。
|
||||
- 页签表示完整、独立的功能设置域。
|
||||
- 通用设置只放跨功能的全局行为,不成为任意设置的杂物区。
|
||||
- 页签超过可读数量时重新组织信息架构,不允许多行堆叠。
|
||||
@@ -229,7 +229,7 @@
|
||||
平台功能
|
||||
管理通用平台行为与可选工作区能力
|
||||
|
||||
[ 通用设置 ] [ 远程项目(技术预览) ]
|
||||
[ 通用设置 ] [ 远程项目 ]
|
||||
|
||||
┌ 模型下载源 ────────────────────────────────────────────┐
|
||||
│ 选择 GoodBuddy 托管本地模型后续下载使用的平台。 │
|
||||
@@ -965,7 +965,7 @@ Ollama 的模型下载由用户和 Ollama 管理。GoodBuddy 的模型下载源
|
||||
|
||||
### 19.1 产品
|
||||
|
||||
- “平台功能”显示“通用设置”和“远程项目(技术预览)”两个可访问页签。
|
||||
- “平台功能”显示“通用设置”和“远程项目”两个可访问页签。
|
||||
- 默认打开“通用设置”。
|
||||
- “通用设置”提供 ModelScope 和 Hugging Face,默认 ModelScope。
|
||||
- 来源选择说明影响范围,并明确已安装模型、ZIP、Ollama 和应用更新不受影响。
|
||||
|
||||
@@ -654,7 +654,7 @@ type WorkbarPanelInstance = {
|
||||
|
||||
### 9.1 设置入口
|
||||
|
||||
设置中心“平台功能”提供独立“远程项目(技术预览)”页签及开关,默认关闭。关闭时不显示
|
||||
设置中心“平台功能”提供独立“远程项目”页签及开关,默认关闭。关闭时不显示
|
||||
“主机与远程执行”分类、托管 SSH 项目创建选项和已保存远程项目,Main IPC 同时拒绝新的
|
||||
SSH/远程项目操作;已有 Host、项目和凭据保持不变。若当前项目已经是远程项目,则切回
|
||||
项目列表中的第一个普通
|
||||
|
||||
@@ -231,8 +231,9 @@ Agent 当前 Runtime installation + 兼容配置
|
||||
竞争读取同一 stdout。消息和 permission 按 sessionId 分派给 binding 的当前 operation。
|
||||
- `AgentAcpConnection` 统一连接级握手、stdio 解码与退出监听,Prompt
|
||||
生命周期仍留在 binding。同一 Session 串行,其他 Session 不等待它完成。
|
||||
- `closeAcpChannel`、取消和 release 只释放所属 binding/Session;最后一个 binding
|
||||
离开后空闲 60 秒关闭进程。关闭 Session 时等待原生 `closeSession` 和原始 Prompt
|
||||
- `closeAcpChannel`、取消和 release 只释放所属 binding/Session;以活动 operation
|
||||
判断共享进程是否空闲,最后一个任务结束即回收,不因历史 binding 保留进程。
|
||||
待答问题仍占用活动 operation。关闭 Session 时等待原生 `closeSession` 和原始 Prompt
|
||||
结算,不等待排在 backend 控制队列后面的完成回调。只有原生控制面不响应时才有界
|
||||
终止共享进程,并向其余请求报告中断,不声称这类故障可以逐 Session 隔离。
|
||||
- terminal 与物理进程状态分开:取消/失败的 Session 也可返回
|
||||
|
||||
@@ -16,3 +16,6 @@
|
||||
跨会话共享 OpenCode 进程、ACP 连接与模型桥的实现设计,见
|
||||
[Runtime 进程复用技术设计](../assistant-workbar/runtime-process-reuse-technical-design.md#6-ssh-opencode-与-agent)。
|
||||
当前实现尚未发布;验收状态见[工作栏进度](../assistant-workbar/progress.md#2026-09-14-runtime-进程复用实施中)。
|
||||
|
||||
更新退役、任务完成后 Runtime 回收和下一轮历史恢复的当前源码验证,见
|
||||
[2026-09-23 生命周期验证](./technical-design.md#生命周期验证2026-09-23)。
|
||||
|
||||
@@ -164,6 +164,10 @@ daemon 参与。两种 acquisition 的差别只在于如何把同一个签名 co
|
||||
5. Main 确认 adoption 后显式执行 cleanup。cleanup 失败不回滚已经健康的 Agent/Runtime,
|
||||
也不阻塞下一次更新。
|
||||
|
||||
新安装成为 current 后,旧 Agent 排空已有任务再退出,空闲旧 Agent 直接退出;当前 Agent
|
||||
可以常驻。Runtime 回收、待答问题与旧任务重连的规则见
|
||||
[远程主机技术设计](./technical-design.md)。
|
||||
|
||||
这不是两个并行安装管理器,也不存在把 Agent 与 Runtime payload 逐文件走 SFTP 安装的
|
||||
第二条路径。两种 acquisition 共享相同的 prepare、commit、adoption、finalize 与 cleanup。
|
||||
|
||||
|
||||
@@ -49,8 +49,8 @@ Agent `0.11.23` 提供,显式请求期限与取消仍有效。当前问答源
|
||||
|
||||
## 产品语义
|
||||
|
||||
远程 Host 与项目能力是默认关闭的技术预览。用户在“设置 > 平台功能”的独立
|
||||
“远程项目(技术预览)”页签启用开关后,Renderer 才显示 SSH Host 管理、托管 SSH 项目创建和已保存
|
||||
远程 Host 与项目能力默认关闭。用户在“设置 > 平台功能”的独立
|
||||
“远程项目”页签启用开关后,Renderer 才显示 SSH Host 管理、托管 SSH 项目创建和已保存
|
||||
远程项目;Main 在 IPC 边界执行同一开关校验,避免隐藏界面被绕过。关闭开关不删除 Host、
|
||||
项目或凭据;当前远程项目切回第一个普通本地项目。应用启动仍固定
|
||||
选择普通本地项目,不会因为保存了远程项目而自动连接 Host。
|
||||
@@ -185,7 +185,15 @@ Detached GoodBuddy Agent
|
||||
解析新的 current registry 并建立当前连接,无需刷新项目记录;当前环境无效时要求用户
|
||||
显式修复,但不在项目切换中下载或安装。失败或取消保留 Host 配置、凭据、项目、
|
||||
Workspace 和旧组件。
|
||||
- 显式 stop、升级、身份冲突或进程退出时清理 GoodBuddy 自己的 socket、状态和子进程;不得删除或覆盖无关 Host 文件。
|
||||
- 当前 Agent 可以常驻。新安装提升为 current 后,旧 Agent 每 250 ms 检查 registry 并进入
|
||||
draining,不再接受新 Prompt;无活动任务时退出,有任务时等待完成。已连接 Desktop 的
|
||||
终态 transcript ACK 也计入排空条件;断线任务完成后不等待离线 Desktop。待答问题仍是
|
||||
活动任务。旧安装退出前可以重新 Attach 原任务,但不得重新 bootstrap 已退役安装。
|
||||
- Prompt 完成后释放空闲 Runtime;共享进程仍有其他活动任务时保留,最后一个任务完成后
|
||||
关闭并核对进程树为空。保留语义 transcript 与会话标识;下一轮重建 Runtime,OpenCode
|
||||
加载原生 session,Continue 通过已有冷启动路径从持久对话历史初始化。
|
||||
- 显式 stop/retire 可校验并停止已被 registry 替换的旧安装。退出只清理 GoodBuddy 自己的
|
||||
socket、状态和子进程,不删除会话历史,不删除或覆盖无关 Host 文件。
|
||||
|
||||
### Host 级环境生命周期
|
||||
|
||||
@@ -872,6 +880,35 @@ goodbuddy-agent diagnostics --installation-id <installationId>
|
||||
Renderer 帮助提示/焦点测试套件。更新后的五个套件连同更新设置大小展示回归共 64 项
|
||||
定向复跑全部通过;未把这次复跑记为又一轮全量通过。
|
||||
|
||||
## 生命周期验证(2026-09-23)
|
||||
|
||||
通过已有 `lifecycle-host` harness 重建当前源码的 Agent、daemon 和桌面驱动,使用已保存
|
||||
凭据与固定 Host Key 经 LAN 连接共享 Linux x64 Host。运行文件均位于独立
|
||||
`/root/tmp/gb-lifecycle-*` 目录,Runtime 使用 Host 已安装的 OpenCode。两次 Ask/Execute
|
||||
运行合计 8 次真实 Provider 请求,另一次空闲更新验证没有模型请求。
|
||||
|
||||
- Ask 完成后测试所属子进程为 0;下一轮重建 Runtime、加载同一原生 session,并准确回忆
|
||||
上轮标记。第二轮完成后子进程再次为 0。
|
||||
- Execute 启动等待 8 秒后写文件的工具任务,Desktop 断开,再把新安装提升为 current。
|
||||
旧 daemon 进入 draining,活动进程仍在;重连并恢复原 operation 后正常收尾,旧 daemon
|
||||
退出,测试所属子进程为 0。
|
||||
- 空闲更新中,旧 daemon 在提升后 3 秒断言窗口内退出,新 current daemon 仍为 ready。
|
||||
各次运行清理时所属子进程均为 0,未修改共享安装 registry。
|
||||
|
||||
8 个定向套件共 213 项通过,覆盖 CLI 退役安装 stop/retire、daemon 常驻与排空、共享及
|
||||
独占 Runtime 回收、待答问题仍属活动任务、断线恢复、原生历史加载和 Desktop 冷启动。
|
||||
远程 UI/IPC 定向复跑 28 项通过,SettingsPanel 完整套件通过;IPC/App 扩展运行共
|
||||
517 项通过、4 项失败,失败位于并行修改中的
|
||||
心跳、设置读取次数和监督者导航断言。全量 `npm test` 运行到 600 秒工具时限仍无最终汇总,
|
||||
期间报告数据库迁移、监督者和导航测试失败,不能记为全量通过。`npm run lint` 通过。
|
||||
类型检查早期通过,最终复跑被并行新增的 `tests/incremental-review-live.electron.test.ts:23`
|
||||
中环境变量对象类型错误阻塞。
|
||||
|
||||
本次实机覆盖当前源码的 daemon、registry 提升、托管 ACP、真实 Runtime 与模型请求。
|
||||
未重复完整签名包安装和 UI 更新点击,也未在实机重跑 Continue 或问题答复;这些场景
|
||||
不能以本次 OpenCode 结果替代。历史发布说明中的技术预览记录保留,当前远程项目设置与
|
||||
说明文档已去除该标签;Linux 桌面控制等其他预览功能不受影响。
|
||||
|
||||
## 发布前完整回归(不能替代开发验证)
|
||||
|
||||
以下清单只用于复核开发期间已经在真实 Host 上通过的链路,不得作为 Agent 改动的首次
|
||||
|
||||
@@ -252,6 +252,36 @@ describe('Agent CLI fixed command contract', () => {
|
||||
expect(bootstrap).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it.each(['stop', 'retire'] as const)('can %s a verified displaced installation without promoting it', async action => {
|
||||
const root = privateTemporaryDirectory()
|
||||
const installationId = verifyAgentInstallationId('install-old')
|
||||
const paths = {
|
||||
executablePath: resolve(root, 'agent', 'goodbuddy-agent'),
|
||||
stateDirectory: resolve(root, 'state', installationId),
|
||||
socketPath: resolve(root, 'run', 'old.sock')
|
||||
}
|
||||
const installationRegistry = new InstallationRegistry({ storagePath: resolve(root, 'registry.json') })
|
||||
const current = verifiedInstallation(paths, verifyAgentInstallationId('install-current'))
|
||||
installationRegistry.stageCandidate(current)
|
||||
installationRegistry.promoteCandidate('install-current')
|
||||
const verified = verifiedInstallation(paths, installationId)
|
||||
const verifyInstallation = vi.fn(async () => verified)
|
||||
const terminate = vi.fn(async () => ({ state: 'absent' as const }))
|
||||
const io = cliIo()
|
||||
const result = await runAgentCli([action, '--installation-id', installationId], {
|
||||
installationPaths: () => paths,
|
||||
installationRegistry,
|
||||
releaseKeyRegistry: { formatVersion: 1, keys: [], revocations: [] },
|
||||
verifyInstallation,
|
||||
createLifecycle: (() => ({ [action]: terminate })) as unknown as AgentCliDependencies['createLifecycle'],
|
||||
io
|
||||
})
|
||||
expect(result, io.error.read()?.toString()).toBe(0)
|
||||
expect(verifyInstallation).toHaveBeenCalledOnce()
|
||||
expect(terminate).toHaveBeenCalledOnce()
|
||||
expect(installationRegistry.snapshot().current?.installationId).toBe('install-current')
|
||||
})
|
||||
|
||||
it('exports only bounded diagnostics for a fixed installation ID as JSONL', async () => {
|
||||
const root = privateTemporaryDirectory()
|
||||
const stateDirectory = resolve(root, 'state', 'install-1')
|
||||
|
||||
+28
-5
@@ -375,6 +375,15 @@ async function runDaemon(
|
||||
protocol: verified.manifest.protocol,
|
||||
stateDirectory: paths.stateDirectory,
|
||||
socketPath: paths.socketPath,
|
||||
isSuperseded: () => {
|
||||
const registry = dependencies.installationRegistry ?? new InstallationRegistry({
|
||||
storagePath: resolve(dirname(dirname(dirname(paths.executablePath))), 'registry.json')
|
||||
})
|
||||
const snapshot = registry.snapshot()
|
||||
return snapshot.current !== undefined &&
|
||||
snapshot.current.installationId !== installationId &&
|
||||
snapshot.candidate?.installationId !== installationId
|
||||
},
|
||||
peerIdentityProvider:
|
||||
dependencies.peerIdentityProvider ??
|
||||
await createLinuxPeerIdentityProvider(),
|
||||
@@ -413,7 +422,10 @@ async function runDaemon(
|
||||
'registered',
|
||||
verified
|
||||
).recordCurrentDaemonReady(daemon.status().daemonBootId)
|
||||
await (dependencies.waitForShutdown?.() ?? waitForShutdownSignal())
|
||||
await Promise.race([
|
||||
dependencies.waitForShutdown?.() ?? waitForShutdownSignal(),
|
||||
daemon.retired
|
||||
])
|
||||
} finally {
|
||||
await daemon.stop()
|
||||
}
|
||||
@@ -436,7 +448,8 @@ async function runAttach(
|
||||
const { verified } = await loadManagedInstallation(
|
||||
installationId,
|
||||
paths,
|
||||
dependencies
|
||||
dependencies,
|
||||
true
|
||||
)
|
||||
prepareManagedSocketDirectory(paths)
|
||||
const stateDirectory = paths.stateDirectory
|
||||
@@ -454,6 +467,8 @@ async function runAttach(
|
||||
connectionId = welcome.connectionId
|
||||
},
|
||||
ensureEndpoint: async () => {
|
||||
// A displaced installation may still serve detached work, but must not restart.
|
||||
await loadManagedInstallation(installationId, paths, dependencies)
|
||||
await createDetachedLifecycle(
|
||||
installationId,
|
||||
paths,
|
||||
@@ -686,7 +701,8 @@ async function runLifecycle(
|
||||
const { verified } = await loadManagedInstallation(
|
||||
installationId,
|
||||
paths,
|
||||
dependencies
|
||||
dependencies,
|
||||
true
|
||||
)
|
||||
const lifecycle = createDetachedLifecycle(
|
||||
installationId,
|
||||
@@ -702,7 +718,8 @@ async function runLifecycle(
|
||||
const { verified } = await loadManagedInstallation(
|
||||
installationId,
|
||||
paths,
|
||||
dependencies
|
||||
dependencies,
|
||||
true
|
||||
)
|
||||
const lifecycle = createDetachedLifecycle(
|
||||
installationId,
|
||||
@@ -820,7 +837,8 @@ async function verifyManagedInstallation(
|
||||
async function loadManagedInstallation(
|
||||
installationId: string,
|
||||
paths: ManagedInstallationPaths,
|
||||
dependencies: AgentCliDependencies
|
||||
dependencies: AgentCliDependencies,
|
||||
allowRetired = false
|
||||
): Promise<{
|
||||
verified: VerifiedInstalledAgentBundle
|
||||
registry: InstallationRegistry
|
||||
@@ -832,6 +850,11 @@ async function loadManagedInstallation(
|
||||
new InstallationRegistry({
|
||||
storagePath: resolve(agentRoot, 'registry.json')
|
||||
})
|
||||
const snapshot = registry.snapshot()
|
||||
if (allowRetired && snapshot.current?.installationId !== installationId &&
|
||||
snapshot.candidate?.installationId !== installationId) {
|
||||
return verifyManagedInstallation(installationId, paths, dependencies)
|
||||
}
|
||||
const registered = registry.assertRegisteredRole(
|
||||
installationId,
|
||||
['current', 'candidate']
|
||||
|
||||
@@ -48,6 +48,37 @@ afterEach(() => {
|
||||
})
|
||||
|
||||
describe('Agent daemon lifecycle', () => {
|
||||
it('keeps the current daemon resident and retires a superseded daemon only after detached work drains', async () => {
|
||||
vi.useFakeTimers()
|
||||
vi.spyOn(PrivateEndpoint.prototype, 'listen').mockResolvedValue()
|
||||
vi.spyOn(PrivateEndpoint.prototype, 'close').mockResolvedValue()
|
||||
let superseded = false
|
||||
let idle = false
|
||||
const drain = vi.fn(async () => idle)
|
||||
const daemon = daemonForTest({
|
||||
isSuperseded: () => superseded,
|
||||
runtimeProtocol: { runtimes: () => [], methods: {}, onAcpFrame: async () => {}, drain }
|
||||
})
|
||||
const retired = vi.fn()
|
||||
void daemon.retired.then(retired)
|
||||
try {
|
||||
await daemon.start()
|
||||
await vi.advanceTimersByTimeAsync(1_000)
|
||||
expect(drain).not.toHaveBeenCalled()
|
||||
expect(retired).not.toHaveBeenCalled()
|
||||
superseded = true
|
||||
await vi.advanceTimersByTimeAsync(250)
|
||||
expect(daemon.status().draining).toBe(true)
|
||||
expect(retired).not.toHaveBeenCalled()
|
||||
idle = true
|
||||
await vi.advanceTimersByTimeAsync(250)
|
||||
expect(retired).toHaveBeenCalledOnce()
|
||||
} finally {
|
||||
await daemon.stop()
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('returns the strict manifest-bound Linux daemon status', () => {
|
||||
const daemon = daemonForTest()
|
||||
const status = daemonStatusSchema.parse(daemon.status())
|
||||
|
||||
@@ -49,6 +49,7 @@ export type AgentDaemonOptions = {
|
||||
now?: () => number
|
||||
gitExecutable?: string
|
||||
workspaceRequestTimeoutMs?: number
|
||||
isSuperseded?: () => boolean
|
||||
runtimeProtocol?: {
|
||||
runtimes: () =>
|
||||
| DaemonCapabilities['runtimes']
|
||||
@@ -64,6 +65,7 @@ export type AgentDaemonOptions = {
|
||||
typeof AgentProtocolServer
|
||||
>[0]['authorizeBlobFrame']>
|
||||
dispose?: () => void | Promise<void>
|
||||
drain?: () => Promise<boolean>
|
||||
}
|
||||
runtimeFactory?: (context: {
|
||||
events: EventJournal
|
||||
@@ -118,6 +120,12 @@ export class AgentDaemon {
|
||||
#events?: EventJournal
|
||||
#workspaces?: WorkspaceRegistry
|
||||
#activeRuntimeProtocol?: AgentDaemonOptions['runtimeProtocol']
|
||||
readonly #isSuperseded?: () => boolean
|
||||
#draining = false
|
||||
#activeRequests = 0
|
||||
#retirementTimer?: NodeJS.Timeout
|
||||
#resolveRetirement!: () => void
|
||||
readonly retired = new Promise<void>(resolve => { this.#resolveRetirement = resolve })
|
||||
|
||||
constructor(options: AgentDaemonOptions) {
|
||||
this.#installationId = agentIdentifierSchema.parse(options.installationId)
|
||||
@@ -147,6 +155,7 @@ export class AgentDaemon {
|
||||
this.#peerIdentityProvider = options.peerIdentityProvider
|
||||
this.#gitExecutable = options.gitExecutable
|
||||
this.#workspaceRequestTimeoutMs = options.workspaceRequestTimeoutMs
|
||||
this.#isSuperseded = options.isSuperseded
|
||||
this.#runtimeProtocol = options.runtimeProtocol
|
||||
this.#runtimeFactory = options.runtimeFactory
|
||||
if (
|
||||
@@ -176,7 +185,7 @@ export class AgentDaemon {
|
||||
architecture: this.#architecture,
|
||||
supervisor: 'detached-on-demand',
|
||||
remoteUserIdentity: this.#remoteUserIdentity,
|
||||
draining: false
|
||||
draining: this.#draining
|
||||
})
|
||||
}
|
||||
|
||||
@@ -331,10 +340,17 @@ export class AgentDaemon {
|
||||
...(reason === undefined ? {} : { reason })
|
||||
})
|
||||
},
|
||||
methods: mergeProtocolMethods(
|
||||
methods: Object.fromEntries(Object.entries(mergeProtocolMethods(
|
||||
workspaceMethods,
|
||||
this.#activeRuntimeProtocol?.methods
|
||||
)
|
||||
)).map(([name, handler]) => [name, async (params, context) => {
|
||||
this.#activeRequests++
|
||||
try {
|
||||
return await handler(params, context)
|
||||
} finally {
|
||||
this.#activeRequests--
|
||||
}
|
||||
}]))
|
||||
})
|
||||
protocolBox.current = protocol
|
||||
this.#endpoint = new PrivateEndpoint({
|
||||
@@ -364,6 +380,7 @@ export class AgentDaemon {
|
||||
})
|
||||
await this.#endpoint.listen()
|
||||
this.#state = 'ready'
|
||||
if (this.#isSuperseded) this.#scheduleRetirementCheck()
|
||||
this.#diagnostics.tryRecord('daemon.ready', {
|
||||
daemonBootId: this.#bootId
|
||||
})
|
||||
@@ -378,6 +395,7 @@ export class AgentDaemon {
|
||||
}
|
||||
|
||||
async stop(): Promise<void> {
|
||||
if (this.#retirementTimer) clearTimeout(this.#retirementTimer)
|
||||
if (this.#state !== 'stopped') {
|
||||
this.#diagnostics.tryRecord('daemon.stopping', {
|
||||
daemonBootId: this.#bootId
|
||||
@@ -440,6 +458,26 @@ export class AgentDaemon {
|
||||
}
|
||||
}
|
||||
|
||||
#scheduleRetirementCheck(): void {
|
||||
this.#retirementTimer = setTimeout(() => {
|
||||
void (async () => {
|
||||
try {
|
||||
this.#draining ||= this.#isSuperseded?.() === true
|
||||
if (this.#draining &&
|
||||
(await this.#activeRuntimeProtocol?.drain?.() ?? true) &&
|
||||
this.#activeRequests === 0) {
|
||||
this.#resolveRetirement()
|
||||
return
|
||||
}
|
||||
} catch (error) {
|
||||
this.#reportError('Agent retirement check failed', error)
|
||||
}
|
||||
if (this.#state === 'ready') this.#scheduleRetirementCheck()
|
||||
})()
|
||||
}, 250)
|
||||
this.#retirementTimer.unref()
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
function mergeProtocolMethods(
|
||||
|
||||
@@ -15,6 +15,8 @@ import type { RemoteRuntimeBundleManifest } from '../shared/remote-runtime-launc
|
||||
import type { ControllerLease } from './controller-registry'
|
||||
import { AgentModelGatewayError } from './agent-model-gateway'
|
||||
import { EventJournal, EventJournalCapacityError } from './event-journal'
|
||||
import { SemanticPromptStore } from './semantic-prompt-store'
|
||||
import { AgentAcpConnection } from './agent-acp-connection'
|
||||
import type { ProtocolMethodContext } from './protocol-server'
|
||||
import type {
|
||||
ModelBridgeBrokerDispatch,
|
||||
@@ -183,6 +185,7 @@ class FakeProcess implements RuntimeAcpProcessOwner {
|
||||
maximumInputBytes: number
|
||||
}> = []
|
||||
completions = 0
|
||||
onWrite?: (payload: Uint8Array) => void
|
||||
reconciliation: RuntimeAcpProcessReconciliation
|
||||
#listener?: (
|
||||
output: RuntimeAcpProcessOutput
|
||||
@@ -222,6 +225,7 @@ class FakeProcess implements RuntimeAcpProcessOwner {
|
||||
|
||||
writeStdin(payload: Uint8Array): void {
|
||||
this.writes.push(payload.slice())
|
||||
this.onWrite?.(payload)
|
||||
}
|
||||
|
||||
stop(input: { reason: string }): void {
|
||||
@@ -288,6 +292,8 @@ function harness(input: {
|
||||
outputGate?: Promise<void>
|
||||
agentOwned?: boolean
|
||||
shareOwnedProcesses?: boolean
|
||||
semanticStore?: SemanticPromptStore
|
||||
configureProcess?: (process: FakeProcess) => void
|
||||
journal?: RuntimeAcpJournal
|
||||
modelGateway?: {
|
||||
dispatch: ReturnType<typeof vi.fn>
|
||||
@@ -299,6 +305,7 @@ function harness(input: {
|
||||
let now = input.now ?? 1_000
|
||||
const journal = new MemoryAcpJournal()
|
||||
const process = new FakeProcess()
|
||||
input.configureProcess?.(process)
|
||||
const outputFrames: string[] = []
|
||||
const launches: Array<{
|
||||
workMode: 'ask' | 'execute'
|
||||
@@ -349,9 +356,12 @@ function harness(input: {
|
||||
workMode: launch.workMode,
|
||||
scratch: launch.scratch
|
||||
})
|
||||
return input.uniqueProcesses && launches.length > 1
|
||||
? new FakeProcess(String(launches.length))
|
||||
: process
|
||||
if (input.uniqueProcesses && launches.length > 1) {
|
||||
const next = new FakeProcess(String(launches.length))
|
||||
input.configureProcess?.(next)
|
||||
return next
|
||||
}
|
||||
return process
|
||||
}),
|
||||
now: () => now,
|
||||
diagnostics,
|
||||
@@ -372,7 +382,7 @@ function harness(input: {
|
||||
},
|
||||
...(semanticPrompts
|
||||
? {
|
||||
semanticPrompts: semanticPrompts as never,
|
||||
semanticPrompts: (input.semanticStore ?? semanticPrompts) as never,
|
||||
modelGateway:
|
||||
(input.modelGateway ?? {
|
||||
dispatch: vi.fn(),
|
||||
@@ -526,6 +536,94 @@ describe('RuntimeAcpBackend', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it.each([false, true])('reclaims completed owned processes, preserves detached peers and loads native history (shared=%s)', async shared => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'goodbuddy-idle-runtime-'))
|
||||
const transcript = new SemanticPromptStore(join(root, 'prompts.sqlite'))
|
||||
const pending = new Map<string, () => Promise<void>>()
|
||||
const processes: FakeProcess[] = []
|
||||
const loaded: string[] = []
|
||||
const setRoute = vi.spyOn(AgentAcpConnection.prototype, 'setModelRoute').mockResolvedValue()
|
||||
let sessionSequence = 0
|
||||
const fixture = harness({
|
||||
agentOwned: true, shareOwnedProcesses: shared, uniqueProcesses: true, semanticStore: transcript,
|
||||
configureProcess: process => {
|
||||
processes.push(process)
|
||||
process.onWrite = payload => {
|
||||
const request = JSON.parse(Buffer.from(payload).toString())
|
||||
if (request.id === undefined) return
|
||||
const reply = (result: unknown) => process.emit('stdout', `${JSON.stringify({ jsonrpc: '2.0', id: request.id, result })}\n`)
|
||||
if (request.method === 'session/prompt') {
|
||||
pending.set(request.params.sessionId, () => reply({ stopReason: 'end_turn' }))
|
||||
return
|
||||
}
|
||||
let result: unknown = {}
|
||||
if (request.method === 'initialize') result = { protocolVersion: 1, agentCapabilities: { loadSession: true } }
|
||||
if (request.method === 'session/new') result = { sessionId: `native-${++sessionSequence}` }
|
||||
if (request.method === 'session/load') loaded.push(request.params.sessionId)
|
||||
if (request.method === 'session/set_config_option') result = {
|
||||
configOptions: [{ id: 'model', name: 'Model', type: 'select', currentValue: request.params.value, options: [] }]
|
||||
}
|
||||
void reply(result)
|
||||
}
|
||||
}
|
||||
})
|
||||
const disconnect = new AbortController()
|
||||
fixture.context.signal = disconnect.signal
|
||||
const start = async (index: number, operationId: string, sessionId?: string) => {
|
||||
const bindingId = `owned-${index}`
|
||||
const channel = await invoke(fixture, 'runtime/openAcpChannel', { ...fixture.openRequest, bindingId }) as { channelEpoch: string }
|
||||
await invoke(fixture, 'runtime/preparePrompt', fixture.preparation({ bindingId, channelEpoch: channel.channelEpoch,
|
||||
operationId, requestId: operationId, promptSequence: sessionId ? 1 : 0 }))
|
||||
return await invoke(fixture, 'runtime/startPrompt', { bindingId, operationId, requestId: operationId,
|
||||
...(sessionId ? { acpSessionId: sessionId } : {}), prompt: [{ type: 'text', text: 'Continue' }] }) as { sessionId: string }
|
||||
}
|
||||
try {
|
||||
const first = await start(1, 'operation-1')
|
||||
const second = await start(2, 'operation-2')
|
||||
expect(processes).toHaveLength(shared ? 1 : 2)
|
||||
await pending.get(first.sessionId)!()
|
||||
await vi.waitFor(() => expect(transcript.attach('owned-1', 'operation-1', fixture.context.controller.controllerId).state).toBe('completed'))
|
||||
expect(processes[0]!.stops).toHaveLength(shared ? 0 : 1)
|
||||
expect(processes.at(-1)!.stops).toHaveLength(0)
|
||||
await pending.get(second.sessionId)!()
|
||||
await vi.waitFor(() => expect(transcript.attach('owned-2', 'operation-2', fixture.context.controller.controllerId).state).toBe('completed'))
|
||||
expect(processes.every(process => process.stops.length === 1)).toBe(true)
|
||||
const resumed = await start(1, 'operation-3', first.sessionId)
|
||||
expect(loaded).toEqual([first.sessionId])
|
||||
expect(resumed.sessionId).toBe(first.sessionId)
|
||||
await processes.at(-1)!.emit('stdout', `${JSON.stringify({
|
||||
jsonrpc: '2.0', method: 'session/update', params: {
|
||||
sessionId: resumed.sessionId,
|
||||
update: { sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: '' },
|
||||
_meta: { goodbuddyQuestion: {
|
||||
endpoint: `http://127.0.0.1:12345/${'a'.repeat(43)}`,
|
||||
question: { id: 'question-1', sessionID: resumed.sessionId,
|
||||
questions: [{ header: 'Continue', question: 'Proceed?', options: [] }] }
|
||||
} }
|
||||
}
|
||||
}
|
||||
})}\n`)
|
||||
await vi.waitFor(() => expect(transcript.page({
|
||||
bindingId: 'owned-1', operationId: 'operation-3',
|
||||
controllerId: fixture.context.controller.controllerId, afterSequence: '0', limit: 10
|
||||
}).events.some(event => JSON.stringify(event.payload).includes('question-1'))).toBe(true))
|
||||
disconnect.abort()
|
||||
await expect(fixture.backend.drain()).resolves.toBe(false)
|
||||
expect(processes.at(-1)!.stops).toHaveLength(0)
|
||||
await pending.get(resumed.sessionId)!()
|
||||
await vi.waitFor(() => expect(transcript.attach('owned-1', 'operation-3', fixture.context.controller.controllerId).state).toBe('completed'))
|
||||
await expect(fixture.backend.drain()).resolves.toBe(true)
|
||||
fixture.context.signal = new AbortController().signal
|
||||
await expect(start(1, 'operation-4', first.sessionId)).rejects.toThrow('draining')
|
||||
expect(transcript.attach('owned-1', 'operation-1', fixture.context.controller.controllerId).sessionId).toBe(first.sessionId)
|
||||
} finally {
|
||||
await fixture.backend.dispose()
|
||||
setRoute.mockRestore()
|
||||
transcript.close()
|
||||
await rm(root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('shares Agent-owned workspaces and modes, retaining peers and reclaiming only an unused process', async () => {
|
||||
const fixture = harness({ agentOwned: true, shareOwnedProcesses: true })
|
||||
const channels: Array<{ bindingId: string; channelId: string; channelEpoch: string }> = []
|
||||
|
||||
@@ -86,7 +86,7 @@ const DEFAULT_MAXIMUM_PENDING_OUTPUT_BYTES_GLOBAL =
|
||||
const DEFAULT_MAXIMUM_OPERATIONS_PER_BINDING = 1_000
|
||||
const DEFAULT_DISPOSE_TIMEOUT_MS = 10_000
|
||||
const OWNED_PROMPT_CANCEL_GRACE_MS = 1_000
|
||||
const SHARED_PROCESS_IDLE_MS = 60_000
|
||||
const SHARED_PROCESS_IDLE_MS = 0
|
||||
const OWNED_PROMPT_START_TIMEOUT_MS = 2 * 60_000
|
||||
const ACP_JOURNAL_RETRY_INITIAL_MILLISECONDS = 25
|
||||
const ACP_JOURNAL_RETRY_MAXIMUM_MILLISECONDS = 1_000
|
||||
@@ -309,6 +309,7 @@ type PreparedOperation = {
|
||||
modelProfile?: AgentPromptModelProfile
|
||||
promptSequence: number
|
||||
ownedPromptStarted?: boolean
|
||||
terminalAcknowledged?: boolean
|
||||
}
|
||||
|
||||
type BindingState = {
|
||||
@@ -374,6 +375,7 @@ export class RuntimeAcpBackend {
|
||||
#pendingOutputBytesGlobal = 0
|
||||
#controlTail: Promise<void> = Promise.resolve()
|
||||
#disposed = false
|
||||
#draining = false
|
||||
#disposePromise?: Promise<void>
|
||||
|
||||
constructor(options: RuntimeAcpBackendOptions) {
|
||||
@@ -636,6 +638,18 @@ export class RuntimeAcpBackend {
|
||||
frame.header.generation === binding.controllerGeneration
|
||||
)
|
||||
|
||||
async drain(): Promise<boolean> {
|
||||
this.#draining = true
|
||||
return this.#enqueueControl(() =>
|
||||
![...this.#bindings.values()].some(binding =>
|
||||
binding.activeOperationId !== undefined ||
|
||||
(binding.transportState !== 'detached' && [...binding.operations.values()].some(operation =>
|
||||
operation.ownedPromptStarted && !operation.terminalAcknowledged
|
||||
))
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
dispose(timeoutMs = DEFAULT_DISPOSE_TIMEOUT_MS): Promise<void> {
|
||||
if (this.#disposePromise !== undefined) {
|
||||
return this.#disposePromise
|
||||
@@ -1039,6 +1053,9 @@ export class RuntimeAcpBackend {
|
||||
existing.acceptance
|
||||
)
|
||||
}
|
||||
if (this.#draining) {
|
||||
throw new RuntimeAcpBackendError('Agent installation is draining after update', 'closed')
|
||||
}
|
||||
if (binding.activeOperationId !== undefined) {
|
||||
throw new RuntimeAcpBackendError(
|
||||
'Another prompt operation is still active',
|
||||
@@ -1055,6 +1072,7 @@ export class RuntimeAcpBackend {
|
||||
)
|
||||
}
|
||||
if (
|
||||
binding.process !== undefined &&
|
||||
binding.workMode !== undefined &&
|
||||
binding.workMode !== preparation.workMode &&
|
||||
binding.sharedProcess === undefined
|
||||
@@ -1617,10 +1635,19 @@ export class RuntimeAcpBackend {
|
||||
'process'
|
||||
)
|
||||
}
|
||||
return this.#options.semanticPrompts.acknowledge({
|
||||
const result = this.#options.semanticPrompts.acknowledge({
|
||||
...request,
|
||||
controllerId: context.controller.controllerId
|
||||
})
|
||||
const operation = this.#bindings.get(request.bindingId)?.operations.get(request.operationId)
|
||||
if (operation?.ownedPromptStarted) {
|
||||
const terminal = this.#options.semanticPrompts.attach(
|
||||
request.bindingId, request.operationId, context.controller.controllerId
|
||||
)
|
||||
operation.terminalAcknowledged = terminal.state !== 'starting' && terminal.state !== 'running' &&
|
||||
BigInt(result.acknowledgedSequence) >= BigInt(terminal.latestSemanticSequence)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
async #terminalizeOwnedPrompt(
|
||||
@@ -1683,6 +1710,7 @@ export class RuntimeAcpBackend {
|
||||
clearTimeout(binding.ownedPromptStartTimer)
|
||||
binding.ownedPromptStartTimer = undefined
|
||||
}
|
||||
await this.#releaseIdleProcess(binding)
|
||||
}
|
||||
|
||||
async #startModelBridge(
|
||||
@@ -2195,6 +2223,25 @@ export class RuntimeAcpBackend {
|
||||
() => undefined
|
||||
)
|
||||
}
|
||||
await this.#releaseIdleProcess(binding)
|
||||
}
|
||||
|
||||
async #releaseIdleProcess(binding: BindingState): Promise<void> {
|
||||
if (binding.activeOperationId !== undefined || !binding.process) return
|
||||
const group = binding.sharedProcess
|
||||
if (group) {
|
||||
if ([...group.bindings].some(peer => peer.activeOperationId !== undefined)) return
|
||||
await this.#stopSharedProcess(group, 'binding-closed')
|
||||
} else {
|
||||
await binding.process.stop({
|
||||
reason: 'binding-closed', deadlineAt: new Date(this.#now() + 10_000).toISOString()
|
||||
})
|
||||
const result = await binding.process.reconcile()
|
||||
if (!sameProcessIdentity(result.identity, binding.process.identity) || result.processTree !== 'empty') {
|
||||
throw new RuntimeAcpBackendError('Idle Runtime process stop did not reconcile', 'process')
|
||||
}
|
||||
await this.#handleProcessExit(binding)
|
||||
}
|
||||
}
|
||||
|
||||
async #stopAndReconcile(
|
||||
@@ -2295,6 +2342,15 @@ export class RuntimeAcpBackend {
|
||||
if (binding.process === undefined) {
|
||||
return
|
||||
}
|
||||
if (binding.activeOperationId === undefined) {
|
||||
for (const operation of binding.operations.values()) {
|
||||
operation.processTree = 'empty'
|
||||
if (operation.completion) operation.completion.processTree = 'empty'
|
||||
}
|
||||
this.#finishProcess(binding)
|
||||
binding.state = 'open'
|
||||
return
|
||||
}
|
||||
this.#appendAbnormalOwnedPromptTerminal(binding, {
|
||||
status: this.#abnormalOwnedPromptState(binding),
|
||||
name: 'RuntimeExited',
|
||||
@@ -2466,11 +2522,13 @@ export class RuntimeAcpBackend {
|
||||
}
|
||||
|
||||
#scheduleSharedIdle(group: SharedProcess): void {
|
||||
if (group.bindings.size || group.exited || this.#disposed || group.idleTimer) return
|
||||
if ([...group.bindings].some(binding => binding.activeOperationId !== undefined) || group.exited || this.#disposed || group.idleTimer) return
|
||||
group.idleTimer = setTimeout(() => {
|
||||
group.idleTimer = undefined
|
||||
void this.#enqueueControl(async () => {
|
||||
if (!group.bindings.size) await this.#stopSharedProcess(group, 'binding-closed')
|
||||
if (![...group.bindings].some(binding => binding.activeOperationId !== undefined)) {
|
||||
await this.#stopSharedProcess(group, 'binding-closed')
|
||||
}
|
||||
}).catch(() => undefined)
|
||||
}, this.#options.sharedProcessIdleMs ?? SHARED_PROCESS_IDLE_MS)
|
||||
group.idleTimer.unref?.()
|
||||
|
||||
@@ -53,6 +53,7 @@ export type ProductionRuntimeProtocol = {
|
||||
onBlobFrame?: RuntimeAcpBackend['onBlobFrame']
|
||||
authorizeBlobFrame?: RuntimeAcpBackend['authorizeBlobFrame']
|
||||
dispose: () => Promise<void>
|
||||
drain: () => Promise<boolean>
|
||||
}
|
||||
|
||||
export type ProductionRuntimeCompositionOptions = {
|
||||
@@ -348,6 +349,7 @@ export async function createProductionRuntimeProtocol(
|
||||
}
|
||||
},
|
||||
methods: backend.methods,
|
||||
drain: () => backend.drain(),
|
||||
onAcpFrame: backend.onAcpFrame,
|
||||
...(options.blobSink === undefined ||
|
||||
options.agentExecutablePath === undefined
|
||||
|
||||
@@ -2869,12 +2869,18 @@ describe('AcpRemoteRuntime Agent-owned prompts', () => {
|
||||
expect(start).toHaveProperty('acpSessionId', original.acpSessionId)
|
||||
expect(resumed.promptSequence).toBe(1)
|
||||
}
|
||||
await collect(replacement.run({ ...request, requestId: 'third' }, new AbortController().signal))
|
||||
expect(await store.getByConversation(request.conversationId)).toMatchObject({
|
||||
bindingId: resumed.bindingId, state: 'ready', promptSequence: resumed.promptSequence + 1
|
||||
})
|
||||
await collect(replacement.run({ ...request, requestId: 'third', history }, new AbortController().signal))
|
||||
if (runtimeId === 'continue') {
|
||||
expect(await store.getByConversation(request.conversationId)).toMatchObject({ state: 'ready', promptSequence: 0 })
|
||||
expect(next.startOwnedPrompt.mock.calls[1]![0]).not.toHaveProperty('acpSessionId')
|
||||
expect(JSON.stringify(next.startOwnedPrompt.mock.calls[1]![0])).toContain('previous answer')
|
||||
} else {
|
||||
expect(await store.getByConversation(request.conversationId)).toMatchObject({
|
||||
bindingId: resumed.bindingId, state: 'ready', promptSequence: resumed.promptSequence + 1
|
||||
})
|
||||
expect(next.startOwnedPrompt.mock.calls[1]![0]).toHaveProperty('acpSessionId', resumed.acpSessionId)
|
||||
}
|
||||
expect(next.startOwnedPrompt).toHaveBeenCalledTimes(2)
|
||||
expect(next.startOwnedPrompt.mock.calls[1]![0]).toHaveProperty('acpSessionId', resumed.acpSessionId)
|
||||
expect(next.attachOwnedPrompt).not.toHaveBeenCalled()
|
||||
} finally {
|
||||
await replacement?.dispose()
|
||||
|
||||
@@ -1746,6 +1746,13 @@ export class AcpRemoteRuntime implements AgentRuntime {
|
||||
signal: AbortSignal
|
||||
): Promise<SessionRecord> {
|
||||
let current = this.sessions.get(conversationId)
|
||||
if (current?.binding.state === 'ready' && this.options.runtimeId === 'continue' &&
|
||||
this.options.modelProfile !== undefined) {
|
||||
// Continue keeps native history in process memory; seed the next process
|
||||
// from the persisted conversation through the existing cold-open path.
|
||||
await this.releaseConversation(conversationId, signal)
|
||||
current = undefined
|
||||
}
|
||||
if (
|
||||
current?.binding.activePromptOperationId &&
|
||||
this.options.modelProfile !== undefined &&
|
||||
|
||||
@@ -2201,7 +2201,7 @@ describe('registerIpcHandlers SSH hosts', () => {
|
||||
).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('blocks disabled and unavailable preview access before remote services', async () => {
|
||||
it('blocks disabled and unavailable remote-project access before remote services', async () => {
|
||||
const hostId = '00000000-0000-4000-8000-000000000105'
|
||||
const candidateId = '00000000-0000-4000-8000-000000000106'
|
||||
const projectId = '00000000-0000-4000-8000-000000000107'
|
||||
@@ -2372,7 +2372,7 @@ describe('registerIpcHandlers SSH hosts', () => {
|
||||
for (const [channel, input] of disabledRequests) {
|
||||
await expect(
|
||||
electronMocks.handlers.get(channel)?.(event, input)
|
||||
).rejects.toThrow('远程项目(技术预览)未启用')
|
||||
).rejects.toThrow('远程项目未启用')
|
||||
}
|
||||
|
||||
await expect(
|
||||
@@ -2385,25 +2385,25 @@ describe('registerIpcHandlers SSH hosts', () => {
|
||||
defaultWorkMode: 'ask'
|
||||
}
|
||||
})
|
||||
).rejects.toThrow('远程项目(技术预览)未启用')
|
||||
).rejects.toThrow('远程项目未启用')
|
||||
await expect(
|
||||
electronMocks.handlers.get(ipcChannels.projectsSetArchived)?.(
|
||||
event,
|
||||
{ projectId, archived: true }
|
||||
)
|
||||
).rejects.toThrow('远程项目(技术预览)未启用')
|
||||
).rejects.toThrow('远程项目未启用')
|
||||
await expect(
|
||||
electronMocks.handlers.get(ipcChannels.projectsDelete)?.(event, {
|
||||
projectId,
|
||||
confirmation: remoteProject.name
|
||||
})
|
||||
).rejects.toThrow('远程项目(技术预览)未启用')
|
||||
).rejects.toThrow('远程项目未启用')
|
||||
await expect(
|
||||
electronMocks.handlers.get(ipcChannels.workspaceFileRead)?.(
|
||||
event,
|
||||
{ projectId, path: 'README.md' }
|
||||
)
|
||||
).rejects.toThrow('远程项目(技术预览)未启用')
|
||||
).rejects.toThrow('远程项目未启用')
|
||||
|
||||
expect(sshHostService.getSnapshot).not.toHaveBeenCalled()
|
||||
expect(sshHostService.remove).not.toHaveBeenCalled()
|
||||
@@ -2457,7 +2457,7 @@ describe('registerIpcHandlers SSH hosts', () => {
|
||||
)
|
||||
await expect(
|
||||
electronMocks.handlers.get(ipcChannels.sshHostsGet)?.(event)
|
||||
).rejects.toThrow('远程项目(技术预览)未启用')
|
||||
).rejects.toThrow('远程项目未启用')
|
||||
expect(sshHostService.getSnapshot).not.toHaveBeenCalled()
|
||||
|
||||
await dispose()
|
||||
|
||||
+1
-1
@@ -1461,7 +1461,7 @@ export function registerIpcHandlers(
|
||||
} catch {
|
||||
// Keep the feature closed when its settings cannot be read.
|
||||
}
|
||||
throw new Error('远程项目(技术预览)未启用')
|
||||
throw new Error('远程项目未启用')
|
||||
}
|
||||
const registerHandler = (
|
||||
channel: Parameters<typeof ipcMain.handle>[0],
|
||||
|
||||
@@ -697,7 +697,6 @@ function assertConnectionMatches(
|
||||
connection.identity.binaryDigest !== agent.binaryDigest ||
|
||||
connection.identity.protocolMajor !== agent.protocol.major ||
|
||||
connection.status.state !== 'ready' ||
|
||||
connection.status.draining ||
|
||||
connection.status.installationId !== agent.installationId ||
|
||||
connection.status.binaryDigest !== agent.binaryDigest ||
|
||||
connection.status.agentVersion !== agent.agentVersion ||
|
||||
|
||||
@@ -286,7 +286,7 @@ describe('ProtocolRemoteWorkspaceTransport', () => {
|
||||
installationMismatch.close()
|
||||
|
||||
const statusMismatch = createHarness({
|
||||
connectionStatus: { draining: true }
|
||||
connectionStatus: { state: 'offline' }
|
||||
})
|
||||
await expect(
|
||||
statusMismatch.transport.acquireLease(binding)
|
||||
|
||||
@@ -356,7 +356,6 @@ function assertConnectionStatus(
|
||||
const installation = resolution.installation
|
||||
if (
|
||||
status.state !== 'ready' ||
|
||||
status.draining ||
|
||||
status.installationId !== installation.installationId ||
|
||||
status.binaryDigest !== installation.binaryDigest ||
|
||||
status.agentVersion !== installation.agentVersion ||
|
||||
|
||||
@@ -645,7 +645,6 @@ export class RemoteAgentConnectionManager {
|
||||
const expected = entry.installation
|
||||
if (
|
||||
status.state !== 'ready' ||
|
||||
status.draining ||
|
||||
status.installationId !== expected.installationId ||
|
||||
status.binaryDigest !== expected.binaryDigest ||
|
||||
status.daemonBootId !== transport.welcome.daemonBootId
|
||||
|
||||
@@ -8586,11 +8586,11 @@ describe("App", () => {
|
||||
fireEvent.click(await screen.findByRole("tab", { name: "平台功能" }));
|
||||
fireEvent.click(
|
||||
await screen.findByRole("tab", {
|
||||
name: "远程项目(技术预览)",
|
||||
name: "远程项目",
|
||||
}),
|
||||
);
|
||||
const remoteProjectsSwitch = await screen.findByRole("switch", {
|
||||
name: "远程项目(技术预览)",
|
||||
name: "远程项目",
|
||||
});
|
||||
expect(remoteProjectsSwitch).toBeChecked();
|
||||
fireEvent.click(remoteProjectsSwitch);
|
||||
|
||||
@@ -1563,7 +1563,7 @@ describe('SettingsPanel runtime files', () => {
|
||||
})
|
||||
|
||||
expect(
|
||||
screen.getByRole('tab', { name: '远程项目(技术预览)' })
|
||||
screen.getByRole('tab', { name: '远程项目' })
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
|
||||
@@ -2060,10 +2060,10 @@ describe('SettingsPanel runtime files', () => {
|
||||
|
||||
fireEvent.click(screen.getByRole('tab', { name: '平台功能' }))
|
||||
fireEvent.click(
|
||||
screen.getByRole('tab', { name: '远程项目(技术预览)' })
|
||||
screen.getByRole('tab', { name: '远程项目' })
|
||||
)
|
||||
const remoteProjectsSwitch = await screen.findByRole('switch', {
|
||||
name: '远程项目(技术预览)'
|
||||
name: '远程项目'
|
||||
})
|
||||
expect(remoteProjectsSwitch).not.toBeChecked()
|
||||
expect(
|
||||
@@ -2178,7 +2178,7 @@ describe('SettingsPanel runtime files', () => {
|
||||
|
||||
fireEvent.click(screen.getByRole('tab', { name: '平台功能' }))
|
||||
fireEvent.click(
|
||||
screen.getByRole('tab', { name: '远程项目(技术预览)' })
|
||||
screen.getByRole('tab', { name: '远程项目' })
|
||||
)
|
||||
expect(
|
||||
await screen.findByText(
|
||||
@@ -2239,7 +2239,7 @@ describe('SettingsPanel runtime files', () => {
|
||||
|
||||
fireEvent.click(screen.getByRole('tab', { name: '平台功能' }))
|
||||
fireEvent.click(
|
||||
screen.getByRole('tab', { name: '远程项目(技术预览)' })
|
||||
screen.getByRole('tab', { name: '远程项目' })
|
||||
)
|
||||
|
||||
expect(
|
||||
@@ -2332,11 +2332,11 @@ describe('SettingsPanel runtime files', () => {
|
||||
screen.queryByRole('tab', { name: '主机与远程执行' })
|
||||
).not.toBeInTheDocument()
|
||||
fireEvent.click(
|
||||
screen.getByRole('tab', { name: '远程项目(技术预览)' })
|
||||
screen.getByRole('tab', { name: '远程项目' })
|
||||
)
|
||||
expect(
|
||||
await screen.findByRole('switch', {
|
||||
name: '远程项目(技术预览)'
|
||||
name: '远程项目'
|
||||
})
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
|
||||
@@ -567,7 +567,7 @@ export const settingsSections = {
|
||||
enabled: 'Render HTML in conversations'
|
||||
},
|
||||
remoteProjects: {
|
||||
title: 'Remote Projects (Technical Preview)',
|
||||
title: 'Remote Projects',
|
||||
description:
|
||||
'Enables SSH Host management and GoodBuddy-managed remote projects.',
|
||||
enabled: 'Enable Remote Projects',
|
||||
|
||||
@@ -538,7 +538,7 @@ export const settingsSections = {
|
||||
enabled: '在会话中渲染 HTML'
|
||||
},
|
||||
remoteProjects: {
|
||||
title: '远程项目(技术预览)',
|
||||
title: '远程项目',
|
||||
description: '启用 SSH Host 管理和由 GoodBuddy 托管的远程项目。',
|
||||
enabled: '启用远程项目',
|
||||
agentInventory: {
|
||||
|
||||
Reference in New Issue
Block a user