diff --git a/AGENTS.md b/AGENTS.md index 63bb6105..286b2a0e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -339,6 +339,10 @@ validation are not substitutes for this development-time check. - Keep all artifacts in GoodBuddy-owned test directories. Before stopping or changing a remote process, verify that it belongs to the test run; never modify unrelated Host files or processes. +- During remote testing, prefer dedicated test directories under `/root/tmp` + for temporary files. Avoid creating files under `/tmp` where possible, since + it may be memory-backed and consume additional RAM. Set `TMPDIR` to the + dedicated test directory when needed so test tools use it as well. - If neither route is reachable, report the real-Host validation as blocked and do not present the Agent work as complete or defer its first real validation to release time. diff --git a/docs/features/assistant-workbar/README.md b/docs/features/assistant-workbar/README.md index 14c4d977..b931ccb6 100644 --- a/docs/features/assistant-workbar/README.md +++ b/docs/features/assistant-workbar/README.md @@ -11,7 +11,9 @@ | [浏览器多实例技术设计](./browser-tabs-technical-design.md) | 浏览器 Tab 身份、Conversation 绑定、IPC、BrowserService 和 MCP 路由 | | [实现与验证进度](./progress.md) | 工作区与 Runtime 相关改动的实现事实、验证证据与剩余阻塞 | | [执行记录存储与升级回收](./execution-history-storage.md) | 子任务过程差量存储、远程去重、旧库转换、空间回收及升级界面 | -| [Runtime 交互边界](./runtime-interactions.md) | 原生权限、结构化问答、后台拒绝、终端交互、托管 Runtime 并行与输出及消息底部请求状态的适配范围 | +| [Runtime 交互边界](./runtime-interactions.md) | 原生权限、结构化问答、后台拒绝、终端交互、并行与输出、消息请求状态及资源生命周期规则 | +| [Runtime 进程复用技术设计](./runtime-process-reuse-technical-design.md) | 跨 Runtime 资源调研、锁定协议证据、进程与会话拆分、远端模型桥、实施顺序和性能验收 | -当前尚未拆出独立 User Stories、功能逻辑和 UI 设计文档;相关内容仍保留在两份现有 PRD -中。浏览器多实例涉及跨进程身份和工具路由,单独使用技术设计文档维护。 +当前尚未拆出独立 User Stories、功能逻辑和 UI 设计文档;产品场景与验收保留在现有 PRD, +Runtime 资源状态规则由交互边界文档维护。浏览器多实例和 Runtime 进程复用涉及不同的 +跨进程身份与工具路由,分别使用技术设计文档维护。 diff --git a/docs/features/assistant-workbar/prd.md b/docs/features/assistant-workbar/prd.md index bf68364d..4de97a57 100644 --- a/docs/features/assistant-workbar/prd.md +++ b/docs/features/assistant-workbar/prd.md @@ -949,6 +949,41 @@ Electron、ChildProcess、PTY、SSH Client、Socket 或文件句柄。 用户级 GoodBuddy Agent Daemon。依赖 Main 模型网关、审批或本机工具的 Run 不能被描述为 可以无人值守继续。 +### 14.1 Runtime 资源复用 + +2026-09-14 调研确认:多个项目可能各自保留一个重型 Runtime,状态或原生清单读取也会 +影响其启动与保留。用户需要多项目/多会话自然并行工作,而不是为避免内存增长关闭工具、 +子代理或删除历史。以下为新需求,**不表示 Desktop 0.13.2 / Agent 0.11.25 已实现**。 + +- **FR-RR1**:支持原生多会话的 Runtime,在同一账号和兼容启动配置下跨项目复用重型 + 进程;不得仅因项目目录或会话不同重复启动。等价的默认和显式选择应得到相同复用结果。 + 不兼容的 Runtime、Host、模型连接或插件配置可以分开,不要求全产品只有一个进程。 +- **FR-RR2**:仅查看被动状态不启动重型 Runtime。显式连接测试、原生清单和执行请求 + 复用兼容的已启动实例;尚未握手时不能显示为已经验证连接。 +- **FR-RR3**:共享进程仍保持各会话的目录、历史、模型用量、工具、权限和业务问答归属。 + 同一会话顺序执行,不同会话并行;Ask 保持只读,Execute 不增加第二层审批。 +- **FR-RR4**:请求结束、会话释放与进程退出分别处理。取消一个普通请求、关闭工作栏 + 或释放一个会话不得误停其他健康会话;无会话的空闲重型实例及时释放。正常续聊保留 + 原生上下文,应用退出、配置替换和真实进程故障仍有明确的终态与有界回收。 +- **FR-RR5**:托管 SSH 独立实现并验证复用,保留 Agent 对活动 Prompt 的所有权、 + 断线续接、事件去重和模型调用结果不确定性规则。不能以本机通过代替远端交付。 +- **FR-RR6**:对不具备原生多会话 Host 的 Continue 保留正确的请求隔离;直连模型不 + 新增独立模型进程。优化应有前后性能证据,不以删历史、禁用工具、全局串行化或 + 推迟正常用户操作来换取进程/内存指标。 + +产品场景验收: + +| 场景 | Given / When / Then | +| --- | --- | +| US-RR1,首次与热启动 | 同一有效配置的两个项目,分别查看状态、打开清单并开始四个对话;状态不冷启动,后续共用一个可多会话的重型 Runtime | +| US-RR2,并行与取消 | 两个项目有同名不同内容文件,四个会话进行真实开发;目录及成果正确,取消其中一个后其余三个继续完成 | +| US-RR3,续聊与释放 | 一轮任务完成后继续原会话,再释放其中一个;原生上下文可续用、其他会话不受影响,数据库完整历史仍可查看 | +| US-RR4,边界与远程 | 切换不兼容配置、查看 Continue/直连状态或中断 SSH;不串配置、不伪造多会话能力,Agent 已接受工作按现有归属恢复 | + +资源状态规则由 [Runtime 交互边界](./runtime-interactions.md#runtime-资源生命周期) +定义;锁定版本研究、实现选择与逐项验收由 +[进程复用技术设计](./runtime-process-reuse-technical-design.md)定义。 + ## 15. 无障碍与响应式 - 能力目录、所有面板实例、目标选择器、终端控制和进程操作可用键盘完成。 diff --git a/docs/features/assistant-workbar/progress.md b/docs/features/assistant-workbar/progress.md index 11b2f440..816dc031 100644 --- a/docs/features/assistant-workbar/progress.md +++ b/docs/features/assistant-workbar/progress.md @@ -1,5 +1,471 @@ # 工作栏实现与验证进度 +## 2026-09-14 Runtime 进程复用(实施中) + +对应 FR-RR1–FR-RR6,设计及完整验收清单见 +[Runtime 进程复用技术设计](./runtime-process-reuse-technical-design.md)。 +用户已授权实施,本次复用改动尚未整体提交或发布,不包含在正式 Desktop 0.13.2 / +Agent 0.11.25 中。最终验证结果见本节末尾,早期失败及中间状态按时间保留。 + +- P0–P2:Main 已组合 `LocalRuntimeRegistry`,OpenCode Server 和 DSH Utility 按兼容 + 配置共享,工作区通过轻量适配器传入 Session。无会话 owner 空闲 60 秒回收; + 适配器淘汰不结束其他会话。DSH 被动状态不再启动 Host,取消超时先释放目标 Session, + 同时回收对应 Main 工具资源;配置替换期间的原生问题继续交给原请求的 Runtime。 +- 首轮 Windows 完整 UI 使用当时构建的生产 Main/Preload/Renderer 和隔离 profile, + OpenCode、DSH 各两个项目、四个并行会话,通过输入区和发送按钮完成实际开发。 + OpenCode 工具数为 7、7、6、8;DSH 为 9、13、11、11。独立运行各项目测试合计 + 60 项通过。两类 Runtime 分别共用一个重型进程,本轮未使用原生子代理。 +- 首轮 UI 提供方请求共 55 次 HTTP 200:OpenCode 23、DSH 32。其中包含驱动选择器 + 失误引发的一条额外默认项目 Ask,工具为 0,不把它计为开发任务验收。 + 同轮 OpenCode 单进程 Private Commit 约 882.88 MiB,工作集约 629.51 MiB; + Electron 自身指标另存,不将两者混为整个 App 的测量结论。后续重复运行和空闲 + 证据见下文,不把这一单点与正式用户库直接比较。 +- P3:生产 Agent 已接入单 ACP decoder/握手、Session → binding/operation 路由、 + 逐 operation Unix 模型桥及空闲回收。共享 stdio 与 Prompt 输入/期限分离; + Session 取消结算可如实保留 `processTree: running`。未增加持久进程池账本。 +- 共享 Linux x64 Host 已通过现有加密凭据与固定 Host identity 连接。使用当前源码、 + 固定 Node 24.19.0 / OpenCode 1.18.29 构建隔离测试包,走真实 prepare/commit、 + activate、attach 和 Agent-owned Prompt,不改写已有 Host 安装。 +- Linux 首轮五条 Prompt 共 22 次已完成且已交付模型调用;两个 Execute 分别在正确 + 项目完成修改,各通过 8 项测试,四个会话只启动一个原生进程。但 Ask 的搜索被原 + `kind === read` 规则误拒,原生子代理工具因权限请求未转发而等待,首轮验收失败。 + 修正采用原生 Session 工具规则;原生子代理只继承父拒绝规则、不继承允许规则这一点 + 已按固定版本源码核实,子 Session 同样设置当前模式,并保留原生显式限制。 +- 下一轮测试脚本在 transcript 尚有分页时按已完成状态提前结束,误报缺少项目 marker, + 随后 cleanup 中止了其他三个请求。这是验证驱动缺陷,产品自身已有 `hasMore` 处理; + 驱动已修正为读完全部分页、等待全部并行结果。该轮共 14 次 dispatch, + 其中 11 次完成、3 次结果不确定,失败记录保留,不将其计为通过。 +- 重复测试包构建曾遇到 Host ENOSPC。只清理本轮已确认可重建的 bundle、Runtime + staging 和重复 `.gbagent`,保留测试 home、workspace、SQLite 及报告,继续验证。 +- 后续两轮 Linux 各 7 条 Prompt、35/34 次 dispatch,四个 Ask/Execute 与原生子代理 + 续接均通过,分别取得 483/467 条子会话事件;取消没有关闭共享 owner。重连驱动先后 + 漏发 `controller/resume`、错读 `agent/capabilities.generation`,导致两次后续验收 + 失败;均已修正,保留失败报告。35 次调用一轮包含 1 次结果不确定,34 次一轮全部完成。 +- 最终当前源码 Linux 生命周期复验:2 条 Prompt、5 次完成且已交付调用,共享一个 + owner。目标返回 `cancelled / processTree: running`;另一会话跨真实 SSH 断线、 + controller resume 和重新 attach 继续执行,3 个工具返回正确项目 marker。 + 全部 binding 释放后等待 65 秒,owner 数为 0;停止隔离 Agent 后仍为 0。 +- 原生 Compact 单独复验:两项目各先完成 3 工具请求,再并行压缩原生 Session。 + 两次压缩分别归到各自的新 operation,各 1 次已交付调用,前后为同一个 owner; + 总计 4 条 Prompt、8 次完成且已交付调用。释放会话并等待 65 秒后 owner 为 0, + Agent 停止后仍为 0。该结果没有用普通聊天返回“已压缩”代替原生 Compact。 +- 第二轮 Windows 完整 UI 重开同一隔离 profile,四项目八条续接开发请求全部完成, + OpenCode 包含原生子代理,DSH 使用已有原生工具。独立项目测试合计 77 项通过。 + 两个 Runtime 仍各一个重型进程。另有四条实际慢工具请求:前两条 30 秒测试在点击 + 停止前已结束,不作为取消通过;后两条 120 秒测试中目标被正常 Stop 取消,另一项目 + 的测试继续完成,DSH PID 未变化。第二个 App 共 12 条任务,11 完成、1 取消; + 100 次 HTTP 200(OpenCode 24、DSH 76),无失败请求。 +- 第二轮任务结束后的 631.573 秒采样共 121 点,提供方请求计数始终为 100, + DB 始终 2,928,640 字节、WAL 始终 4,268,352 字节。期间查看了项目和截图,属于 + **无新任务/模型请求**,不是完全无人操作的 UI。原生 Session 有意保留,不要求其 + Runtime 退出;首个 App 自行退出时的进程消失不计为空闲计时验收。 + +| 进程 | Private Commit 起止(MiB) | 范围(MiB) | 区间 CPU 秒 | +| --- | ---: | ---: | ---: | +| OpenCode | 826.34 → 825.05 | 824.36–830.82 | 12.03125 | +| DSH Utility | 98.73 → 98.73 | 98.73–98.73 | 0 | +| Main | 184.16 → 185.05 | 182.90–186.77 | 0.953125 | +| Renderer | 142.80 → 136.22 | 135.00–142.80 | 0.6875 | +| GPU | 120.48 → 138.88 | 120.48–173.52 | 0.0625 | + +Private Commit 不是独占物理内存,工作集相加会重复计算共享页。这组测量未发现两个 +Runtime 在该空闲区间持续增长,但不是整个 App 无泄漏证明,也不是旧新版本对照。 + +- 当前 Linux 六个 ACP/bridge/owner 测试文件为 82 通过、0 跳过,包含 Windows 跳过的 + Unix broker 并发及旧路由排队拒绝回归。本机完整回归曾为 4,170 通过、67 跳过; + 首轮唯一失败来自旧测试把 `failed + running` 当成非法,修正后仍保留 `unknown` + 的拒绝断言,并新增共享失败/取消接受用例。 +- 标准 `typecheck` 原先没有把部分 Agent 源码作为检查入口。已增加 + `tsconfig.agent.json`,并修正暴露的 ACP resume capability、spawn stdio 及几个局部 + 类型问题;扩展类型检查、26 项受影响 Agent 回归及 lint 通过,之后已再次进行上述 + 当前源码 Host 生命周期与压缩验证。 +- 三个只读审查分别检查复用、质量/兼容来源和效率。修复了被动状态刷新不断重置 + 60 秒空闲计时的问题;状态检查不取得执行 owner,Native Session 映射成为唯一会话 + 保留依据,不再另存重复 conversation Set。最后本机定向回归 98 通过、3 跳过, + 含真实 OpenCode 与 DSH ACP 组合;扩展类型检查通过。该补修仅涉及本机 registry, + 不改变 Agent/远程路径。当时全量 `npm test` 为 **4,171 通过、67 跳过、0 失败** + (356 文件通过、9 跳过,609.57 秒);`npm run typecheck`、`npm run lint`、 + `npm run build` 均通过。构建仍报告既有 chunk 大小/混合导入警告,不影响成功结果。 +- 保留 exclusive raw ACP 是已有公开字节流路径,不是为本分支中间形态保留的兼容层。 + 新的共享取消/失败结果需要配套 Desktop/Agent,不声称旧 Desktop 已支持新结果。 + 原生插件两处祖先遍历可局部提取,但没有已证实故障或性能收益,本次不为此扩展改动。 +- 本轮真实模型调用合计 **273 次**:本机两轮 UI 155 次,Linux 六轮 + **118 次 dispatch**(29 条 Prompt,114 次完成、4 次结果不确定)。 + 包含所有失败轮次和误发默认 Ask,不重复计算最新报告别名;本机 HTTP 200 不等于 + Agent ledger 的完整交付证明。原生确定性协议测试不计入真实提供方调用。 + +补充的 owner 成本对照使用同一当前源码、同一 Windows 机器、全新空工作区,分别经 +生产 factory 创建两个独占实例或两个共享适配器。每种组合重复 3 轮,每轮显式连接后 +再做 5 次双工作区连接检查;共启动 18 个真实 OpenCode/DSH Utility,全部退出, +**真实模型和本机合成模型请求均为 0**。测量前全量测试已结束,没有同时运行测试套件。 + +| Runtime / owner 方式 | 进程数 | Private Commit 中位数及范围(MiB) | 双工作区首次 ready 中位数及范围(ms) | +| --- | ---: | ---: | ---: | +| OpenCode 两个独占实例 | 2 | 1,401.17(1,396.15–1,517.91) | 1,195.00(1,184.14–1,213.49) | +| OpenCode 一个共享 owner | 1 | 777.59(667.04–802.78) | 1,135.71(1,130.32–1,140.81) | +| DSH 两个独占实例 | 2 | 171.62(170.79–173.50) | 167.06(163.36–167.78) | +| DSH 一个共享 owner | 1 | 86.64(86.18–87.29) | 155.31(155.28–157.79) | + +这是**进程所有权方式**的对照,不是旧版 App 对新版 App、等规模历史或真实开发负载 +对照;没有测模型首字节。DSH 后续连接检查读取已缓存握手,不能当作热 Prompt 延迟。 +最后一个共享 DSH 在无 Session 时每 5 秒读取状态,持续 65 秒,原 Host 已退出且没有 +重启,证明状态轮询不会续期空闲 owner。探针早期的类型及 CJS/ESM 打包错误均发生在 +模型调用前;改为正确 ESM 依赖解析并加入启动前语法检查后完成测量,未修改产品以适应驱动。 + +清理时,完整 UI 的“关闭窗口”按现有产品行为隐藏到托盘,并不退出 App。随后只对已无 +活动任务且重新核对启动命令的隔离 App 进程树进行清理;这不作为正常托盘退出验收。 +命名浏览器会话已关闭,本机按本轮启动路径筛选的进程数为 0。最终通过固定身份 SSH +只读核对本轮 Linux 路径下的 `/proc` 进程和各测试安装 owner 数据库,两者均为 0; +保留测试 profile、工作区、数据库和报告,未删除用户历史或停止用户 Runtime。 + +此时尚未完成的完整 UI、十项目和问答恢复验收,后续结果见以下继续验收记录。 +旧新版本全 App 对照、完整共享能力组合及 Continue/直连资源分解仍需独立完成, +不能将 P0–P4/V1–V8 整体标为完成。 + +本机证据位于忽略目录 `.agent-resources/runtime-reuse-implementation-20260914/`, +包括 UI 聚合指标、截图、Host 聚合报告及保留的失败轮次。这些不是 checkout 自带资产, +不包含可提交的模型或 SSH 凭据。 + +### 继续验收:远端完整 UI 与模式切换 + +- 用户要求继续后,已从完整生产 Electron UI 启用远程项目、保存两个 SSH 项目并在 + 各项目创建两条会话。仅在测试 SSH 命令入口将 `HOME` 指向既有隔离安装;项目保存、 + Host/Agent/Runtime 发现、Main Runtime factory、传输、协议、模型和工具均保持生产实现, + 未伪造 capability、Prompt、事件或权限结果。 +- 四条正常开发任务均完成,共用一个原生 owner。分别在各自工作区实现 words/prices, + 独立运行两项目测试为 12 + 13 = **25 项通过**。实际重叠峰值为 2 条请求, + 不是四条同时运行;四个会话完成后,共享的原生 owner 仍然存活。 +- 新增真实模型调用 **21 次**,分别为 5、5、5、6,均 completed 且已交付。 + 与上一段 273 次相加,累计 **294 次**;没有把旧 Agent ledger 的 13 次基线重复计入。 +- 随后在原价格会话中从 Execute 切到 Ask 并发送原生业务问答请求,实际失败,UI + 显示“没有可安全附加的远端 Agent 请求,且恢复不会重放任务”。Agent 仍把 workMode + 固定在 binding 首次启动值,拒绝新模式;此次在模型 dispatch 前失败,新增调用为 0。 + 此问题不能由先前“不同 binding 使用不同模式”的通过结果排除。 +- 当前修正只允许共享 Session 的后续准备变更模式,独占进程仍保持原契约; + `AgentOwnedAcpPrompt.start` 显式接收当前请求模式,原生 Session 路由与 ACP 备用权限 + 决策均使用该模式,不再捕获构造时模式。48 项定向回归和扩展类型检查、lint 通过, + 包含 Execute → Ask → Execute 保留 Session 与原生父/子问答。 + 后续当前源码 Linux 包和完整 UI 复验已通过,见下一节;48 项是当时的定向结果, + 不是后续全部修正后的全量结果。 +- 首轮 UI 通过正常 `app.quit()` 进入生产退出清理,本机测试进程退出; + 隔离 Agent 随后按 installation identity 停止,`/proc` 与 owner 数据库均为 0。 + 首轮报告、失败任务和工作区保留。再次构建前 Host 仅余约 251 MiB,已明确限定只回收 + 更早停止的测试安装软件及重复测试包,不清理状态、历史或工作区。 + +### 继续验收:断线、取消、模式续接与 Desktop 重启 + +- 四个原生问题同时等待时切断实际 SSH,复现“ACP channel does not exist”,继而出现 + 重复注册 binary channel。诊断确认 Agent-owned Prompt 可能没有 raw ACP journal 行, + replay 却对不存在的行 ACK 0。修正只在已有 raw cursor 时确认 journal,不放宽正序号、 + channel/epoch 或 installation 检查。真实临时 SQLite 回归先失败再通过;94 项后端/ + journal/channel 测试、当前源码 Linux 六文件 86 项通过,失败报告保留。 +- 保留旧 Desktop profile 并更换隔离 Agent 后,旧安装的恢复曾无限重试阻塞项目。 + attach-only 路径现在在打开 channel 前明确报告安装身份不匹配,不重放 Prompt。 + 同一 Agent 的新连接则保留 Runtime,对已有 binding 旋转 transport generation, + 新 channel 使用实际连接 generation;不同 daemon 或倒退身份仍按原规则拒绝/替换。 +- 完整生产 Electron UI 再次同时等待四个问题,实际断线重连后原表单和 Session 均保留, + 没有新增模型调用或 owner。四会话尚在等待时,第五个新 Session 可以执行文件读写; + 该 Session 的 Execute → Ask → Execute 三轮均完成,各 2 个工具、3 次模型调用, + 验证实际写入、只读读取、再次写入,不以模型自述代替文件结果。 +- 取消一个 120 秒原生命令时,其余三个待答会话仍可各自回答并完成。取消后立即在原 + 对话显式发送新请求也完成,使用新的原生 Session,仍共用原 owner。已确认取消通过 + 专用错误进入终态;未确认的 Stop 保留原恢复逻辑,不把网络中断等同于取消成功。 + 曾出现 Task 已取消但 assistant message 仍 streaming,改用既有数据库终态事务 + 同时更新消息、任务和终态事件。最终真实 UI、Desktop SQLite、Agent 状态一致, + streaming message 和 active Task 均为 0,共享进程未被误杀。 +- Desktop 重启前的首次尝试因测试观察 SSH 挂起,实际上未退出,已明确作废该证据。 + 驱动加有界观察超时和 `finally` 退出后,真实重启复现两个缺陷:live question 没有 + transcript provenance 却被当作持久事件拒绝;同项目串行恢复让第二个待答 Session + 无法附加。现由 Main 并行恢复任务,live question 使用已有待答 Map 和活动请求租约, + 只投影到实时会话快照,不伪造 provenance,不写新的问题日志。 +- 修正后的真实正常退出/重开恢复两个同时待答的 Session,后一个先回答并完成, + 前一个独立 Stop。两端终态分别 completed/cancelled,UI 无残留活动或恢复提示, + 仍为同一个原生 owner。到该轮累计真实提供方调用 **348 次**: + 273 + 首轮远端 UI 21 + 旧隔离安装诊断 6 + 当前隔离安装 48。 + 48 次全部完成并交付;更早 273 次中的 4 次不确定仍保留,Prompt 取消不等同于 + 模型调用交付不确定。后续审查复验的增量另记,不覆盖这一时间点。 +- 重启修正后的全量回归为 **4,186 通过、67 跳过、1 失败**,唯一失败是 + `ActivityPanel` 的 501 条历史显示测试;该文件单独重跑 **16 项全通过**。 + 不将单文件重跑写成全量通过,原全量报告保留。此前 4,171 通过的报告早于这些修正。 + +### 继续验收:共享能力与配置退役审查 + +- OpenCode 生产 factory、真实二进制测试覆盖 2/10 项目,每项目两 Session、两轮原生 + 文件读取并逐项目原生 Compact,manager 容量设为 1,仍只启动一个 Server。 + DSH 实际 Host/ACP composition 同样覆盖 2/10 项目、每项目两 Session、两轮原生 + 文件读写和平台 Shell,一个 Host。二者使用确定性模型,新增真实提供方调用为 0; + 这是原生组合测试,不是十项目完整 Electron 性能测试。 +- DSH 补充双项目共享组合:原生 Skill、实际 stdio MCP、Host 插件清单、不同尺寸的 + inline 图片和 Execute → Ask 均通过。两项目使用不同 Main ToolProvider 和正确的 + conversation context,Ask 不调用先前 MCP,图片原始字节分别匹配。该文件当前 + **9 通过、3 跳过**;Host 在测试进程内实际运行,不能表述为完整 Utility/UI 能力矩阵。 +- 完整 61 文件复查分别检查复用、质量/兼容来源和效率。没有新增仅服务分支中间形态的 + 兼容层;raw ACP 独占和 Continue 单请求 Host 保留其已有契约。 + 复查发现重启后的回答/跳过未保存到 remote-authoritative assistant message,以及 + manager reset 后旧 controller 拒绝所属问题的回答。修正复用原消息 `answeredQuestions` + metadata 和既有 question owner,不新增 schema、恢复日志或重复历史。 +- 真实 UI 复验又确认设置保存入口仍主动取消全部请求,不能以 controller 单测通过 + 宣称生产配置切换完整。Runtime 设置、原生定制和能力更新现在只激活/退役配置, + 不批量 abort 或清除待答确认;已接受请求继续使用原配置。用户 Stop、清除数据和 + App 退出的原有取消/分离规则不变。真实 SQLite 的三入口回归覆盖并行恢复、反序回答、 + 跳过、重新打开答案和终态;manager reset 回归覆盖旧问题可回答、新工作仍拒绝。 + 最新 **152 项 IPC/controller/manager 测试、typecheck、lint 和生产构建通过**; + 这次补修的最终真实 UI 复验及全量回归仍在进行。 +- 后续真实 UI 的设置保存不再取消原生待答 Prompt;一次连接 readiness 超时后, + 重新启动 Desktop 附加同一请求并回答 `KeepYes`,原 owner 未更换,任务 completed, + 原消息的 `answeredQuestions` 已落库。再一次正常退出/重开后,“问题与回答”仍显示 + 正确答案和完成状态,截图已检查。该时点累计真实调用 **352 次**,相对 348 新增 + 当前 Agent 3 次及误选默认项目的本机 Ask 1 次;后者未使用问题工具,不计问答验收。 + 当前 Agent ledger 共 51 次,均 completed 且已交付。正常退出 Desktop 后按已核对的 + 隔离 installation 停止 Agent,所属 `/proc` 进程和 active owner 记录均为 0。 +- 复查设置更新的专家团队分支发现,既有调度器会取消剩余专家,但父请求可能用替换后的 + 模型继续综合。仅对活动团队父请求保留配置替换取消,普通原生请求不受影响; + **156 项 IPC/controller/manager/SubagentService 回归通过**。此分支属于 Main 专家 + 调度,不是 Agent 原生子代理;远端普通 Prompt 的 `teamMode` 为 false,先前验证的 + SSH 问答路径不改变。 +- 工作区随后出现其他任务的新提交和 Magic Notes API 修改。整仓类型检查曾因其 + `DesktopApi.magicNotes.onChanged` 与 App 测试 mock 不匹配失败,没有覆盖这些并行 + 改动。冻结 candidate 的 typecheck、lint、生产构建通过,前后性能对照 + 使用该隔离源码,不滚入后来的功能变更;其已带入的按钮位置调整见下节范围说明。 +- 随后完成 baseline/candidate 两个独立生产构建、相同 seed 历史、三次冷启动及 + 无新任务十分钟的全 App 对照,结果见下节。驱动校准失败不计性能样本, + 测量期间未并发全量测试;既有 owner-only 数据不替代此结果。 + +### 2026-09-15:前后完整 App 对照与剩余 Runtime 检查 + +对照基线为 `de14a6157a6c34f1e3ef22619eca374a50a83706`。通过隔离源码构建 baseline +和包含本任务改动的 candidate,使用同一 Windows 机器、同一依赖、同一 seed +SQLite(四项目、八会话、320 条等量历史消息)和相同工作区 marker。后续其他工作区 +提交不滚入该冻结对照;这里的冷启动是新的 App/profile/Runtime 进程,不清 OS 文件缓存。 + +截图及源码复核发现,candidate 的 `App.tsx` 同时带入了 `fbba2e3` 的工作栏按钮 +移到主题切换按钮旁的调整。用户明确要求保留,工作区及冻结 candidate 均未剔除, +也不为此补跑。以下结果是**包含该 UI 差异的完整 App 实测对照**,不是严格的 +纯 Runtime 单变量实验;此前“只含本任务改动”的范围描述不准确,现予以更正。 + +- 每组 3 次冷启动,各 55 条请求、110 次确定性 loopback 模型请求。每次冷启动包含 + 八会话一轮工具请求和五条热请求,最后一次另加两轮八会话续接。每轮等待八个真实 + Native Prompt 同时到达提供方再释放响应,随后实际原生 `read` 读取各项目 marker。 + 全部目录结果正确,无全局 Prompt 串行化。该对照新增真实提供方调用为 **0**; + 自然开发、写入、Shell 和真实模型验收仍以前文证据为准。 +- baseline 稳态为 3 个 OpenCode Server、2 个 DSH Host;三轮分别累计启动 3/3/3 个 + OpenCode 和 4/4/6 个 DSH(包含被动状态临时 Host)。candidate 每轮只启动一个 + OpenCode、一个 DSH;六次 App 正常退出后,本次登记的全部 Runtime 进程均退出。 +- 早期驱动的必填模型字段、Windows 子进程继承输出管道、Runtime 未就绪时点击以及 + 读到半份 JSON 报告导致的失败均保留,不纳入以下样本。最终报告原子替换,发送前等待 + 实际启用状态并核对 Main 已接收请求;不重放失败/不确定的真实模型请求。 + +| 指标 | baseline,中位数(范围) | candidate,中位数(范围) | 样本口径 | +| --- | ---: | ---: | --- | +| 全 App 采样峰值 Private Commit,MiB | 3,543.18(3,485.58–3,589.01) | 2,040.11(1,698.68–2,085.82) | 每组 3 个 App,分别取采样峰值 | +| Runtime 采样峰值 Private Commit,MiB | 2,843.18(2,831.00–2,854.82) | 1,338.93(1,229.31–1,347.63) | 同上,仅 Runtime 进程 | +| bootstrap 到首个模型请求到达,ms | 4,519(4,464–4,529) | 3,573(3,519–4,040) | 每组 3 次,包含 UI 启动与操作,不是独立握手计时 | +| OpenCode 热请求总时长,ms | 225(198–286) | 200(187–308) | 每组 9 次 | +| OpenCode 首个可见事件,ms | 139(118–201) | 124(102–187) | 文本/推理/工具任一事件,不是模型首字节 | +| DSH 热请求总时长,ms | 66.5(54–80) | 83(62–104) | 每组 6 次 | +| DSH 首个可见事件,ms | 32(28–39) | 38(30–45) | 同上 | + +这批包含上述 UI 差异的样本中,全 App 采样峰值中位数降低 **42.42%**,不能将全部 +差值单独归因于 Runtime 复用。DSH 的热请求中位数反而增加 16.5 ms, +没有隐去该回退,也没有据此增加未经测量的队列优化。Private Commit 不是独占物理 +RAM,工作集求和会重复统计共享页;5 秒采样不等于捕获了瞬时峰值。两组使用相同 +测试观察代码,数据不是未插桩 App 或生产大库的性能承诺。 + +最后一次各有 **124 个**无新任务空闲样本,覆盖 **610.588 / 610.544 秒**,最大间隔 +5.149 / 5.068 秒。两组模型请求均固定为 58,active Task 为 0,DB 固定 +2,007,040 字节、WAL 固定 4,128,272 字节、事件数固定为 276。 + +| 空闲组 | baseline Private Commit 起止,MiB / CPU 秒 | candidate Private Commit 起止,MiB / CPU 秒 | +| --- | ---: | ---: | +| 全部 OpenCode | 2,433.02 → 2,382.73 / 19.359375 | 1,007.88 → 973.12 / 11.609375 | +| 全部 DSH | 180.00 → 179.19 / 0.09375 | 86.92 → 87.62 / 0 | +| Main | 211.00 → 200.34 / 4.9375 | 205.33 → 197.70 / 4.4375 | +| Renderer | 183.32 → 137.40 / 0.265625 | 157.90 → 127.23 / 0.4375 | + +Session 有意保留;DSH 尾端有 0.70 MiB 增量,不把这一段描述为所有点都下降, +也不从十分钟数据宣称整个 App 已无泄漏。聚合报告同时保留工作集、堆、句柄和逐进程 CPU。 +原始数据位于忽略目录 `matched-app-WBCdJL/{before,after}-{10,11,12}`,汇总为 `summary.json`; +这些是本机验证资产,不进入版本库。 + +其后才运行剩余 Runtime 检查,避免干扰对照: + +- OpenCode 双项目真实 Server 组合通过原生 Skill、真实 stdio MCP 和不同 inline 图片, + 两轮八次 MCP 调用均用请求 ID 核对原 grant 归属,读取结果仍匹配各自目录;十项目 + 测试仍通过。DSH 的对应共享组合也通过。四文件合计 **114 通过、4 跳过**, + 跳过的是 3 个显式启用的真实 DSH 模型测试和 1 个真实图像生成测试,不把这些记为通过。 +- 直连模型的会话 release/dispose 与实际 Shell 完整输出分页检查通过:删除目标会话 + 缓存并释放对应 ToolProvider;dispose 先释放所有已知会话,再释放 provider。实际命令 + 结束后输出可分页,dispose 后句柄失效。没有新增模型进程池、历史字节 LRU 或持久缓存, + 此处也没有做直连模型整体 retained-heap 泄漏证明。 +- Continue 使用实际 1.5.47 包和生产 Utility adapter,3 轮各两个并发请求,另 5 次热请求。 + 11 次本机确定性提供方请求全部返回预期内容,11 个请求级 Utility 均退出,真实模型调用 + 为 0。准备耗时中位数:冷准备 **333.93 ms**(326.06–381.94),同 adapter 缓存 + **0.0031 ms**(0.0010–0.0054),新 adapter 命中磁盘产物仍 **312.97 ms** + (276.08–334.63),各 3 次。11 次原生 HTTP ready 中位数 **936.96 ms** + (925.60–959.27),首个提供方请求从 run 起算 **980 ms**(969–1,013),总时长 + **1,353 ms**(1,314–1,373)。准备在 run 前单独测量,不能把它再说成总时长已包含。 + 跨 adapter 重复准备没有消失;本次保留现有检查与缓存,不为约 0.31 秒的准备成本 + 修改单 Session Host 或新增常驻池。 +- 最终整仓 `npm test -- --reporter=json --outputFile=<本机证据目录>/full-validation-final.json` + 为 **4,204 通过、67 跳过、0 失败**,耗时约 **733.80 秒**。包括最后加强的 + OpenCode 逐请求图片互斥断言、共享能力组合、设置退役及专家父请求回归; + 先前失败的 `ActivityPanel` 本次在全量中 **16 项通过**。早期失败报告仍保留, + 不将跳过的真实提供方或平台专用测试计为通过。 +- 全量结束后再次运行 `npm run typecheck`、`npm run lint` 和 `git diff --check`, + 全部通过,先前并行 Magic Notes mock 不匹配已不再出现。10 个相关文档的 + **140 个相对文件链接**均可解析,未独立复验标题锚点。全量测试进程及按本次测试 + 路径筛选的本机验证进程均已退出;Linux 隔离安装的清理证据见前文。 +- 最终门禁及报告修订没有新增真实提供方调用,累计仍为 **352 次**。按钮位置调整及 + 其他并行工作均保留;本次未执行 commit、push、tag 或发布。性能对照仍使用原冻结 + 样本,不把整仓后来通过的回归结果描述成已重新测量该版本的性能。 + +验收边界仍需保留:性能对照是 Windows 四项目确定性读取,不是大库自然开发或十项目 +完整 UI 性能报告;十项目为实际 Native composition 测试。未测所有第三方插件的组合, +未证明整个 App/直连模型无泄漏,macOS/Linux arm64 原生运行仍需可用平台或 CI。 + +### 2026-09-15:追加真实模型复验与资源观察 + +用户要求继续使用真实模型,并监控数据库及内存。此次将 `e1fe1cf` 与当时工作区改动 +冻结到独立源码目录,构建真实 Main/Preload/Renderer,使用新的隔离 profile 和四个专用 +项目,预置 320 条合成历史。按钮位置调整保留;没有把正式用户历史送入测试。 +原始证据位于忽略目录 `.agent-resources/runtime-live-20260915/run-tQ1xtk/`, +不是 checkout 自带资产。本轮只调整测试驱动,没有修改 Runtime 产品源码。 + +- 独立生产构建通过。冻结快照的类型检查暴露并行附件功能的 App 测试 mock 缺少 + `getFilePath` / `importFiles`,未覆盖或撤销那部分工作;这一失败不被早先整仓通过 + 的结果覆盖。初次构建取证脚本误用 Preload 扩展名、同步 esbuild 不支持插件等驱动 + 错误均发生在模型调用前,生产构建与 Runtime 未因此修改。 +- 显式启用 DSH 的三个真实模型专项,全部通过:自定义 Header、Ask 下 Main 代理的 + Web Search/Fetch、真实 npm 插件在 Ask 被拒绝且在 Execute 执行。共 **7 次** + 实际模型请求;该次按测试名选择,仅运行这三项,不把其余九项的过滤跳过算作通过。 +- 完整 App 从输入区发出四项目八条开发任务,实际读写模块、添加边界测试并执行 + `node --test`。项目选择器曾因模糊名称匹配到路径中的 `live-2` 而选错项目, + 驱动在发送前被会话身份检查挡住,改用已验证的精确项目文本;没有重复发送已接受任务。 +- 第一轮六条任务完成、两条 DSH 以 `pi-ai stream idle timeout after 300000ms` + 失败。一次未观察到上游响应状态,另一条有 HTTP 200 但任务仍超时,不能把 HTTP 200 + 当作交付成功。保留失败与数据库后,正常退出测试 App,将测试转发层改为独立 + `undici.request` 流式转发,再显式重跑这两条,均完成。由于同时重启了 App, + 不能据此断言已经唯一定位到转发器或提供方;本轮未盲目修改 Runtime 来规避失败。 +- 四项目独立执行测试分别 **16、17、18、19 项通过,共 70 项**。每类 Runtime 的 + 项目请求存在重叠,使用同组 owner;本轮开发请求重叠峰值为 OpenCode 4、DSH 2。 + 两类 Runtime 分批发送,不声称八条同时运行。 + 后续真实原生子代理读取模块及测试,父请求执行测试完成;DSH 两个真实慢命令重叠时, + 只 Stop 其中一个,另一项目继续完成,DSH Host PID 未变。 +- 直连与 Continue 从同一完整 App 会话各发出一次无工具短请求,分别持久化 + `LIVE_DIRECT_OK` / `LIVE_CONTINUE_OK`。当前已配置的图像模型另发出一次生成请求, + 实际得到白底蓝色圆形图片并可在生产预览中打开;不是配置检查,也没有读取提供方 + 返回的图片 URL。这验证当前配置路径,不冒充运行了绑定指定服务商的可选测试。 +- 此时 App 共 **16 条 Prompt:13 completed、2 failed、1 cancelled**, + **67 次模型请求**;加上 DSH 专项 7 次,本机新增 **74 次真实调用(73 次文本、 + 1 次图像)**。计数包含失败及显式重跑。早期累计 352 次暂增至 426 次, + 后续 Linux 增量另记;本机 HTTP 计数不等于 Agent ledger 的交付证明。 +- 真实子代理的 **153 条事件**,实际保存 **70,668 字节**,等价完整快照 + **495,934 字节**,该事件负载减少 **85.75%**。使用生产 codec 逐条还原及重新编码 + 校验通过;6 次工具块 upsert 中无变化重复写入为 **0**,数据库 `quick_check` 为 + `ok`。这是同批事件的编码差额,不是整个数据库文件缩小 85.75%,也没有删历史。 +- 模型结束后连续 **610.857 秒、123 个样本**,最大采样间隔 **5.292 秒**。 + 请求数固定为 16,模型调用数固定为 67,活动任务和 streaming 消息为 0; + DB 固定 **1,765,376 字节**、WAL 固定 **4,140,632 字节**,消息数 355、 + 事件数 663、事件 JSON 负载 284,577 字节均不变。 +- 本轮全 App 采样峰值 Private Commit 为 **1,728.79 MiB**,OpenCode + DSH + 合计采样峰值 **997.66 MiB**。没有同负载的旧版真实模型样本,不能把它与前文 + 确定性对照相减生成新的“节省百分比”,也不把采样峰值称为瞬时峰值或独占物理 RAM。 +- App 收到正常 quit 后回收了本轮登记的全部 Runtime,OS 核验最终 Main、OpenCode、 + DSH 和 Continue Utility 均已消失。退出探针曾因等待 `will-quit` 误报超时: + 生产实现实际在 `before-quit` 清理后调用 `app.exit(0)`。该驱动失败记录保留, + 后续探针改从独立父进程观察退出,不修改产品退出路径或伪造原始报告中的 phase。 + 关闭后 WAL 为 0,DB 为 **3,125,248 字节**,属于正常 checkpoint,不是空闲增长; + 已保存图片内容为 **1,351,526 字节**,不能把图片占用与事件差量节省混为一谈。 + +| 空闲进程 | Private Commit 起止(MiB) | 范围(MiB) | 区间 CPU 秒 | 句柄起止 | +| --- | ---: | ---: | ---: | ---: | +| 全 App | 1,345.77 → 1,345.95 | 逐进程样本保留 | 分进程见下 | 分进程见下 | +| OpenCode | 750.42 → 746.00 | 741.62–750.42 | 5.984375 | 333 → 333 | +| DSH | 89.86 → 89.86 | 89.86–89.93 | 0.015625 | 364 → 364 | +| Main | 196.61 → 200.37 | 193.13–205.09 | 5.703125 | 1,137 → 1,137 | +| Renderer | 120.66 → 121.51 | 120.64–122.67 | 0.15625 | 337 → 335 | + +Main 堆使用量为 67.27 → 69.44 MiB;Main 与 Renderer 有小幅增量,不能表述为 +“所有进程都下降”。采样包含测试观察器累积记录的分配,这一窗口未见数据库增长或 +Runtime 句柄积累,但不构成整个 App 无泄漏证明。 + +当前源码 Linux 复验使用 `/root/tmp` 下的专用目录。旧 `/tmp` 测试 Node/依赖缓存 +已不存在,初次准备因失效链接失败,未发出模型请求。随后只替换本轮创建的失效链接, +核验锁定 Node 24.19.0 的 SHA-256,并安装锁定依赖,完成当前源码 Agent/Runtime +组包、隔离安装及 OpenCode 1.18.29 激活,不修改旧安装或无关 Host 文件。 + +- 两项目各 Ask/Execute 一会话,共四会话使用同一个进程 owner、一个原生 OpenCode PID。 + Ask 实际读取,Execute 实际修改和运行测试,目录 marker 均正确。后续原生子代理 + 产生 **497 条**子会话事件,同一 Session 续接完成;两项目独立测试各 **8 项通过**。 +- 两个真实 90 秒慢命令期间,目标 Prompt 明确结算为 + `cancelled / processTree: running`。另一会话经真实 attach 传输断开、 + controller resume 和重新附加后完成,原 Session 与 owner 均保留。 + 本轮走生产 SSH/ACP/Agent/模型桥,不是再次运行完整远程 Electron UI 或 Desktop 重启。 +- 共 **7 条 Prompt:6 completed、1 cancelled**,**36 次模型请求全部 completed + 且已交付**,逐 operation 归账。取消 Prompt 不等于模型交付不确定。释放全部 binding, + 等待 65 秒后 owner 为 0;随后停止本轮隔离 Agent,独立 `/proc` 与 owner 表核验均为 0。 +- Host 共 71 次资源快照,未出现采样错误;所属进程私有驻留页合计采样峰值 + **1,033.00 MiB**,其中 OpenCode 峰值 **771.60 MiB**。这是 Linux 私有驻留页, + 不是 Windows Private Commit;也不是 Linux 十分钟空闲或旧新版本对照。 + 退出后 semantic-prompts、model-calls、runtime-owners 数据库分别为 + **1,634,304 / 36,864 / 24,576 字节**,WAL 均为 0。 +- 本轮最终新增 **110 次模型请求:109 次文本、1 次图像**,包括本机失败和显式重跑; + 加上此前 352 次,累计 **462 次**。其中本机一次未观察到上游响应状态,不能把全部 + 110 次写成成功交付。实际开发项目测试合计 **86 项通过(Windows 70 + Linux 16)**。 +- 收尾时当前工作区 `npm run typecheck`、`npm run lint` 和差异检查通过。 + 238 个 Runtime/Agent/远程适配器文件与冻结快照的文本内容一致,原始字节差异来自 + checkout 的 CRLF/LF;Main、Preload、Renderer 入口产物哈希未变化。并行附件测试 + mock 的缺项已在当前工作区消失,不回写冻结快照,也不掩盖其当时的类型检查失败。 + 10 个相关文档的 140 个相对文件链接有效,标题锚点未独立复验。 + 本轮没有产品源码修复,未再次运行全仓 `npm test`;前文 4,204 项通过的报告 + 早于后来的并行附件/窗口改动,不能将其当成这些改动的完整回归结果。 +- 本机命名浏览器会话及所有本轮测试进程均已关闭;保留隔离工作区、数据库、生成图片、 + 失败诊断及汇总报告,没有清理正式用户历史,也没有 commit、push、tag 或发布。 + +## 2026-09-14 Runtime 资源调研与方案(未实施) + +- 按用户要求完成文档与源码调研,方案见 + [Runtime 进程复用技术设计](./runtime-process-reuse-technical-design.md),对应 + FR-RR1–FR-RR6。当前工作仅文档和本机忽略目录内的隔离探针,未修改 Runtime/Agent + 源码、停止用户 Runtime、清理用户数据或准备新发布。 +- 源码基线 `de14a6157a6c34f1e3ef22619eca374a50a83706`,保留此前活动 UI 修复。 + manager/controller、OpenCode、Continue、DeepSeek、直连、远程 ACP、Agent backend / + owner、模型桥和原生插件均已检查。此前同基线生命周期定向回归为 11 文件、 + 346 通过/1 跳过;不是本方案实施后的产品验收。 +- 已安装 0.13.2 的 09:10:12–09:15:12 五分钟只读采样:数据库约增加 2.72 MiB, + 3,367 条新增子代理事件约 1.49 MiB、平均约 465 字节,93 个工具 upsert 中未变化的 + upsert 为 0;App 相关进程 Private Commit 约 3,229–3,866 MiB,CPU 平均约为 + 16 核整机的 3.15%。后续一分钟又完成两个任务,不能作为真正空闲或泄漏结论。 +- 两个 OpenCode Server 属于当前 App,启动工作区不同。其中一个原生库 0 Session / + 0 Message、约 527 MiB Private Commit。源码确认按工作区保留实例、清单可启动执行 + Runtime、无时间型空闲回收;没有独立调用轨迹证明该空 Server 最早由清单启动。 +- 首轮两个无真实模型探针中 OpenCode 通过,DSH 驱动误用工具内部 `filePath` 而不是 + 原生 schema 的 `file_path`,断言失败。修正驱动参数后两项通过,未调整产品代码。 +- 随后完整三探针最终通过,Vitest 3 passed / 0 failed,39.20 秒: + - 生产 OpenCode launcher 单次 spawn,两个目录四个原生 Session,文件路由与单会话 + 删除后的其他会话存活通过;没有调用生产 `Runtime.run`。 + - 真实 DSH Host composition、Agent factory、文件和 PowerShell 工具,两个不同 cwd + 的 Agent 并行读写各自测试文件;生产控制面拒绝第二工作区也得到断言确认。 + 这不是 Electron Utility/ACP 生产多工作区测试。 + - 一个 Windows 原生 `opencode acp`、一条 ACP connection、两个工作区 Session, + 并行完成两个合成 Prompt;`chat.headers` 的 sessionID 与两个 HTTP 请求归属一致。 + 尚未经过 Agent backend、生产模型桥、真实工具/子代理或 Linux Host。 +- 本次研究真实模型调用 **0 次**;最终 ACP 探针向本机确定性服务发出 **2 次 HTTP + 请求**。测试服务、原生进程和专用工作区在 finally 中关闭/删除,不操作用户运行中的 + Runtime 或其临时数据库。 +- 复现命令:`npx vitest run --config .agent-resources/runtime-reuse-research-20260914/vitest.config.ts`。 + `probe.test.ts`、`opencode-result.json`、`dsh-result.json`、`acp-result.json` 保留在 + 同一忽略目录,仅为本机证据,不作为他人 checkout 后必然存在的测试资产。 +- 未实施项及生产验收见技术设计 P0–P4/V1–V8;本次未进行新的 Linux Host 验证、 + 真实模型开发任务或修复前后内存对照,不将原生能力探针描述成修复完成。 +- 文档核验覆盖 10 个改动文档、131 个相对链接和其中 44 个标题锚点,全部通过; + FR-RR1–FR-RR6 的定义唯一且有验收引用,`git diff --check` 通过。研究用 OpenCode + 二进制剩余进程数为 0。产品源码未改动,因此本轮未重跑全仓 test/typecheck/lint。 + +本页下方 schema 35 段落保留当时的失败与未发布记录。其后已随 Desktop `0.13.2` +(发布提交 `81ae9e0`)正式交付,Agent `0.11.25` 同期为维护发布;新的进程复用方案 +不包含在这两个已发布版本内。 + ## 2026-09-14 多 Runtime 工具操作摘要 显示与事件合并规则见[工具操作摘要](./runtime-interactions.md#工具操作摘要)。Continue、 diff --git a/docs/features/assistant-workbar/runtime-interactions.md b/docs/features/assistant-workbar/runtime-interactions.md index 4b94a049..6ebadba4 100644 --- a/docs/features/assistant-workbar/runtime-interactions.md +++ b/docs/features/assistant-workbar/runtime-interactions.md @@ -10,9 +10,10 @@ | DeepSeek Harness | 固定 Host 的 Execute 文件与命令工具不询问目录权限;若收到 ACP 权限请求,活跃 Execute 直接选一次性允许,缺少该选项时使用 Runtime 提供的允许选项,Ask 拒绝,不等待额外 authorizer | 固定 Host 当前未提供通用原生业务问答服务,不把普通模型正文误识别成待答协议。模型以普通回复提问时,用户通过下一条消息回答 | 托管 SSH OpenCode 的权限、原生问题事件与回复链路由 -[远程主机技术设计](../remote-host/technical-design.md)定义。本次候选的远端业务问答需要 +[远程主机技术设计](../remote-host/technical-design.md)定义。该远端业务问答基线需要 Desktop `0.13.0`、Agent `0.11.24` 和新启动的托管 Runtime;请先升级 Desktop,再更新 -Host 环境。任意第三方 ACP 服务及旧 Agent 不因此获得通用问答接口,候选不表示已经发布。 +Host 环境。2026-09-14 的已发布 Desktop `0.13.2` / Agent `0.11.25` 已包含此前问答修复, +但不包含本文末尾尚未发布的进程复用改动。任意第三方 ACP 服务及旧 Agent 不因此获得通用问答接口。 ## 运行与失败 @@ -30,10 +31,17 @@ Host 环境。任意第三方 ACP 服务及旧 Agent 不因此获得通用问答 提交失败时保留输入与重试入口,不生成已回答记录;取消未回答的问题不记为主动跳过。 - 提交响应晚于任务结束时仍保存已提交的问答,但不覆盖完成或失败状态;晚于下一轮提问时 不清除新的待答卡片。旧版会话未保存的问题与答案无法从“已提交”状态文案恢复。 -- 待答队列仅保存在当前桌面会话内存中,不写入会话快照。刷新仍在运行的消息时保留整个 +- 待答队列仅保存在当前桌面会话内存中,不写入持久会话快照。刷新仍在运行的消息时保留整个 本地队列;完成、失败、取消事件或持久化终态快照清除整个队列。迟到的提交结果可以保存 已回答记录,但不会恢复表单或覆盖终态。本机 Runtime 不恢复重启前未提交的问题;托管 SSH 重新附加同一存活 Agent Prompt 时,由 Agent 当前待答列表补发问题,已回答的历史事件不补发。 +- 未发布的进程复用源码在 Desktop 重启后由 Main 并行附加同项目的存活任务,将待答 + 列表和 Stop 所需的请求 ID 投影到实时 `activeRequest`,不保存该字段或重新发送任务。 + 回答/跳过成功由 Main 写回原远程助理消息的既有问答历史,Renderer 不覆盖远程正文或终态。 + Runtime 设置、定制和能力更新让旧请求在原配置上结束,已有问题仍可回答;新请求使用 + 新配置。专家团队例外:既有专家调度器在设置替换时取消子任务,团队父请求也随之停止, + 不使用替换后的模型汇总旧结果。详细实现见 + [恢复与终态投影](./runtime-process-reuse-technical-design.md#63-恢复与终态投影)。 - 后台任务收到结构化提问时,通过现有 Main 路径明确失败并提示改为前台对话,不永久等待。 - 请求结束或取消后清除对应待答映射;过期回答明确报错,不转交给后来的请求。 - 任意终端程序的密码、OAuth 登录、许可证确认或 stdin 提示不自动回复 `yes`。这类程序应 @@ -90,3 +98,46 @@ DeepSeek Harness 按调用 ID 保留名称、输入和摘要,结束事件省 Runtime 的重试上限、请求重放或取消策略。 实现与真实 Runtime 验证证据见[进度记录](./progress.md)。 + +## Runtime 资源生命周期 + +本节对应 [FR-RR1–FR-RR6、US-RR1–US-RR4](./prd.md#141-runtime-资源复用), +定义进程复用的行为规则。当前源码已接入本机共享 owner 和 Agent 共享 ACP; +完整验收状态以[进度记录](./progress.md#2026-09-14-runtime-进程复用实施中)为准,不表示已发布。 +具体 owner、兼容键和协议实现见[技术设计](./runtime-process-reuse-technical-design.md)。 + +资源有三个独立维度:进程是否启动/退役,会话是否仍持有原生上下文,请求是否在途。 +“任务完成”“当前没有选中该项目”“工作栏关闭”均不能直接推导进程可以销毁。 + +| 触发 | 请求与会话行为 | 进程行为 | 对应需求/场景 | +| --- | --- | --- | --- | +| 被动状态读取 | 不取得新执行会话,已有状态可读取 | 不冷启动,不延长无会话 owner 的空闲期限;不为探测替换服务中的进程 | RR2 / US-RR1 | +| 显式测试/原生清单 | 取得临时检查引用;清单带明确工作区 | 与执行共用启动 Promise 和 owner;结束只释放检查引用 | RR1、RR2 / US-RR1 | +| 首次或并行 Prompt | 取得对应 execution space + conversation 的绑定 | 同兼容组复用,非兼容组分别启动 | RR1、RR3 / US-RR1、RR2 | +| 普通请求完成 | 释放请求订阅、临时 token、问题和 callback,保留可续聊 Session | 不因为一个请求完成关闭整个进程 | RR3、RR4 / US-RR3 | +| 取消一个请求 | 取消其模型/工具调用并结算,其他会话继续 | 不以 kill 共享进程代替正常会话取消 | RR3、RR4 / US-RR2 | +| 关闭面板/切换项目 | 停止非必要 UI 推送;不取消后台任务或抹掉会话所有权 | 不触发隐式关闭 | RR4 / US-RR3 | +| 显式释放/删除会话 | 释放该 Session 与会话工具资源;删除历史仍只由原有明确删除操作决定 | 其他引用仍在则保留 | RR4 / US-RR3 | +| 无会话、无请求、无检查引用 | 不存在需要保留的原生上下文 | 进入短空闲等待,再关闭;新使用者到来取消等待 | RR4 / US-RR1、RR3 | +| 配置替换 | 已接受请求使用旧配置结算,新请求使用新配置 | 旧 owner 排空后退役;不积累只被历史 ID 持有的旧配置进程 | RR1、RR4 / US-RR4 | +| 进程故障/不可响应 | 只给该 owner 下受影响请求明确中断;未知结果不自动重放 | 有界终止失效 owner,不伪造逐会话故障隔离 | RR3、RR4 / US-RR2、RR4 | +| SSH detach | 保留 Agent 已接受 Prompt 及既有恢复归属 | 关闭传输不关闭 Agent 或活动 Runtime | RR5 / US-RR4 | + +表中 RRn 是 PRD 稳定需求 ID `FR-RRn` 的简写。 + +不变量: + +1. 一条请求始终使用接受时的 execution space、工作模式、配置和归属;不能读取后来 + 切换的 UI 选择,也不以进程 cwd 表示所有会话的 cwd。 +2. 同一会话串行,跨会话不增加全局 Prompt 队列。权限、子代理、问题、用量和最终清理 + 必须沿同一 Session/请求归属,不因共享进程放宽 Ask 或限制 Execute。 +3. owner 的退出条件依据所有使用者,不依据某个 adapter 的空闲状态。 + 未启动对象的 dispose 不产生冷启动;一个等待者取消不取消其他等待者的初始化。 +4. 正常会话仍持有原生上下文时,不用任意 TTL 清掉它来声称“空闲内存已回收”。 + 无会话 owner 的短空闲等待为 60 秒; + 不增加用户配置项。它是进程引用回收,不是聊天历史保留期限。 +5. 持久历史不因请求结束、进程 idle、配置变化或测试 cleanup 被删除。 + 原生进程确实重启后的续聊沿现有历史重建规则,不新增第二套持久会话快照。 + +已明确普通并行、取消、释放、配置替换及断线行为。各项实际通过的场景及尚未完成的 +组合/性能验证以[进度记录](./progress.md#2026-09-14-runtime-进程复用实施中)为准。 diff --git a/docs/features/assistant-workbar/runtime-process-reuse-technical-design.md b/docs/features/assistant-workbar/runtime-process-reuse-technical-design.md new file mode 100644 index 00000000..7a869526 --- /dev/null +++ b/docs/features/assistant-workbar/runtime-process-reuse-technical-design.md @@ -0,0 +1,386 @@ +# Runtime 进程复用技术设计 + +## 1. 状态与结论 + +| 项目 | 内容 | +| --- | --- | +| 状态 | 已接入本机与 Agent 生产源码,Windows / Linux x64 验证结果及边界已记录,未发布 | +| 日期 | 2026-09-15 | +| 调研源码 | `de14a6157a6c34f1e3ef22619eca374a50a83706`;相关 Runtime 与 Desktop 0.13.2 一致 | +| 需求 | [工作栏 PRD §14.1,FR-RR1–FR-RR6](./prd.md#141-runtime-资源复用) | +| 行为规则 | [Runtime 资源生命周期](./runtime-interactions.md#runtime-资源生命周期) | +| 验证事实 | [实施与验证记录](./progress.md#2026-09-14-runtime-进程复用实施中),原始调研记录保留在同页 | + +**修复对象是进程所有权过细,不是工具太多,也不是历史记录应该删除。** 本机 OpenCode +和 DeepSeek Harness 应在兼容启动配置内共享重型进程,工作区与会话上下文仍各自独立。 +远端 OpenCode 必须同时调整 Agent 进程所有权、ACP 连接和模型桥,不能把桌面修复当作远端 +修复。Continue 的锁定版本是单会话 Host,不做未经验证的常驻进程池;直连模型没有独立 +模型进程,应检查对象、历史和工具资源的生命周期。 + +“一个进程”指一组兼容启动配置对应一个 Runtime Server/Host,不是把不同 Runtime、 +不同 SSH 账号、不同插件组合强行塞入一个进程。正常工具命令产生的短期子进程不属于重复 +Server,也不能通过关闭 Shell、子代理或 MCP 来达成进程数指标。 + +## 2. 修复前证据与缺陷 + +### 2.1 已测现象和解释边界 + +Windows 已安装 0.13.2 的只读采样发现两个属于同一 App 的 OpenCode Server:一个原生 +数据库为 0 Session / 0 Message,约 527 MiB Private Commit;另一个已有会话并执行请求。 +两个 Server 的启动工作区不同。源码能解释“清单查询也可启动按工作区缓存的 Runtime”, +但没有取得能独立证明空 Server 最初由哪一个 UI 操作启动的调用链记录。 + +该缺陷不等于整个 App 内存的唯一来源。五分钟活跃采样的 App 相关进程 Private Commit +约 3.15–3.78 GiB,CPU 平均占整机约 3.15%;后续一分钟包含新任务,不是空闲回收试验。 +数据库五分钟增长约 2.72 MiB,新增子代理负载约 1.49 MiB,重复工具 upsert 为 0。 +schema 35 的存储修复与本方案独立,不能回退或删历史来改善指标。 + +同次源数据检查还发现最近 100 个会话的消息 metadata 合计约 107.75 MiB, +`listConversations()` 会读入这些会话的全部消息详情。它是 Renderer 大对象加载的明确 +检查方向,不是堆快照已经证明的泄漏。进程复用不能代替历史按需加载的独立性能工作。 + +### 2.2 调研基线的源码归属 + +| 位置 | 已确认行为 | 修复含义 | +| --- | --- | --- | +| [SelectedRuntimeManager](../../../src/main/agent/selected-runtime-manager.ts) | 缓存键包含原始 selection 与 execution-space identity;默认 8 个缓存、16 个保留实例;只在容量压力下淘汰 | 轻量工作区适配器和重型进程不能继续按同一键、同一数量计费 | +| [AgentRuntimeController](../../../src/main/agent/runtime-controller.ts) | `canRetire` 同时要求无请求、无 owned conversation;普通完成不移除会话所有权 | 不能把“已完成请求”和“已释放原生会话”混为一谈 | +| [本机 OpenCode](../../../src/main/agent/opencode-runtime.ts) | 对象私有 Server/client/初始化 Promise;已有多会话映射;多个入口使用固定 `defaultWorkspace` | 仅删除 manager 的 workspace 键会把后续操作送进错误目录 | +| [DeepSeek Runtime](../../../src/main/agent/deepseek-harness-runtime.ts) | 每对象一个 Utility Host;`getStatus()` 调用 `getState()` 启动 Host | manager 的临时状态实例会额外启动、探测并销毁 Host | +| [Harness Host](../../../src/main/deepseek-harness-host.ts)、[控制面](../../../src/main/agent/goodbuddy-harness-control-plane.ts) | 根 Provider 有 cwd 默认值;控制面拒绝与 Host workspace 不同的 `newSession.cwd` | 限制主要在 GoodBuddy 创建入口,不是原生工具缺少会话 cwd | +| [Continue Host Adapter](../../../src/main/agent/continue-host-adapter.ts) | 每次 run 启动临时 Host,最终 `/exit` 与进程树回收;准备结果已在 adapter 内缓存 | 不重复添加已有缓存;研究跨 adapter 重复准备,不直接共享活动 Host | +| [直连模型](../../../src/main/agent/model-runtime.ts) | Main 内 HTTP;历史 Map 按 50 个会话限制,其他归属及工具资源依赖 release/dispose | 没有可合并的常驻模型子进程,按对象和资源检查 | +| [远端 ACP](../../../src/main/agent/acp-remote-runtime.ts)、[Agent backend](../../../src/agent-daemon/runtime-acp-backend.ts) | 会话取得 binding/channel;backend 按 binding 拥有进程并拒绝跨 binding 复用 process identity | 必须拆开会话绑定与进程所有权 | +| [Agent-owned Prompt](../../../src/agent-daemon/agent-owned-acp-prompt.ts)、[模型桥](../../../src/agent-daemon/model-bridge-helper.ts) | owner 一个 ACP connection/session/当前 operation;helper 固定模型和工作模式并串行 dispatch | 共享进程还要求会话路由、请求归属和取消隔离 | + +原始 selection 的 `auto`、默认 profile 和显式 profile 可能解析为同一有效配置。当前 +已确认“先按原始 selection 建键、后解析配置”的顺序,但尚未证明全部 UI 调用会实际生成 +等价重复实例。实施时从 [runtime-selection](../../../src/main/agent/runtime-selection.ts) +和调用者验证,不把可能性记为另一次生产复现。 + +### 2.3 锁定依赖与协议基础 + +以仓库 lock 和本机已安装代码为准,不用最新网页替代锁定版本: + +| 依赖 | 基线 | 本次核验 | +| --- | --- | --- | +| OpenCode binary / SDK / plugin | 1.18.29 | HTTP API 接受 directory;ACP 接受多个 Session;`chat.headers` 有 sessionID | +| ACP SDK | 0.25.1 | 一条连接关联多个 request ID,session notification/permission 带 sessionId | +| DeepSeek Harness 包族 | 0.1.2-rc.1 | Agent factory/scoped context、Session header.cwd、真实文件与 Shell 工具 | +| Cordis | 4.0.2 | Host 根组件与 Agent scope 的生命周期 | +| Continue CLI | 1.5.47 | 实际受管补丁入口的单 `M.session`、QuizService 和权限状态 | + +已通过的三个隔离探针: + +1. **OpenCode HTTP**:调用当前生产 Server launcher,只有一次 spawn;同一个 SDK client + 在两个目录创建四个 Session,读取各自的同名文件;删除一个 Session 后其余三个仍可取回。 + 没有调用 `Runtime.run`、模型或活动取消。 +2. **DeepSeek 工具**:当前 Host composition 和真实 Agent factory 在同一 Node 测试进程中 + 创建两个不同 cwd 的原生 Agent;并行执行真正的 `read`、`write` 和 PowerShell, + 同名相对路径各自命中正确目录。另确认生产控制面仍拒绝第二工作区。没有启动 Electron + Utility、ACP Prompt 或模型流,不能据此声称生产多工作区已经可用。 +3. **原生 ACP 与模型归属**:Windows 上一个 1.18.29 `opencode acp` 进程、一条 ACP + connection、两个工作区 Session;分别选模型并发出并行 Prompt。本机确定性 HTTP + 服务收到两次请求,原生 `chat.headers` 插件传来的 sessionID 分别匹配两个 Session。 + 不经过已发布 Agent backend/helper,没有测试真实工具、子代理、Linux 或断线恢复。 + +三个探针真实模型调用均为 **0**,其中第三个有 **2 次本机合成提供方请求**。 +它们证明方案有原生基础,不替代后文的真实开发任务验收。 + +特别纠正:`dsh-tool-fs` 将 `exec.agent.session.header.cwd` 传给 `fs.resolve`; +`dsh-tool-bash` / `dsh-tool-pwsh` 也从 Session cwd 补全相对 workdir。 +`dsh-fs-local` / 本地 Shell 的根 cwd 是非会话调用的默认值,不是工作区隔离边界。 +不需要为这个已具备的能力新建一套“每工作区 Filesystem/Shell Provider”。 + +官方背景资料:[OpenCode Server](https://opencode.ai/docs/server/)、 +[DeepSeek Harness](https://www.deepseek.com/harness/en/)、 +[Continue CLI](https://docs.continue.dev/cli/quickstart)。 +网页只用于核对产品入口;上述版本结论来自本地锁定代码和探针。 + +## 3. 选定的结构 + +### 3.1 两层对象,不建立通用进程池框架 + +```text +SelectedRuntimeManager + -> 轻量 Runtime adapter(有效 selection + execution space) + -> OpenCode process owner / Harness Host owner(兼容启动配置) + -> Session binding(execution space + conversationId) + -> 当前 requestId / 原生 sessionId / 工作目录 +``` + +- 保留 Main 已解析、校验的 execution space;adapter 每次将其显式交给共享 owner。 + 目录不是 Renderer 新增的任意启动参数,也不依赖 `process.chdir` 或进程级可变 cwd。 +- owner 持有唯一启动 Promise、client/connection、子进程、会话映射与关闭逻辑。两个 + 工作区同时冷启动时复用同一 Promise;一个调用者取消等待不取消其他使用者的启动。 +- adapter 的 `dispose` 只释放其引用,不直接关闭同组进程。会话映射由 owner 持有,避免 + adapter 缓存淘汰丢掉原生续接。会话操作通过 binding 查目录,不从“当前选中项目”反查。 +- 设置重建沿用现有 reset/drain:新请求取得新配置,旧请求持有启动时的配置直到结算。 + 旧配置不再有在途使用者后释放旧绑定和进程,不能被历史会话 ID 永久钉住。 + IPC 的 Runtime 设置、定制及能力更新不再主动取消全部请求或清空审批;controller + 退役仍允许回答其已拥有的问题,但拒绝新请求和未知问题。 + 既有专家调度器仍在配置替换时取消专家子任务,团队父请求一起停止,防止用新模型 + 汇总旧 generation 的部分结果;不为该单独生命周期新建通用退役框架。 +- 8/16 的旧上限不应继续限制轻量项目 adapter 数量。若保留重型 owner 的容量保护, + 应统计实际进程配置组及退役进程;不靠提高常数掩盖“一项目一进程”。 +- 只用进程内 Map、现有 controller、Promise 和普通定时器。会话历史仍以现有 SQLite / + Agent transcript 为准,不增加数据库表、迁移、持久化池快照、恢复日志或新配额。 + +### 3.2 兼容键与上下文 + +先通过 `applyRuntimeSelection` 等现有解析取得有效配置,再生成进程键;公开的 selection +仍按现有语义保存,不能把“跟随默认”永久改写成某个显式 profile。 + +| 范围 | 参与进程兼容性 | 留在会话/请求 | +| --- | --- | --- | +| 本机 OpenCode | 本机账号、二进制身份、有效模型连接/配置、启动环境、原生配置和插件组合 | 目录、conversation/session、Prompt、模式、当前权限和问答、请求级 MCP/知识 token | +| 本机 Harness | Host/依赖版本、有效模型连接、图片能力、凭据引用、启动环境、启用插件及配置、启动 Skill 集合 | cwd、conversation/session、请求模式、Main Web/MCP 代理与用量归属 | +| SSH OpenCode | 已有 Host/SSH 身份、Agent/Runtime installation identity、模型协议/连接、原生插件与启动配置 | Workspace、binding/session、operation、请求凭据和账本归属 | + +第一轮只合并同一有效模型连接及等价默认选择,不顺便支持任意不同模型连接共享: +本机 OpenCode 配置使用固定 provider ID,Harness 用量也读取 Host config,跨 profile +混用会引入另一项真实风险。不同 profile ID 不通过比较或序列化明文 API Key 来合并; +密钥、Header 和插件配置更新通过现有设置失效路径使旧 owner 退役。 + +调研基线的 SSH launch profile 把 workMode 写入原生启动配置。当前实现把工作模式 +放入 Session/当前 Prompt 路由,并设置原生 Session 工具规则,详见 §6.1; +不能仅从兼容键中删除 workMode,也不向用户增加逐工具审批。 + +## 4. 本机 OpenCode + +在 [opencode-runtime.ts](../../../src/main/agent/opencode-runtime.ts) 中抽出重型 owner, +由 [create-runtime](../../../src/main/agent/create-runtime.ts) 的 Main composition 复用。 +保留工作区 adapter,逐项替换隐式 `options.defaultWorkspace`: + +1. create/get/delete Session、Prompt、历史初始化和压缩。 +2. 原生能力、工具、文件、Skill、MCP 查询的 directory。 +3. SSE 订阅、父子 Session 归属、待答 question 的 directory 与原生 ID。 +4. 权限更新、abort、问题答复/拒绝、MCP 临时注册及最终清理。 + +会话绑定记录规范化绝对目录及原生 sessionId。同一 conversation 的请求继续串行, +不同 conversation 并行,不引入覆盖整个 Server 的 Prompt 队列。 +现有 `snapshot: false` 必须保留。 + +`mcpMutationTail` 目前属于 Runtime 对象;共享后同一原生目录的变更必须由同一 owner +串行协调。注册名称、工具可见性、knowledge token 和清理仍按请求区分,不能用一个全局 +“当前 MCP 列表”覆盖同目录或跨目录的其他请求。探针未覆盖动态 MCP,这是实施验收项。 + +第一轮保留现有按请求消费事件流和结束清理的方式,扩展目录及 Session 过滤即可。 +只有测出重复 SSE 消费是瓶颈时才另做 owner 级事件分发,不同时引入第二套事件总线。 +健康检查也读取 owner 的总 activeRequests,不能从一个空闲 adapter 判断整个 Server +无任务后重启。异常退出使该 owner 的全部绑定失效,明确结算受影响请求,不重放已执行工具。 + +## 5. DeepSeek Harness + +### 5.1 共享 Host 的最小改法 + +- 将 `HarnessState`、Utility 启动 Promise、ACP connection 和事件路由提升到兼容配置 + owner;Runtime adapter 保留请求工作区。 +- 每个新 Session 在 Main 解析 `realpath`,Host 校验绝对路径格式, + 不再要求它等于 Host 启动工作区;`meta.cwd` 传入已有 Agent factory。 + 不依赖 Windows 大小写字符串猜测,也不取消路径存在性校验。 +- 保留一个本地 Filesystem、Subprocess 和平台 Shell Provider。Session 使用原生 cwd + 路由;非 Session 的清单查询显式传入查询工作区,不能回退到第一个项目。 +- 保留 `setup(agentCtx)` 注册 Skill 和工具视图、每 Session 的 Ask definition 身份校验、 + `sessionId + requestId` 准备及事件路由。Main 的 Web/MCP callback 按对应请求找 + ToolProvider,不挂到某个最先创建的 adapter 上。 +- 第一轮保持同 profile、图片能力和插件组合共享,`queueUsage` 的模型配置因此仍一致。 + 给用量加跨会话断言,不借本次改造扩大跨模型 Host 能力。 + +第三方插件的初始化可能依赖配置中的路径,不能因为内置工具支持 cwd 就声称所有插件 +天然多工作区安全。已启用插件组合属于进程配置;对实际产品支持的插件验证它使用 +Agent scope/Session cwd。若某插件确实有项目级初始化状态,将该状态显式作为不兼容 +启动配置,而不是默认给所有工作区各起一套 Provider 或禁止 Execute 工具。 + +### 5.2 状态与取消 + +被动 `getStatus` 改为已有 owner 快照或本地配置/Host 资源检查,不调用 `getState()`。 +没有启动过时不能写“握手已验证”,可表示资源已安装、尚未连接。 +状态检查不取得执行引用,也不清除或重置无会话 owner 的空闲计时;owner 已回收时, +临时配置检查对象用完即释放,不将其登记成新的执行 owner。 +用户显式连接测试与原生清单可启动共享 owner;已有 owner 时只做有界控制检查,最终 +释放的是检查引用,不能在 `finally` dispose 掉仍服务其他会话的 Host。 + +当前取消超时会 terminate 整个 Utility。共享后先取消并释放目标 Session 的 Agent / +工具资源,不因一个请求结算较慢就杀掉仍健康服务其他 Session 的 Host。若真实控制面 +已失去响应,保留整个 Host 的有界终止,向受影响会话分别报告中断;不得把它说成 +“取消完全不影响其他任务”。同步插件卡死的进程级故障无法在同一 JS 进程中强行隔离, +不为此新增通用恢复框架。普通长工具取消与整 Host 故障必须分开测试。 + +## 6. SSH OpenCode 与 Agent + +本节定义当前源码的共享进程改造;当前已发布版本仍见 +[远程技术设计](../remote-host/technical-design.md)。不能删除或绕过其持久 binding、 +operation、transcript、ACK 和模型 dispatch 结果不确定性规则。 + +### 6.1 连接与所有权 + +将 `RuntimeAcpBackend` 的进程 owner 与 binding 分开: + +```text +Agent 当前 Runtime installation + 兼容配置 + -> 一个 process owner / 一条 ACP ClientSideConnection + -> sessionId -> binding -> 当前 operation / 原始 Prompt Promise +``` + +- 一条 stdio 流只交给一套 ACP decoder/connection,不能让几个 ClientSideConnection + 竞争读取同一 stdout。消息和 permission 按 sessionId 分派给 binding 的当前 operation。 +- `AgentAcpConnection` 统一连接级握手、stdio 解码与退出监听,Prompt + 生命周期仍留在 binding。同一 Session 串行,其他 Session 不等待它完成。 +- `closeAcpChannel`、取消和 release 只释放所属 binding/Session;最后一个 binding + 离开后空闲 60 秒关闭进程。关闭 Session 时等待原生 `closeSession` 和原始 Prompt + 结算,不等待排在 backend 控制队列后面的完成回调。只有原生控制面不响应时才有界 + 终止共享进程,并向其余请求报告中断,不声称这类故障可以逐 Session 隔离。 +- terminal 与物理进程状态分开:取消/失败的 Session 也可返回 + `processTree: running`,表示进程继续服务其他 Session,不能伪报进程树为空。 + Desktop 与 Agent 须使用配套的新契约;原始字节透传 binding 仍保留独占进程规则。 +- 共享 stdio owner 不使用单 Prompt 的累积输入和墙钟期限管理整进程;单次写入、 + 待写队列、输出背压继续有界,Prompt 输入与截止时间由 binding 管理。 +- SSH attach 仍不拥有活动 Prompt,断线不能取消或重发 Agent 已接受的工作。 + 历史 binding 的语义和原生 sessionId 保留;内存进程索引从当前安装和已有绑定得出, + 不增加第二份持久进程恢复账本。 +- 同一 PID 的多个 binding 不代表同一权限或同一 operation。Host/installation identity + 变化仍使用已有失效和退役规则,不把不同 Host、账号或 Runtime generation 合并。 + +原生 `chat.message` 在模型请求前设置该 Session 的工具规则:Execute 允许工具; +Ask 只开放原生只读、搜索和读取网络内容的工具,不开放 Shell/编辑/写入。 +OpenCode 1.18.29 的 ACP 不转发未注册子 Session 的权限请求,且原生子代理只继承父 +Session 的拒绝规则、不继承允许规则,因此子 Session 也按根 Prompt 设置规则,再 +保留原生子代理的显式限制。不能只把进程启动配置统一设成 `ask`,否则子代理工具会等待。 + +### 6.2 模型桥归属 + +选用锁定 OpenCode 已有的 **`chat.headers` 会话标记**,扩展当前受管 +[原生插件](../../../src/agent-daemon/opencode-subagent-plugin.ts),不靠解析正文、猜测 +“当前最新 Prompt”或重写用户模型名来路由: + +1. 现有 Agent prepare/start 顺序在 helper 的进程内登记 Session/根 Session 与 + binding、operation;插件从原生 sessionID 及已建立的父子关系取得精确归属。 +2. 在请求生成时固定所属 operation,将有界内部归属 Header 加到 loopback 模型请求。 + 归属必须覆盖原生子代理、自动/手动压缩,不能等 HTTP 到达后才绑定“目前活动的请求”。 +3. helper 检查它是否仍属于已登记的 Prompt,选择对应现有 Agent gateway/凭据上下文。 + 转发真实提供方前剥离内部 Header;不将其当作密钥或新增认证体系。 +4. 当前 helper 的单 socket/全局 dispatchBusy 改成按 operation 隔离的派发状态; + 每个 operation 的现有轮次、顺序、字节和结果不确定性规则不变,取消 A 只关闭 A 的 + 提供方流,不关闭 B 的 socket、response 或待处理请求。 +5. Prompt 终态撤销对应映射及凭据;迟到请求不能借用下一轮 operation。保留 + `snapshot: false`、关闭不消费的 title 请求和现有父子问题转交。 + +普通 ACP 双 Session 的 Header 路由已有探针证据;子代理、压缩和终态竞态的实际 +验证结果以进度记录为准。若锁定版本某条实际调用路径不触发 hook,先定位那条原生路径, +不得悄悄用全局串行 Prompt 或一个共享凭据槽冒充并行复用。 +内部桥字段如需调整,按既有受管 Agent/Runtime 版本配套交付,不移动发布标签,不给 +旧 Agent 宣称具备新行为。 + +### 6.3 恢复与终态投影 + +- Agent-owned 语义 Prompt 不一定产生原始 ACP journal 行。binary replay 仍校验现有 + cursor 边界,但不存在 raw cursor 时不向 journal ACK 0;不虚建记录来满足 replay。 +- 同一 Agent 断线恢复后,保留的 Session binding 使用新 transport generation, + Runtime 可以同时接纳新 Session。安装身份已经更换的 attach-only 请求明确失败, + 不能无限重试阻塞项目,也不能借此重发旧 Prompt。 +- Desktop 重启时,同项目的可恢复任务并行附加,不能等待一个待答 Prompt 结束才附加 + 第二个。live `question`/`question-resolved` 不属于有 provenance 的 transcript, + 使用既有 pending Map,清除时只影响对应 request/question。 +- `conversations.list` 将恢复租约的 request/message ID 和待答列表投影为可选 + `activeRequest`,Renderer 合并表单并使 Stop 指向真实请求。保存 header/消息时不保存 + 这个实时字段。回答/跳过成功由 Main 按 task → assistant message 归属更新既有 + `answeredQuestions`,不写另一份问题日志,不覆盖流式正文、已完成或失败状态。 +- 明确确认的取消使用现有消息/任务终态事务一并结算,避免 Task cancelled 而消息仍 + streaming。仍无法确认的 Stop 保持可恢复;连接中断本身不是已取消或已失败的证据。 + +## 7. Continue、直连模型与相邻内存问题 + +### 7.1 Continue + +保留每请求 Host 的隔离和最终清理。1.5.47 的单 Session、QuizService/权限队列意味着 +“一个 HTTP Server”不等于可安全承载多会话;这一例外有锁定源码依据。 + +`getPreparedHost()` 已缓存本 adapter 的准备 Promise,磁盘也有按内容确认的补丁产物。 +先分别测量读取/哈希/补丁准备、Utility 启动、HTTP ready、首个提供方请求和退出耗时。 +若确认跨 adapter 重复准备明显,再将只读准备结果与启动 Promise 按已验证的 CLI +identity/补丁版本放到 Main composition 复用;失败仍允许重试。不得复用临时 config、 +token、global dir、活动 Session 或权限/问题队列。设置替换及窗口状态刷新也纳入测量。 + +不在本次给 Continue 加多 Session 补丁或常驻池。将来只有锁定版本真正支持多 Session +且生产桥验证通过,才替换这一实现。 + +### 7.2 直连模型及历史详情 + +- 不新增模型 Utility 或进程池。检查 `conversations`、`knownConversationIds`、 + summary、ToolProvider 与 DirectModelProcessService 的实际引用与 release 调用链。 +- 活动请求、会话可续读的有界输出和数据库历史是不同资源。请求结束回收临时闭包/ + callback;会话释放才结束其可续读输出和工具资源,不在工具刚完成时删掉用户可查看结果。 +- 当前 50 个会话的 history 限制不是字节上限。先采堆与 retained size,再决定可从 + SQLite 重建的历史缓存是否使用字节约束和 LRU,不能先增加一套持久历史副本。 +- Renderer 最近 100 个会话整包 metadata 加载另做列表摘要、当前会话分页、展开后取 + 详情的性能改造;本方案只记录依赖,不将这一尚未设计完成的 UI 改造算进进程修复交付。 + 必须保持完整历史、搜索、重新打开和当前活动事件可用。 +- 旧 Runtime 磁盘缓存可单独评估版本清理策略,不在本次研究或进程 idle 时删除安装、 + 用户工作区、数据库、备份或历史。 + +## 8. 内部实施顺序 + +| 阶段 | 具体工作 | 必须产出的证据 | +| --- | --- | --- | +| P0 | 固化本次原生探针为聚焦回归;补有效 selection/进程计数和状态启动计数 | 修复前能复现重复启动,失败原因不依赖真实用户配置 | +| P1 | Main composition、兼容键、owner 与 adapter 生命周期;本机 OpenCode 全入口目录化 | 真实 UI 两项目四会话同一 Server,文件/工具/问答/取消不串线 | +| P2 | DeepSeek 共享 Utility、会话 cwd、被动状态、请求级代理及取消收敛 | 实际 Utility/ACP 两项目四会话,原生工具与插件验证,状态刷新无额外 Host | +| P3 | Agent process/session owner、ACP 单连接路由、Header 模型桥、权限和终态清理 | 开发中的真实 Linux x64 Host,含多会话工具、断线/重连和结果不确定性 | +| P4 | 全 Runtime 资源回归;Continue 冷启动分解与有证据的准备复用;直连对象释放检查 | 相同输入的前后进程/内存/CPU/磁盘/延迟报告;没有残留测试进程 | + +这是依赖顺序,不是发布一半实现的许可。涉及的正常生产 UI 路径、远端专用修复和下节 +验收全部完成后,才能称为 Runtime 复用修复。Continue 测量若没有显示重复准备成本, +明确记录不改;不要为凑齐阶段增加代码。历史 UI 分页是后续独立工作。 + +## 9. 验收与性能比较 + +### 9.1 必测场景 + +| 编号 | 对应需求 | 场景与通过条件 | +| --- | --- | --- | +| V1 | FR-RR1、FR-RR2 | 全新 profile 仅查看状态、切换设置,OpenCode/DSH 重型启动次数为 0;已有 Host 时刷新不新建;清单与首个真实请求并发只有一次初始化 | +| V2 | FR-RR1、FR-RR3 | 同一有效模型连接,两个独立项目各两会话同时自然开发,OpenCode/DSH 各一重型进程;各项目同名不同内容文件、命令 cwd、Git/测试结果都正确 | +| V3 | FR-RR3 | 第二轮复用同一批会话;Ask/Execute 混合,Ask 写入被边界拒绝,Execute 不增审批;Skill、MCP、图片能力、用量和业务问答不串归属 | +| V4 | FR-RR3、FR-RR4 | 一会话取消真实长工具/子代理时另外三会话完成并通过独立项目测试;分别检查请求 abort、SSE、问题映射和工具句柄释放 | +| V5 | FR-RR4 | 关闭面板不取消工作;释放一个会话不关闭共享进程;无会话 owner 空闲后退出;App 退出后本机受管进程无残留,远端按已有 detach 规则保留 Agent-owned 工作 | +| V6 | FR-RR1、FR-RR4 | 等价默认/显式选择复用;不同实际连接/插件配置不串用;配置更新期间旧请求完成,旧 generation 不永久积累;超过八个项目不因轻量 adapter 报重型容量满 | +| V7 | FR-RR5 | 当前源码 Desktop → SSH → 当前开发 Agent → 原生 Runtime,两个项目/四会话;断线、重连、取消、重复 ACK/事件、迟到模型请求不重复调用或跨 operation 归账 | +| V8 | FR-RR6 | Continue 并发请求仍各自正确清理;直连 HTTP 不生出额外模型进程;会话 release 后工具/分页输出资源释放;性能采样不靠删除历史或禁用工具达标 | + +V2/V4 必须是正常开发任务,模型可自然调用读写、Shell、测试和已支持的原生子代理; +不是要求模型“不用工具”后回复一行。验证产出应用本身可运行、独立测试通过,模型口头 +说完成不算完成。每个 Runtime 只验证它已经支持的能力,不伪造 DSH/Continue 子代理。 + +### 9.2 采样口径 + +- 使用同一源码变更前后、同一机器、隔离 profile 与等规模历史副本;不改写用户运行库。 + 覆盖至少三次冷启动、五次热请求,以及三轮多项目任务和至少十分钟**确无新任务**的 + 空闲观察。分别记录中位数、范围与实际样本数,不从一次采样推断泄漏已消失。 +- 记录 Main、Renderer、Runtime 和工具子进程的 Private Commit、工作集/RSS、堆、 + CPU、PID/启动次数、会话数、活动请求、SSE/ACP 订阅与残留句柄。Windows 工作集求和 + 会重复计算共享页,Private Commit 也不等于独占物理 RAM。 +- 同时报首轮准备、HTTP/ACP ready、首个模型请求、首次可见输出和总耗时。降低进程数 + 但把不同会话全局串行化,不能通过验收。 +- 数据库/WAL 字节与新增事件负载分开;进程 I/O 包含管道,不能称为物理磁盘读写。 + 继续检查未变化工具 upsert 为 0,旧历史读取/搜索/展开正常。 +- 核心硬指标是每个兼容组一重型进程、无额外被动启动、无跨会话错误、无结束后持续 + 增加的活动句柄、无重复提供方 dispatch。没有证据前不承诺“固定省 527 MiB”或 + “整个 App 降到某个 RAM 上限”;原生 Session 仍保留时必须单独说明其常驻成本。 + +### 9.3 源码与平台验证 + +定向覆盖 manager/controller、各 Runtime lifecycle、Harness Host/control plane、 +ACP backend/owner、模型桥与插件,随后执行仓库要求的 `npm test`、`npm run typecheck`、 +`npm run lint`;生产构建改动在开发阶段按仓库规则构建验证。 + +凡改变 Agent 或桌面到 Agent 路径,必须在开发期间按 +[真实 Host 验证规则](../remote-host/technical-design.md#agent-开发期间的真实-host-验证) +使用当前源码的共享 Linux x64 Host 验证。Windows 原生 ACP 探针、旧版本 Host、mock、 +CI 和将来发布测试都不能替代它。Windows PowerShell 与 Linux Bash 的工作目录及取消 +分别验证;macOS/Linux arm64 生命周期与原生依赖由可用原生平台和 CI 补齐并明确报告。 + +当前源码的真实 Linux Host、完整生产 UI 和最终整仓门禁结果均保留在进度记录; +各次失败、修正、模型调用数及未测平台也一并记录,不以本机单测或原生协议探针 +替代完整产品验收。完整 App 性能样本保留用户要求的按钮位置调整,属于包含该 UI +差异的实测对照,不是纯 Runtime 单变量实验。 diff --git a/docs/features/conversation-media-generation/README.md b/docs/features/conversation-media-generation/README.md new file mode 100644 index 00000000..6c8cf429 --- /dev/null +++ b/docs/features/conversation-media-generation/README.md @@ -0,0 +1,35 @@ +# 会话媒体生成 + +状态:设计草案,尚未实现。日期:2026-09-15。 + +用户添加生图或生视频模型后,可以开启“允许 AI 在会话中调用”。支持工具调用的聊天模型通过 GoodBuddy 使用这些模型,图片和视频返回当前会话。切换聊天模型或 Agent Runtime 时,生成模型配置、已提交的生成操作和历史成果各自保持明确的归属。 + +本目录负责普通 LLM 调用媒体模型的产品行为、调用入口和生命周期。现有直连图片协议及连续修改仍由[图片生成](../image-generation/README.md)维护;本设计不表示现有功能已改变。 + +## 首期范围 + +首期以图片为主:在直连文本 Model Runtime 中调用已有生图协议,支持文生图及协议已有的参考图编辑。提供模型调用开关、全局默认和本次指定模型,复用图片成果展示。切换聊天模型或离开会话后,已提交生成继续执行,结果保存到原会话。 + +视频、其他 Runtime 的自动调用、会话级生成模型选择、独立生成表单及跨重启查询恢复留到后续。首期只需 `generate_image` 核心工具,Skill 按实际使用需要补充。需求的阶段划分以[产品需求](./prd.md)为准;下文各设计保留的后续规则不作为首期实现要求。 + +## 文档导航 + +| 文档 | 权威职责 | +| --- | --- | +| [产品需求](./prd.md) | 范围、需求编号与产品验收 | +| [用户场景](./user-stories.md) | 用户操作及场景验收 | +| [行为规则](./logic-design.md) | 选择优先级、切换规则、状态和失败处理 | +| [界面设计](./ui-design.md) | 配置入口、会话操作、结果卡片和反馈 | +| [技术设计](./technical-design.md) | Skill、工具、Main 服务、存储与 Runtime 接入 | +| [进度记录](./progress.md) | 当前代码事实、剩余工作与验证证据 | + +## 术语与依赖 + +聊天模型负责理解请求、调用工具和组织回答;生成模型负责图片或视频输出;Agent Runtime 承载聊天模型及工具执行。Skill 提供能力使用说明,工具提供结构化调用入口,两者不能互相替代。 + +“生成操作”指一次媒体生成及其结果记录,不自动创建任务中心的 Task,也不引入新的通用 Job 调度系统。领域边界遵循 [Task 与 Job 模型](../task-and-job/task-and-job-model.md)。 + +- [模型连接](../model-connections/README.md):连接、凭据、协议及请求定制。 +- [直连文本 Agent](../direct-model-agent/README.md):文本工具循环与上下文。 +- [远程主机](../remote-host/README.md):远程 Runtime 与桌面通信。 +- [统一界面规则](../../../UI-DESIGN.md):控件、反馈和可访问性。 diff --git a/docs/features/conversation-media-generation/logic-design.md b/docs/features/conversation-media-generation/logic-design.md new file mode 100644 index 00000000..4ac69205 --- /dev/null +++ b/docs/features/conversation-media-generation/logic-design.md @@ -0,0 +1,77 @@ +# 行为规则 + +状态:设计草案。此文是模型选择、切换与生成状态的权威定义。 + +## 首期适用规则 + +阶段范围以[产品需求](./prd.md)为准。以下 L-1 至 L-5 保留完整目标,首期按这些限定执行: + +- L-1:只在直连文本 Model Runtime 暴露图片工具;未接入的 Runtime 或无工具模型显示限制,沿用已有直连图片工作流,不新增显式生成入口。 +- L-2:新建图片按“本次明确指定 > 全局默认”选择;编辑按“本次明确指定 > 原成果模型 > 全局默认”选择。只有前一项未设置时才查下一项;选定模型失效时提示改选。 +- L-3:提交时固定生成模型、连接参数、素材和原会话。首期没有会话级生成选择;新请求在实际提交时解析默认值。切换到未接入的 Runtime 只影响后续自动调用,不取消已提交操作。 +- L-4:对图片请求复用进行中、已完成、失败和已停止等待的状态表达,保存成功后才完成,不预建视频任务状态机。取消后若仍取得成果,更新原卡片并说明取消未生效。重启后未完成记录显示结果未确认,不查询恢复或自动重提。人工重新生成创建新操作;首期不提供独立保存重试。 +- L-5:图片成果引用、无视觉模型的文字上下文及会话归属规则全部适用。 + +## 能力与调用时点 + +L-1(FR-1、FR-3、FR-7、FR-9;US-01、US-08、US-11):会话自动生成需要模型连接允许调用、对应协议可执行、Runtime 能暴露工具、聊天模型支持工具调用,并且请求处于 Execute。Skill 只描述这些真实能力。无工具能力时,显式生成入口直接调用同一服务;Ask 下生成入口不能绕过只读限制。 + +请求开始和聊天模型、Runtime、配置切换后重新计算可用能力。运行中的聊天回合保留原有工具协议,Main 在真正提交生成前检查最新可用性。这样已关闭的模型即使残留在该回合的工具列表中,也不能接受新操作。 + +## 模型和素材选择 + +L-2(FR-2、FR-5;US-02、US-03、US-05、US-07、US-14):先确定生成类型和操作意图,再按下表选模型。图片与视频的默认值独立保存。 + +| 操作 | 选择优先级,由高到低 | +| --- | --- | +| 新建图片或视频 | 用户本次明确指定 > 当前会话明确选择 > 全局默认 | +| 编辑历史成果 | 用户本次明确指定 > 当前会话明确选择 > 原成果模型 > 全局默认 | + +只有较高优先级未设置时才查看下一项。已选目标被删除、关闭或不支持所需操作时,要求改选,不能静默跳过。没有默认值时由用户选择,可以同时设为默认;名称歧义必须解析到唯一连接后再提交。LLM 不得无依据地填写模型 ID。 + +显式附件或明确点选的成果优先。用户使用“刚才那张”等明确指代且仅有一个匹配对象时才引用历史图;多义指代先澄清。新建不相关图片不附带旧图。图生视频复用素材引用,但按视频模型选择顺序处理,不沿用图片模型。 + +编辑能力不足或原图缺失时保留输入并提示。新工具路径不自动将编辑降为纯文生图;用户可以明确选择按文字重新生成。已有直连路径的降级规则仍见[图片技术设计](../image-generation/technical-design.md)。 + +## 切换规则 + +L-3(FR-4;US-03 至 US-06、US-11):生成服务接受提交时固定所属会话、触发消息、实际生成模型、生成参数与素材引用。用户尚未发送的草稿使用发送时配置;已发送且排队的请求保留会话选择,跟随全局默认的选项在实际提交时解析。该时点以后不再重选模型。 + +| 变化 | 未提交操作 | 已提交操作与成果 | +| --- | --- | --- | +| 切换聊天模型 | 下一回合使用新模型及其可用工具 | 不改变生成模型;原回合按 Runtime 切换机制结束,操作独立继续 | +| 切换 Runtime | 下一回合按新 Runtime 的工具接入能力执行 | 不依赖旧 Runtime 进程;不向新进程补发未配对的旧工具响应 | +| 切换全局默认 | 跟随默认的操作解析新默认 | 继续原模型 | +| 切换会话生成模型 | 后续操作使用新选择 | 继续原模型 | +| 关闭或删除生成连接 | 拒绝该连接的新操作,提示改选 | 已发请求尽力完成;若后续查询所需连接已失效,显示无法继续查询 | +| 更改地址、凭据或请求参数 | 新操作用新配置 | 当前操作不混用新端点;重启后连接已改变则提示无法继续原操作 | +| 离开或重开会话 | 会话选择独立保留 | 只更新所属会话,不能写入当前打开的其他会话 | + +切换聊天模型不会自动取消媒体生成。“停止回复”只结束当前文字回合;生成卡片的“取消生成”单独处理已提交操作。切到 Ask 不撤销先前 Execute 已提交的操作,也不允许新增生成。 + +## 操作状态 + +L-4(FR-6、FR-8;US-04、US-09、US-10、US-13):状态描述应用实际知道的事实。上游没有百分比时,只显示阶段及已等待时间。 + +| 状态 | 进入条件 | 后续处理 | +| --- | --- | --- | +| 提交中 | 已接受操作,正在发送 | 获得上游响应后生成中或保存中;失败保留原因 | +| 生成中 | 上游已接受、尚无最终成果 | 查询状态或等待响应,结果返回后保存中 | +| 保存中 | 已获得成果,Main 正在校验和保存 | 本地成果及引用保存成功后才完成 | +| 已完成 | 成果可从会话重新读取 | 终态,后续编辑或重试创建新操作 | +| 取消中 | 用户请求取消、等待终止结果 | 上游确认停止后已取消;若已完成则展示成果并说明取消未生效 | +| 已取消 | 本地已停止等待或上游确认取消 | 区分“已停止等待,上游可能继续生成”与“生成已取消” | +| 失败 | 上游明确失败或成果保存失败 | 提供失败原因及适用的重试入口 | +| 状态待确认 | 提交响应丢失、断连或应用重启,无法确认上游结果 | 有上游 ID 时查询;不能查询时提示用户,不自动重新提交 | + +同一操作的重复状态通知只能更新同一记录。取消后的晚到结果不恢复旧工具回合,不自动开始下一次生成。若仍能取得成果,可在原卡片标明“取消未生效,已生成”并展示。超时不等于上游取消,不承诺退款。 + +重新生成创建独立操作,沿用旧操作的模型和输入,提交前检查可用性;用户改选模型后将新选择显示在新尝试上。保存失败且上游成果仍可读取时,“重试保存”使用原操作 ID,由失败回到保存中并更新原卡片,不再次提交生成。无法确认上游是否接受的请求,不做自动生成重试。 + +L-5(FR-5、FR-8;US-07、US-12):历史上下文传成果引用、提示词及实际模型信息。只有具备视觉输入能力的聊天模型才接收适用的图片内容。不能以“有成果引用”推断模型已经看过图片。删除会话沿用已有确认流程,并说明活动生成的取消及不能保证上游停止的情况,删除后晚到结果不能重建会话。 + +## 完整性与待定项 + +已覆盖首次配置、模型选择、历史编辑、模型与 Runtime 切换、只读模式、失败、取消、重启和成果归属。无产品规则需要在本次文档交付前补充确认。 + +视频服务商、协议、取消与查询能力、可接受的文件限制属于实施前的技术选型。远程 Runtime 的工具往返需要原型验证;无法接入时按 L-1 提供显式入口,不把工具接入状态写成已支持。跨设备同步、应用退出后的本地执行不在本次范围。 diff --git a/docs/features/conversation-media-generation/prd.md b/docs/features/conversation-media-generation/prd.md new file mode 100644 index 00000000..d5cd3a61 --- /dev/null +++ b/docs/features/conversation-media-generation/prd.md @@ -0,0 +1,43 @@ +# 产品需求 + +状态:设计草案。本文描述目标行为,不作为现有能力说明。 + +## 用户问题与范围 + +用户希望在文字会话中直接提出“画一张海报”“把背景改成蓝色”“用这张图生成视频”,由聊天助手调用已配置的专用模型。用户不应为了生成媒体而丢失当前聊天入口;切换聊天模型后,也应能继续引用已有成果。 + +范围包括模型调用开关、默认与会话选择、文生图、支持协议下的参考图编辑、文生视频及图生视频、结果展示和生成过程管理。视频具体服务商及协议待技术选型;接通真实服务前不能宣称视频可用。 + +不包含模型训练、自动采购服务、无工具能力 LLM 的通用文本指令执行器、批量渲染平台、应用退出后仍由桌面后台执行的承诺。直接选择图片模型的已有工作流继续保留。 + +## 功能需求 + +### 首期交付 + +首期只交付图片生成,自动调用先接通直连文本 Model Runtime。支持已有图片协议下的文生图、参考图编辑、历史图片引用与成果保存,不新增图片协议。保留模型调用开关、一个全局默认生图模型及用户本次明确指定;编辑默认沿用原成果模型。首期不持久化会话级生成模型选择。 + +切换聊天模型、生成模型或离开会话后,已提交操作仍固定原模型和原会话。切换到尚未接入工具的 Runtime 后,原操作继续,但该 Runtime 暂不能自动发起生图。不支持工具调用时说明限制,用户可使用已有直连图片工作流;首期不增加独立生成表单。 + +运行状态、取消、错误说明及人工重新生成属于首期。应用退出后不继续本地生成;重新打开时将未完成操作标明结果未确认,不自动重新提交。跨重启查询恢复和独立“重试保存”入口后续再做。 + +### 后续范围 + +视频生成与播放、其他本地及远程 Runtime 自动调用、会话级模型选择、独立生成表单、查询恢复及保存重试不纳入首期验收。不为这些能力预建工具、存储字段或界面占位。 + +以下 FR 编号保留完整目标;FR-1 至 FR-8 按上述首期范围验收,FR-9 首期只要求准确说明已接通的 Runtime 和使用限制,其余接入后分别验收。 + +| 编号 | 要求 | 产品验收 | +| --- | --- | --- | +| FR-1 | 图片、视频模型分别提供会话调用开关;新增和已有连接默认关闭,由用户主动开启 | 保存后成为对应可用能力,关闭后不能再提交新操作;历史成果保留 | +| FR-2 | 支持全局默认、当前会话选择和本次明确指定 | 按[选择规则](./logic-design.md)解析并显示实际模型;不静默改用其他服务 | +| FR-3 | 在支持工具调用的聊天模型下,使用自然语言生成媒体 | 从聊天输入到真实服务输出、保存与会话展示完整可用,活动记录可见 | +| FR-4 | 支持聊天模型、Runtime 及生成模型切换 | 新请求获得当前能力,已提交操作保持原模型和原会话;切换不触发重新生成 | +| FR-5 | 支持历史成果引用与继续编辑 | 重新打开会话后可引用已保存原图;缺失素材或编辑能力不足时有明确处理入口 | +| FR-6 | 长耗时生成可独立显示状态、取消与重试 | 离开会话不取消;状态区分上游生成、结果保存、完成及失败,不虚构进度 | +| FR-7 | 适配工作模式及能力边界 | Ask 不提交生成;不支持工具调用时仍可用 Execute 下的显式生成入口 | +| FR-8 | 图片、视频作为会话成果保存 | 结果可预览和导出,来源模型可追溯;聊天模型不能看图时只接收文字事实及引用 | +| FR-9 | 本地和远程 Runtime 有可核验的支持说明 | 自动调用以真实工具往返验收为准;未接通的 Runtime 显示原因及显式生成入口 | + +## 交付标准 + +以[用户场景](./user-stories.md)验收产品路径。生图、视频和各 Runtime 分别记录验证结果,不用某一个模型或 Runtime 的成功代替其他路径。配置连接检查成功不等于生成成功,真实生成成果必须在会话中可见且能重新打开。 diff --git a/docs/features/conversation-media-generation/progress.md b/docs/features/conversation-media-generation/progress.md new file mode 100644 index 00000000..d57ae865 --- /dev/null +++ b/docs/features/conversation-media-generation/progress.md @@ -0,0 +1,33 @@ +# 进度记录 + +## 2026-09-15 + +根据用户“首期以图片为主”的意见收敛设计,已同步需求、场景、规则、界面及技术文档的阶段范围。首期接通直连文本图片工具,保留模型切换、原会话归属及历史图片引用;后续范围见[产品需求](./prd.md)。 + +本次只修改设计文档,未检查或改变应用实现,2026-09-14 的代码检查记录保留为当日证据,不表示已重新验证。图片首期仍待实施。 + +文档验证:7 份文档的一致性审阅未发现首期范围冲突;31 个相对 Markdown 链接均可解析。`deai-writing` 扫描阻断项为 0,31 个复核候选为设计状态、能力职责及行为限制,按语境保留。`git diff --check -- docs/features/conversation-media-generation` 通过;目录当前未跟踪,该命令不覆盖未跟踪文件,内容与链接另行审阅。未运行应用测试或真实生成请求。 + +## 2026-09-14 + +当前阶段:设计草案已落盘,功能未实现。本次只新增设计文档与相关入口链接。 + +代码与文档检查确认以下基础能力: + +| 事实 | 证据 | +| --- | --- | +| 已有直连图片生成、编辑及历史成果引用 | [图片技术设计](../image-generation/technical-design.md)、`src/main/agent/model-runtime.ts` | +| 模型连接及 Main 凭据存储已存在 | `src/shared/contracts.ts`、`src/main/runtime-settings-store.ts` | +| 已有 Skill 发现、Runtime 分配及工具分发 | `src/main/capabilities/capability-service.ts`、`src/main/agent/model-tool-provider.ts` | +| 会话、消息 metadata 和成果已有 SQLite 存储 | `src/main/assistant/assistant-database.ts`、`src/shared/assistant-contracts.ts` | +| 生图尚未成为普通 LLM 的内置生成工具 | 检查 `src/shared/builtin-model-tools.ts` 及工具分发路径 | +| 本次检查未发现第一方视频生成协议实现 | 检查模型能力契约、Runtime 及现有功能文档;不据此推断外部 MCP 的能力 | + +剩余工作对应[技术设计实施顺序](./technical-design.md):模型调用设置与选择、图片服务及会话入口、各 Runtime 接入、视频真实服务与成果交付均待实施。视频协议选择和远程工具桥接验证待完成。 + +本次未修改应用代码,未运行应用测试或真实生成请求。文档验证记录如下: + +- 相对 Markdown 链接检查:7 份新文档及图片功能入口共 33 个链接,失效链接为 0。 +- `deai-writing` 扫描:阻断项为 0;人工复核保留设计状态、能力边界和禁止行为等必要限定,以及真实并列的验收项。 +- 独立一致性审阅发现“重新生成”与“重试保存”的操作身份表述不一致,已在 L-4 和 US-09 中统一:前者创建新操作,后者更新原操作。 +- `git diff --check` 通过。上述检查只验证文档,不构成功能验收。 diff --git a/docs/features/conversation-media-generation/technical-design.md b/docs/features/conversation-media-generation/technical-design.md new file mode 100644 index 00000000..8afae250 --- /dev/null +++ b/docs/features/conversation-media-generation/technical-design.md @@ -0,0 +1,91 @@ +# 技术设计 + +状态:设计草案。拟议字段和工具名用于评审,不表示已存在公开 API。实现 [FR-1 至 FR-9](./prd.md)及[行为规则 L-1 至 L-5](./logic-design.md)。 + +## 首期实现边界 + +首期调用路径为:直连文本 Model Runtime → `generate_image` → Main 图片生成服务 → 现有图片协议 → 原会话 artifact。提取已有图片请求和保存逻辑,Main 持有活动请求,使聊天模型或 Runtime 切换不释放已提交生成。复用现有成果视图与活动反馈。 + +只新增图片会话调用开关和全局默认图片 profile ID;本次指定作为工具参数。生成记录优先放入现有消息 metadata,保存实际模型、输入、状态及成果引用。首期没有会话级生成选择、视频字段、上游任务恢复信息或独立任务表。 + +工具说明随每个新回合提供可调用图片模型的脱敏 ID、名称和已支持能力,提交前重新校验配置。先以工具描述承载使用规则;只有实际调用验证表明说明不足时才补充一份共享 Skill,不把 Skill 管理作为交付依赖。首期不注册 `list_media_models`、视频、查询和取消工具,取消通过会话中的操作入口完成。 + +工具等待遵循现有超时约束;超出回合等待或发生 Runtime 切换后,Main 仍将结果写回原会话。后续回合读取已保存的操作摘要和成果,不补发旧工具响应。应用重启后未完成记录只提示结果未确认,不建立恢复查询或自动重试机制。 + +以下章节描述完整目标中的调用关系和扩展约束。出现视频、会话选择、额外工具、独立生成表单、其他 Runtime 接入和恢复查询时,均属后续设计,不要求首期预建抽象或占位实现。 + +## 调用结构 + +```mermaid +flowchart TD + Chat[聊天模型与 Agent Runtime] --> Tool[媒体工具入口] + Skill[内置媒体 Skill 使用说明] -.-> Chat + UI[会话显式生成入口] --> Main[Main 媒体生成服务] + Tool --> Main + Main --> Profile[模型连接与凭据存储] + Main --> Adapter[图片或视频协议实现] + Adapter --> Provider[生成服务商] + Main --> Artifact[操作记录与会话成果] + Artifact --> Card[原会话生成卡片] +``` + +Main 持有生成请求、取消句柄和结果保存职责。服务生命周期独立于聊天 Runtime controller,切换或释放 controller 不释放已提交的媒体操作。Renderer 经明确 preload/IPC 方法调用;IPC 使用共享契约校验并保留现有发送方校验。凭据只在 Main 解密,不进入 Skill、工具参数或工具输出。 + +从 `ModelAgentRuntime` 提取现有图片请求、校验和成果处理中的可复用部分,使直连图片与媒体工具共用协议实现。直连路径保持原有上下文与编辑降级策略;工具路径依据 L-2 传入明确的素材和降级选项,避免协议重用改变产品语义。 + +## Skill 与工具 + +提供一份内置媒体生成 Skill,说明新建、编辑、图生视频、模型选择、素材引用和结果使用。开启模型配置仅更新能力目录,不为每个连接复制 Skill。能力目录与工具从同一份有效配置生成;Skill 中不保存可过期的模型名单或凭据。 + +拟议工具:`list_media_models` 返回脱敏的可用连接 ID、名称、已支持操作及参数约束;`generate_image`、`generate_video` 提交生成;`get_media_generation` 查询;`cancel_media_generation` 取消。按操作性质接入现有工作模式控制,不因媒体工具增加额外通用审批。 + +生成输入包括 `prompt`、可选 `modelProfileId`、显式 `operation`、`sourceArtifactIds` 及协议支持的类型化选项。图片数量、尺寸与质量,以及视频时长、分辨率等按已接入协议校验,不能假定服务商完全兼容。`conversationId`、触发消息及请求身份由运行上下文绑定,不能由 LLM 自由指定目标会话。 + +提交后返回操作 ID、实际模型和当前状态;完成结果返回成果 ID 与必要文字信息,不把 Base64 或视频字节塞入工具历史。等待有界,长任务通过查询及卡片更新呈现。原回合仍在执行时,可通过其合法工具响应返回最新结果;回合已结束或 Runtime 已切换时,只更新操作记录与原会话卡片,下一回合读取成果摘要,不自动唤起新 LLM 请求。 + +## 配置与持久化 + +| 对象 | 最小拟议数据 | 存储职责 | +| --- | --- | --- | +| 模型连接 | 会话调用开关、适用生成能力 | 复用 settings/profile;现有连接缺少开关时解释为关闭 | +| 全局偏好 | 默认图片、视频 profile ID | 复用应用设置;不复制连接及凭据 | +| 会话选择 | 图片、视频分别为跟随默认或显式 profile ID | 持久化到会话设置,独立于聊天 Runtime selection | +| 生成操作 | ID、原会话/消息 ID、工具调用关联、状态、实际模型 ID 与名称、提示词、有效参数、素材引用、时间、错误、成果 ID | 优先扩展现有会话消息 metadata;实施时评估查询需求后决定是否需要独立表 | +| 上游异步信息 | 必要的上游任务 ID、查询所需非敏感参数 | 仅在协议确实支持异步查询时保存;无凭据副本 | +| 成果 | 媒体内容及类型、原操作与素材关联 | 复用 artifact 体系;不放入 Skill 或 Runtime 内存作为唯一副本 | + +已提交操作在内存持有当次解析的连接参数;持久记录只保留来源与恢复查询所必需的非敏感信息。应用重启后通过原连接核对是否仍可查询,不增加凭据历史、通用恢复日志或多阶段提交机制。不能确认时采用 L-4 的“状态待确认”。 + +操作不自动成为 Task/Job,已有 `runs` 表也不等于通用后台调度已经存在。数据库变更实施前按[迁移规范](../../development/database-migrations.md)评审已发布数据兼容;本次文档不预先引入新表和迁移。 + +Main 必须在成果持久化成功后发布完成事件,事件按操作 ID 更新而非重复追加。校验素材来自当前会话可访问的附件或成果,继承现有项目、会话隔离,不允许模型填写任意文件路径代替成果引用。 + +## 图片与视频结果 + +图片继续遵循[图片技术设计](../image-generation/technical-design.md):校验有界内联图片,不下载 Provider 返回的图片 URL。现有成果内容字符串上限为 5 MiB,这不是原始二进制文件大小,实施时必须正确处理编码开销和超限提示。 + +视频不沿用内联图片存储。优先复用 `managed_file` 成果和现有文件导出能力,补充媒体类型、播放所需信息及预览。协议选型必须明确提交、查询、取消、结果读取、认证、文件大小上限、超时和过期时间;最终方案用实际服务的证据补入本文。 + +若视频服务只返回下载地址,需在视频协议设计中明确 Main 端的读取来源、认证、重定向及字节限制后实现,不能借用或放宽图片 URL 禁止规则。生成媒体读取不使用应用更新或模型包下载源设置;受管软件包镜像规则也不适用于动态生成的媒体结果。无法保存或播放的上游结果不能标记为交付完成。 + +## Runtime 接入 + +| 路径 | 设计接入点 | 验证要求 | +| --- | --- | --- | +| 直连文本 Model Runtime | `builtin-model-tools.ts` 的工具契约及 `model-tool-provider.ts` 分发 | 自然语言请求实际调用生成模型并返回成果 | +| 本地 OpenCode、Continue、DeepSeek Harness | 复用各 Runtime 已有工具或 MCP 注入机制,转到同一 Main 服务 | 分别验证发现、调用、查询、取消和切换;不能以 Skill 注入成功代替 | +| GoodBuddy Agent 远程路径 | 经现有桌面与 Agent 通信能力将工具请求绑定到原会话,Main 执行生成 | 验证真实 Host 的工具往返、断连和结果归属,凭据不下发远端 | +| 不支持工具调用的聊天模型或尚未接入的 Runtime | Renderer 显式入口经 IPC 调用同一服务 | Execute 可完成生成,Ask 不提交,原会话可查看成果 | + +Runtime 进程复用时,新的聊天回合仍需刷新能力目录;不假定修改配置必然重启进程。远程桥接是否可直接复用现有工具分发是实施前的验证项,不能由本地成功推断远程支持。远程断连后桌面仍运行时,可继续管理已提交生成;远程 LLM 停止等待不等于媒体取消。 + +## 实施顺序与验证 + +1. 首期:实现图片调用开关、全局默认及本次指定,提取图片服务并接通直连文本 `generate_image`。完成文生图、已有协议编辑、成果保存、取消及模型切换后的结果交付。 +2. 后续按实际需求接通其余本地 Runtime 与远程工具路径,分别验证真实调用及切换。 +3. 后续评估会话级选择、独立生成入口及恢复能力,确认需要后再增加界面和数据。 +4. 视频单独选型与验收,实际接通服务后再增加视频工具、异步查询和播放能力。 + +首期以图片真实产品路径验收,完成后标记“图片首期完成”,后续能力单独记录。针对首期适用的 L-1 至 L-5 编写选择优先级、配置失效、会话隔离、重复结果、取消及晚到结果测试。验证原直连图片行为不回归,检查进程切换不会重复提交。 + +源代码实现后运行 `npm test`、`npm run typecheck`、`npm run lint`。首期真实生成从直连文本聊天输入进入,覆盖[用户场景](./user-stories.md)中的首期验收;记录实际请求数量、模型、Runtime 和结果。后续显式入口需独立验证。涉及 Agent 的变更按[远程主机验证要求](../remote-host/technical-design.md)在真实测试 Host 验证。当前仅写文档,不发起收费生成调用。 diff --git a/docs/features/conversation-media-generation/ui-design.md b/docs/features/conversation-media-generation/ui-design.md new file mode 100644 index 00000000..ac6e529d --- /dev/null +++ b/docs/features/conversation-media-generation/ui-design.md @@ -0,0 +1,42 @@ +# 界面设计 + +状态:设计草案。遵循[统一界面设计](../../../UI-DESIGN.md),实现 [FR-1 至 FR-9](./prd.md),行为以[行为规则](./logic-design.md)为准。 + +## 首期界面 + +首期只在图片模型配置提供会话调用开关和“设为默认生图模型”,沿用下文共享控件规范。关闭或删除默认模型后保留失效选择并提示改选,不自动改用其他模型。 + +会话以自然语言发起生图,本次指定模型也通过请求表达。不新增会话级模型选择器、独立生成表单或视频入口。未设默认时,助手提示用户本次指定一个可用模型,或到设置中选择默认后重新发送请求;名称有歧义时先澄清。 + +复用图片成果展示与引用入口,显示实际模型、进行状态、结果或错误,并支持取消和人工重新生成。首期不增加视频播放和独立“重试保存”按钮。不支持工具调用或 Runtime 未接通时说明限制,提示可切换到支持的聊天路径或使用已有直连图片工作流;Ask 下说明生成需要 Execute,不自动切换模式。 + +以下为完整目标的界面设计,按[产品需求的阶段划分](./prd.md)实施;首期不预留后续控件。 + +## 模型配置 + +在现有模型连接表单中,按已支持的生成能力显示持久开关: + +- 图片模型:“允许 AI 在会话中调用此模型生成图片”。 +- 视频模型:“允许 AI 在会话中调用此模型生成视频”。 + +说明文字:“开启后,支持工具调用的聊天助手可使用此模型。生成结果保存在发起请求的会话中。”开关复用共享 Switch,并提供 `role="switch"`、键盘操作及可见焦点。不要求用户维护 Skill 文件或填写第二套 API 凭据。 + +开启后提供“设为默认生图模型”或“设为默认视频模型”操作。列表在对应模型旁显示“可在会话中调用”和默认标识。关闭、删除默认连接时提示会话选择将不可用,保留原选择供改选,不自动指定其他模型。 + +## 会话入口 + +在现有输入区工具菜单提供“生成图片”“生成视频”,以及对应模型选择。默认项写成“跟随全局设置(模型名称)”;没有默认值则显示“选择生成模型”。选择只影响当前会话,设为全局默认必须使用独立操作。 + +自然语言调用与显式入口共用成果卡片。显式入口提供提示词和参考素材选择,按所选模型展示适用参数,不呈现供应商请求字段。输入区显示已引用的素材缩略图或文件名,可移除。 + +不支持工具调用时提示“当前聊天模型无法自动调用,可使用生成入口”。Ask 下提示“生成图片或视频需要 Execute 模式”,由用户切换工作模式;不能自动切换。未配置生成模型时给出“添加生成模型”入口。 + +## 生成卡片 + +卡片就近显示媒体类型、实际模型、当前阶段、成果或错误。参数、提示词和来源素材放在可展开详情中;不显示密钥、认证头或供应商私有下载地址。运行活动使用同一操作标识,与卡片关联。 + +图片完成后可预览、下载和“继续修改”;存在可用图生视频能力时提供“生成视频”。视频完成后提供带原生播放控制的预览及下载,不自动播放。点击继续修改会把成果加入输入区,用户填写本次修改要求。 + +进行中显示“取消生成”,取消结果按行为规则展示。失败显示有针对性的“重试”“重新选择模型”“重新选择素材”或“重试保存”。卡片错误不重复弹出全局错误通知;模型设置保存等非上下文反馈使用应用通知。 + +窄窗口中操作允许换行,媒体宽度不超过消息区;模型长名称可截断并提供完整可访问名称。状态使用文字表达,不能只依靠颜色;阶段变化通过适度的 `aria-live` 提示,避免等待时间逐秒播报。弹出选择器关闭后焦点回到触发控件。 diff --git a/docs/features/conversation-media-generation/user-stories.md b/docs/features/conversation-media-generation/user-stories.md new file mode 100644 index 00000000..6e1a56de --- /dev/null +++ b/docs/features/conversation-media-generation/user-stories.md @@ -0,0 +1,39 @@ +# 用户场景 + +状态:设计草案。表中 Given、When、Then 分别为前提、操作和预期结果。 + +## 首期验收 + +原 US 编号保留完整目标。首期在直连文本 Model Runtime 验收 US-01、US-03、US-04、US-07、US-12、US-14,其中切换后的自动编辑要求目标聊天模型支持工具调用。切换到其他 Runtime 时,只验证原生成继续并写回原会话,不要求新 Runtime 能发起生成。 + +其余场景的首期预期如下,以[首期行为规则](./logic-design.md)为准: + +| 场景 | 首期验收 | +| --- | --- | +| US-02 | 未设默认时提示本次指定模型或去设置默认;重新发送后生成,不新增模型选择弹窗 | +| US-05 | 当前操作使用 X,修改全局默认为 Y 后,新建图片使用 Y,旧操作保持 X | +| US-06 | 默认或本次指定的 X 不可用时提示改选,历史图片保留;没有会话级选择 | +| US-08 | 无工具能力时说明限制及已有直连图片工作流;Ask 不提交生成 | +| US-09 | 图片生成可取消并如实显示结果,人工重新生成创建新操作;没有独立保存重试 | +| US-13 | 应用重启后未完成图片操作显示结果未确认,不自动查询或重新提交 | + +US-10 视频生成、US-11 远程自动调用,以及下表中超出上述范围的预期,留到后续验收。 + +## 完整目标场景 + +| 编号 | 需求 | Given | When | Then | +| --- | --- | --- | --- | --- | +| US-01 | FR-1、FR-3 | 已添加生图模型并开启会话调用,已选默认模型 | 在支持工具的文字模型中要求画海报 | 活动中显示调用,完成后原会话出现图片及实际模型 | +| US-02 | FR-2 | 有两个可调用生图模型,未选默认 | 请求生成图片 | 提供模型选择入口,选择后继续原请求;不会自行按列表顺序提交 | +| US-03 | FR-4、FR-5 | 模型 A 已生成图片 | 切换聊天模型 B,要求修改该图 | B 获得成果引用和可用工具,编辑模型按规则选择 | +| US-04 | FR-4、FR-6 | 一个生成操作已经提交 | 切换 Runtime 并打开其他会话 | 原操作继续,结果只写回原会话;新 Runtime 不重复调用 | +| US-05 | FR-2、FR-4 | 会话跟随全局默认,当前操作使用模型 X | 将默认改成 Y,再发一个新建图片请求 | 旧操作仍用 X,新操作用 Y,卡片显示各自来源 | +| US-06 | FR-4 | 会话明确选择 X | X 被关闭或删除后请求生成 | 显示 X 不可用并允许改选;历史图仍可打开 | +| US-07 | FR-5、FR-8 | 已保存生成图并重新打开会话 | 显式引用原图,指定另一个支持编辑的模型 | 新模型接收原图;成果丢失时要求重新选择素材,不能假装已编辑 | +| US-08 | FR-7 | 切换到不支持工具调用的文字模型 | 点击会话显式生图入口 | Execute 下可生成并回写;Ask 下说明需要切换 Execute,不能发出生成请求 | +| US-09 | FR-6 | 操作正在生成或保存结果 | 点击取消,或失败后选择重试 | 如实显示取消结果;重新生成创建独立操作,重试保存更新原操作,不自动重复收费请求 | +| US-10 | FR-3、FR-8 | 已接通支持图生视频的模型 | 从图片成果选择生成视频 | 使用所选图片,视频完成并保存后可播放、导出,重开会话仍可访问 | +| US-11 | FR-4、FR-9 | 远程 Runtime 已接入媒体工具 | 在远程会话中生成后切换本地 Runtime | 结果与活动属于原会话,凭据留在 Main,后续请求可引用成果 | +| US-12 | FR-5、FR-7 | 新聊天模型没有视觉能力 | 请求分析已生成图片或继续修改 | 说明无法直接查看图片;可根据用户文字调用编辑工具,不编造视觉观察 | +| US-13 | FR-6 | 上游视频返回可查询的任务标识 | 退出并重开应用 | 查询已有任务;无法查询的中断操作明确提示,不自动重新提交 | +| US-14 | FR-5 | 同一会话有多张成果 | 要求“修改第二张”或“新画一张无关图片” | 前者引用指定成果,后者不自动附带上一张图;指代不清时先澄清 | diff --git a/docs/features/deepseek-harness/README.md b/docs/features/deepseek-harness/README.md index ae3101ba..db433567 100644 --- a/docs/features/deepseek-harness/README.md +++ b/docs/features/deepseek-harness/README.md @@ -11,3 +11,8 @@ | [实现进度](./progress.md) | Windows 工作区路径修复及验证记录 | 现有文档同时包含部分 PRD、功能逻辑和进度内容,后续修改该 Runtime 时再按标准职责拆分。 + +跨项目共享 Host 和状态检查不冷启动的实现设计,由 +[Runtime 进程复用技术设计](../assistant-workbar/runtime-process-reuse-technical-design.md) +统一维护;本 Runtime 的生命周期见[技术设计 §9](./technical-design.md#9-runtime-生命周期), +未发布实现的验收状态见[工作栏进度](../assistant-workbar/progress.md#2026-09-14-runtime-进程复用实施中)。 diff --git a/docs/features/deepseek-harness/technical-design.md b/docs/features/deepseek-harness/technical-design.md index bd0f632f..8ea8cc7c 100644 --- a/docs/features/deepseek-harness/technical-design.md +++ b/docs/features/deepseek-harness/technical-design.md @@ -121,10 +121,9 @@ GoodBuddy 使用自己固定的 Harness Host 入口和只读组合模板,不 模型名称、服务地址、工作区、Skills、MCP schema 和已启用插件的规范化入口通过严格校验的 Main 配置传给 Host。API Key 只通过受控凭据通道按需提供,不写入 YAML、命令行、Renderer 或日志。插件配置只来自 GoodBuddy 受管状态,不合并用户 profile 或全局补丁。 -本机 Runtime 初始化时对工作区调用 `realpath`,将结果保存在该 Host 的运行状态中; -Host 启动与 ACP `newSession.cwd` 共用这一路径。Host 仍校验目录并严格比较会话路径。 -Windows 混合分隔符与同目录规范写法可继续通过现有 execution-space identity 复用 -Runtime,无需更改缓存键或增加重试。远端 SSH 由 `ManagedRemoteAcpRuntime` / +Main 在创建 Session 前解析该请求工作区的 `realpath`,把绝对路径传给 ACP +`newSession.cwd`。Host 不再要求 Session cwd 等于启动 cwd,原生 Agent header.cwd +驱动文件与 Shell 工具;启动 cwd 仅为默认值。远端 SSH 由 `ManagedRemoteAcpRuntime` / `AcpRemoteRuntime` 单独处理,不经过本机 DS Runtime 的目录解析。 ### 5.3 双层内部控制面 @@ -323,18 +322,18 @@ Harness Control Plane 只发送 GoodBuddy 能稳定解释的字段: ### 9.1 进程模型 -- 每个活动的 DeepSeek Harness Runtime 实例拥有一个 `utilityProcess`。 -- 一个进程可以承载多个 Harness Session。 +- Main `LocalRuntimeRegistry` 按兼容模型及 Host 配置共享重型实例; + 每组拥有一个 `utilityProcess`,不同工作区使用轻量适配器及独立 Session cwd。 - 同一 GoodBuddy 对话的 Prompt 串行执行。 - 不同对话可以并行,但受全局并发上限控制。 -- Runtime 设置变化时创建新实例,旧实例等待在途请求结束或在宽限期后被取消。 +- Runtime 设置变化时旧配置停止接收新请求,在途请求结算后释放旧实例。 ### 9.2 会话映射 Main 保存内存映射: ```text -GoodBuddy conversationId -> Harness sessionId + process generation +GoodBuddy conversationId -> Harness sessionId + workspace + process generation ``` - 首次请求创建 Session。 @@ -346,7 +345,8 @@ GoodBuddy conversationId -> Harness sessionId + process generation ### 9.3 取消与有界控制操作 - 用户取消时立即发送 `session/cancel`。 -- 取消等待有界,超时后关闭连接并终止整个 Harness 进程。 +- 取消等待有界,超时先释放目标 Session 的 Agent、工具和 Main 代理资源; + 只有该控制操作也不响应时才终止整个 Harness 进程。 - 初始化、握手、请求准备、权限回传和关闭分别使用独立控制超时。 - 生产 Prompt 不设置固定墙钟总时限,由 Harness 自身协议终态或用户取消结束。测试可以 显式注入短 Prompt 时限验证取消传播,超时错误与用户取消仍不能被宽泛 catch 抹平。 @@ -354,7 +354,10 @@ GoodBuddy conversationId -> Harness sessionId + process generation ### 9.4 释放与退出 -- 原生能力清单由 `SelectedRuntimeManager` 缓存的执行 Runtime 提供,与随后的对话请求共用同一实例,不再为清单单独启动并销毁一次 Host;该实例遵循执行缓存的空闲淘汰、设置变更 reset 和退出 dispose 规则。 +- 原生清单与执行使用共享 owner,查询携带工作区。轻量适配器被缓存淘汰不结束原生 + Session;无会话、无请求的 owner 空闲 60 秒退出,再次使用按需启动。 +- `getStatus()` 只读取已有状态和配置,不启动 Host,也不重置空闲回收计时;显式 `testConnection()` 才启动 + 并验证连接,临时检查适配器的 dispose 不终止其他会话。 - 删除或释放对话时调用 `goodbuddy/session/release`。 - Runtime dispose 时先拒绝新请求,再取消所有 Session。 - Harness Control Plane 完成 Agent、工具和会话清理,Host 完成 Cordis Fiber 与子进程的反向清理。 @@ -363,6 +366,17 @@ GoodBuddy conversationId -> Harness sessionId + process generation - 应用退出时中止正在运行的 npm 插件安装并终止其完整进程树,不能让 lifecycle script 在 GoodBuddy 退出后继续运行。 - 应用退出不得因 Harness 清理无限阻塞。 +### 9.5 跨项目复用 + +锁定 0.1.2-rc.1 的文件和 Bash/PowerShell 工具已按原生 Session header.cwd 路由, +Provider 的根 cwd 只是默认值。不为每工作区复制本地 Provider; +Main 的 Web/MCP ToolProvider 按工作区保存,最后一个对应 Session 释放时 dispose。 + +共享 Utility、逐 Session cwd、被动状态、Main 工具代理和取消升级统一由 +[Runtime 进程复用技术设计](../assistant-workbar/runtime-process-reuse-technical-design.md#5-deepseek-harness) +定义,证据及剩余验收见[工作栏进度](../assistant-workbar/progress.md#2026-09-14-runtime-进程复用实施中)。 +本节描述当前未发布源码,不将新行为归入已发布的 Desktop 0.13.2。 + ## 10. 权限与主机执行 ### 10.1 模式映射 diff --git a/docs/features/direct-model-agent/technical-design.md b/docs/features/direct-model-agent/technical-design.md index 2d1edd98..96d1cf4b 100644 --- a/docs/features/direct-model-agent/technical-design.md +++ b/docs/features/direct-model-agent/technical-design.md @@ -29,6 +29,11 @@ ## 2. 总体架构 +直连模型在 Main 内执行 HTTP,本身没有独立模型进程。跨 Runtime 资源调研中的直连 +部分仅检查历史缓存、会话引用和工具资源释放,不为它新增进程池;分析与验证范围 +见 [Runtime 资源技术设计 §7.2](../assistant-workbar/runtime-process-reuse-technical-design.md#72-直连模型及历史详情)。 +以下当前功能不因此改变,进程工具产生的子进程仍按各自会话生命周期管理。 + ```text ModelAgentRuntime └─ ModelToolProvider diff --git a/docs/features/image-generation/README.md b/docs/features/image-generation/README.md index fb1f299b..65faf565 100644 --- a/docs/features/image-generation/README.md +++ b/docs/features/image-generation/README.md @@ -27,6 +27,7 @@ ## 相关功能 +- [会话媒体生成设计](../conversation-media-generation/README.md):规划普通 LLM 通过工具调用图片、视频模型及模型切换行为,尚未实现。 - [模型连接](../model-connections/README.md):地址、模型 ID、凭据与请求定制。 - [直连文本 Agent](../direct-model-agent/README.md):文本模型的工具和上下文压缩, 不等于图片模型支持 Responses 图片工具。 diff --git a/docs/features/remote-host/README.md b/docs/features/remote-host/README.md index d0ac53c0..3affa5e7 100644 --- a/docs/features/remote-host/README.md +++ b/docs/features/remote-host/README.md @@ -11,3 +11,7 @@ | [远程环境准备技术设计](./environment-provisioning-technical-design.md) | Host 下载、GoodBuddy 传输、安装事务、更新和恢复边界 | 现有文档包含产品、功能逻辑和进度事实,后续修改远程功能时再按标准职责拆分。 + +跨会话共享 OpenCode 进程、ACP 连接与模型桥的实现设计,见 +[Runtime 进程复用技术设计](../assistant-workbar/runtime-process-reuse-technical-design.md#6-ssh-opencode-与-agent)。 +当前实现尚未发布;验收状态见[工作栏进度](../assistant-workbar/progress.md#2026-09-14-runtime-进程复用实施中)。 diff --git a/docs/features/remote-host/technical-design.md b/docs/features/remote-host/technical-design.md index 79bec552..1770304f 100644 --- a/docs/features/remote-host/technical-design.md +++ b/docs/features/remote-host/technical-design.md @@ -319,6 +319,9 @@ Execute 直接启动已签名 Runtime: 自动重放。插件随 OpenCode 实例释放临时端口,helper 退出时删除插件文件。 以上只适用于当前 Agent 管理的 SSH OpenCode 生产路径,不声明任意 ACP 服务的问答兼容性。 真实二进制、SSH 传输和取消验证见[工作栏进度](../assistant-workbar/progress.md#2026-09-13-托管-ssh-原生问答)。 + 未发布进程复用源码补齐 Desktop 重启后的多任务并行附加、实时待答快照及 Main 答案 + 持久化,详见[恢复与终态投影](../assistant-workbar/runtime-process-reuse-technical-design.md#63-恢复与终态投影); + 不把该待答投影当作 durable transcript 或旧版本已经支持的行为。 - ACP 工具结果同时支持文本 `content` 和 `rawOutput.output`,移除 OpenCode 的 `` 外层包装后渲染正文。恢复已有子代理时使用 ACP 所带的所属 `toolCallId` 继续路由,不等待新的父 Task metadata,也不重放模型请求。 diff --git a/package.json b/package.json index 852ff2b9..33d23cac 100644 --- a/package.json +++ b/package.json @@ -21,7 +21,7 @@ "scripts": { "dev": "electron-vite dev", "start": "electron-vite preview", - "typecheck": "tsc --noEmit -p tsconfig.node.json && tsc --noEmit -p tsconfig.web.json", + "typecheck": "tsc --noEmit -p tsconfig.node.json && tsc --noEmit -p tsconfig.agent.json && tsc --noEmit -p tsconfig.web.json", "lint": "eslint .", "test": "vitest run", "test:watch": "vitest", diff --git a/src/agent-daemon/agent-acp-connection.test.ts b/src/agent-daemon/agent-acp-connection.test.ts new file mode 100644 index 00000000..60d95eac --- /dev/null +++ b/src/agent-daemon/agent-acp-connection.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it, vi } from 'vitest' +import { AgentSideConnection, ndJsonStream, PROTOCOL_VERSION, type Client } from '@agentclientprotocol/sdk' +import { AgentAcpConnection } from './agent-acp-connection' +import type { RuntimeAcpProcessOutput, RuntimeAcpProcessOwner } from './runtime-acp-backend' + +describe('AgentAcpConnection', () => { + it('uses one decoder and handshake and routes interleaved Sessions without closing peers', async () => { + let output!: (value: RuntimeAcpProcessOutput) => void | Promise + let input!: ReadableStreamDefaultController + const unsubscribe = vi.fn() + const process: RuntimeAcpProcessOwner = { + identity: { launchId: 'launch', processId: 'process', supervisorIdentityDigest: `sha256:${'a'.repeat(64)}` }, + beginPrompt: vi.fn(), completePrompt: vi.fn(), stop: vi.fn(), + reconcile: vi.fn(), + subscribeOutput: vi.fn(listener => { output = listener; return unsubscribe }), + subscribeExit: vi.fn(() => unsubscribe), + writeStdin: async data => input.enqueue(data) + } + const initialize = vi.fn(async () => ({ protocolVersion: PROTOCOL_VERSION })) + const server = new AgentSideConnection(() => ({ + initialize, newSession: async () => ({ sessionId: 'session' }), + prompt: async () => ({ stopReason: 'end_turn' as const }), + cancel: async () => undefined + }), ndJsonStream(new WritableStream({ + write: async data => { await output({ stream: 'stdout', data }) } + }), new ReadableStream({ start: controller => { input = controller } }))) + const transport = new AgentAcpConnection(process) + const one: Client = { sessionUpdate: vi.fn(), requestPermission: vi.fn(async () => ({ + outcome: { outcome: 'selected', optionId: 'allow' } + })) } + const two: Client = { sessionUpdate: vi.fn(), requestPermission: vi.fn(async () => ({ + outcome: { outcome: 'cancelled' } + })) } + const release = transport.register('one', one) + transport.register('two', two) + try { + await Promise.all([1, 2].map(() => transport.initialize({ protocolVersion: PROTOCOL_VERSION }))) + expect(initialize).toHaveBeenCalledOnce() + for (const sessionId of ['one', 'two', 'one', 'two']) { + await server.sessionUpdate({ + sessionId, update: { sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: sessionId } } + }) + } + await vi.waitFor(() => expect(two.sessionUpdate).toHaveBeenCalledTimes(2)) + expect(one.sessionUpdate).toHaveBeenCalledTimes(2) + const permission = (sessionId: string) => server.requestPermission({ + sessionId, options: [{ kind: 'allow_once', optionId: 'allow', name: 'Allow' }], + toolCall: { toolCallId: 'tool', title: 'write', kind: 'edit' } + }) + expect(await permission('one')).toEqual({ outcome: { outcome: 'selected', optionId: 'allow' } }) + expect(await permission('two')).toEqual({ outcome: { outcome: 'cancelled' } }) + release() + expect(await permission('one')).toEqual({ outcome: { outcome: 'cancelled' } }) + expect(await permission('two')).toEqual({ outcome: { outcome: 'cancelled' } }) + expect(process.subscribeOutput).toHaveBeenCalledOnce() + expect(unsubscribe).not.toHaveBeenCalled() + } finally { + transport.dispose() + input.close() + } + expect(unsubscribe).toHaveBeenCalledTimes(2) + }) +}) diff --git a/src/agent-daemon/agent-acp-connection.ts b/src/agent-daemon/agent-acp-connection.ts new file mode 100644 index 00000000..efb247c6 --- /dev/null +++ b/src/agent-daemon/agent-acp-connection.ts @@ -0,0 +1,108 @@ +import { + ClientSideConnection, + ndJsonStream, + type Client, + type InitializeRequest, + type InitializeResponse +} from '@agentclientprotocol/sdk' +import type { RuntimeAcpProcessOwner } from './runtime-acp-backend' + +/** One decoder and handshake per native process, with Session-scoped clients. */ +export class AgentAcpConnection { + readonly connection: ClientSideConnection + readonly exited: Promise + private initialization?: Promise + private readonly sessions = new Map() + private readonly unsubscribeOutput: () => void + private readonly unsubscribeExit: () => void + private closed = false + private closeInput?: () => void + private bridgeOrigin?: string + + constructor( + process: RuntimeAcpProcessOwner, + fallback?: () => Client, + createConnection?: (client: () => Client, stream: ReturnType) => ClientSideConnection + ) { + let controller: ReadableStreamDefaultController | undefined + let rejectExit!: (error: Error) => void + this.exited = new Promise((_resolve, reject) => { rejectExit = reject }) + void this.exited.catch(() => undefined) + const input = new ReadableStream({ + start(value) { controller = value } + }) + this.closeInput = () => { + controller?.close() + controller = undefined + } + this.unsubscribeOutput = process.subscribeOutput((output) => { + if (!this.closed && output.stream === 'stdout' && output.data.byteLength > 0) { + controller?.enqueue(output.data.slice()) + } + }) ?? (() => undefined) + this.unsubscribeExit = process.subscribeExit?.(() => { + if (this.closed) return + const error = new Error('ACP Runtime process exited') + controller?.error(error) + controller = undefined + rejectExit(error) + }) ?? (() => undefined) + const output = new WritableStream({ + write: async (data) => { await process.writeStdin(data) } + }) + const client = (): Client => ({ + sessionUpdate: async (notification) => { + await (this.sessions.get(notification.sessionId) ?? fallback?.()) + ?.sessionUpdate(notification) + }, + requestPermission: async (request) => + await (this.sessions.get(request.sessionId) ?? fallback?.()) + ?.requestPermission(request) ?? { outcome: { outcome: 'cancelled' } }, + extMethod: async () => ({}), + extNotification: async (method, params) => { + if ( + method === 'goodbuddy/modelBridgeReady' && + typeof params.origin === 'string' && + /^http:\/\/127\.0\.0\.1:\d+\/[A-Za-z0-9_-]{43}$/u.test(params.origin) + ) this.bridgeOrigin = params.origin + } + }) + const stream = ndJsonStream(output, input) + this.connection = createConnection?.(client, stream) ?? new ClientSideConnection(client, stream) + } + + initialize(request: InitializeRequest): Promise { + return this.initialization ??= this.connection.initialize(request) + } + + register(sessionId: string, client: Client): () => void { + if (this.sessions.has(sessionId)) throw new Error('ACP Session already has an owner') + this.sessions.set(sessionId, client) + return () => { + if (this.sessions.get(sessionId) === client) this.sessions.delete(sessionId) + } + } + + async setModelRoute( + sessionId: string, + operationId: string, + socketPath?: string, + workMode?: 'ask' | 'execute' + ): Promise { + if (!this.bridgeOrigin) throw new Error('Shared model bridge is not ready') + const response = await fetch(`${this.bridgeOrigin}/session`, { + method: 'POST', headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ sessionId, operationId, ...(socketPath ? { socketPath, workMode } : { release: true }) }), + signal: AbortSignal.timeout(10_000) + }) + if (!response.ok) throw new Error(`Shared model bridge route failed (${response.status})`) + } + dispose(): void { + if (this.closed) return + this.closed = true + this.sessions.clear() + this.unsubscribeOutput() + this.unsubscribeExit() + this.closeInput?.() + } +} diff --git a/src/agent-daemon/agent-owned-acp-prompt.test.ts b/src/agent-daemon/agent-owned-acp-prompt.test.ts index 60f00ad0..2908eef6 100644 --- a/src/agent-daemon/agent-owned-acp-prompt.test.ts +++ b/src/agent-daemon/agent-owned-acp-prompt.test.ts @@ -23,6 +23,64 @@ afterEach(() => { }) describe('AgentOwnedAcpPrompt', () => { + it('applies each prompt mode to the retained session and its permission decisions', async () => { + const root = mkdtempSync(join(tmpdir(), 'goodbuddy-owned-acp-modes-')) + temporary.push(root) + const transcript = new SemanticPromptStore(join(root, 'prompts.sqlite')) + let client!: Client + let finish!: (response: PromptResponse) => void + const newSession = vi.fn(async () => ({ sessionId: 'session-1' })) + const prepareSession = vi.fn(async () => undefined) + const completePrompt = vi.fn(async () => undefined) + const owner = new AgentOwnedAcpPrompt({ + bindingId: 'binding-1', controllerId: 'controller-1', + workspaceDirectory: '/workspace', process: new MemoryProcess(), + transcript, prepareSession, completePrompt, + createConnection: factory => { + client = factory() + return { + initialize: async () => ({ protocolVersion: 1, agentCapabilities: {} }), + newSession, + prompt: () => new Promise(resolve => { finish = resolve }) + } as unknown as ClientSideConnection + } + }) + try { + for (const [index, workMode] of (['execute', 'ask', 'execute'] as const).entries()) { + const operationId = `operation-${index}` + transcript.prepare({ + bindingId: 'binding-1', operationId, requestId: operationId, + controllerId: 'controller-1', preparationDigest: `sha256:${'a'.repeat(64)}`, + promptSequence: index + }) + await owner.start({ + bindingId: 'binding-1', operationId, requestId: operationId, + prompt: [{ type: 'text', text: 'Continue with the selected mode' }] + }, workMode) + expect(prepareSession).toHaveBeenLastCalledWith('session-1', operationId, workMode) + const result = await client.requestPermission!({ + sessionId: 'session-1', + toolCall: { toolCallId: `tool-${index}`, kind: 'execute', title: 'Run a test' }, + options: [ + { kind: 'allow_once', optionId: 'allow', name: 'Allow' }, + { kind: 'reject_once', optionId: 'reject', name: 'Reject' } + ] + }) + expect(result.outcome).toEqual({ + outcome: 'selected', optionId: workMode === 'execute' ? 'allow' : 'reject' + }) + finish({ stopReason: 'end_turn' }) + await vi.waitFor(() => expect(transcript.attach('binding-1', operationId, 'controller-1')) + .toMatchObject({ state: 'completed' })) + } + expect(newSession).toHaveBeenCalledOnce() + expect(completePrompt).toHaveBeenCalledTimes(3) + } finally { + owner.close() + transcript.close() + } + }) + it('keeps the original prompt promise through Desktop detach and recovers the final response', async () => { const root = mkdtempSync(join(tmpdir(), 'goodbuddy-owned-acp-')) temporary.push(root) @@ -76,7 +134,6 @@ describe('AgentOwnedAcpPrompt', () => { bindingId: 'binding-1', controllerId: 'controller-1', workspaceDirectory: '/workspace', - workMode: 'execute', expectedModel, process, transcript, @@ -101,7 +158,7 @@ describe('AgentOwnedAcpPrompt', () => { requestId: 'operation-1', prompt: [{ type: 'text' as const, text: 'finish independently' }] } - await expect(owner.start(request)).resolves.toMatchObject({ + await expect(owner.start(request, 'execute')).resolves.toMatchObject({ state: 'running', sessionId: 'session-1' }) @@ -141,7 +198,7 @@ describe('AgentOwnedAcpPrompt', () => { limit: 10 }).events.map((event) => event.kind) ).toEqual(['session-update', 'prompt-terminal']) - await expect(owner.start(request)).resolves.toMatchObject({ + await expect(owner.start(request, 'execute')).resolves.toMatchObject({ state: 'completed' }) expect(prompt).toHaveBeenCalledOnce() @@ -170,7 +227,6 @@ describe('AgentOwnedAcpPrompt', () => { bindingId: 'binding-1', controllerId: 'controller-1', workspaceDirectory: '/workspace', - workMode: 'execute', process: new MemoryProcess(), transcript, completePrompt, @@ -191,7 +247,7 @@ describe('AgentOwnedAcpPrompt', () => { operationId: 'operation-1', requestId: 'operation-1', prompt: [{ type: 'text', text: 'finish independently' }] - }) + }, 'execute') await vi.waitFor(() => expect( transcript.attach('binding-1', 'operation-1', 'controller-1') @@ -239,7 +295,6 @@ describe('AgentOwnedAcpPrompt', () => { bindingId: 'binding-1', controllerId: 'controller-1', workspaceDirectory: '/workspace', - workMode: 'execute', process, transcript, completePrompt, @@ -261,7 +316,7 @@ describe('AgentOwnedAcpPrompt', () => { operationId: 'operation-1', requestId: 'operation-1', prompt: [{ type: 'text', text: 'wait for Runtime exit' }] - }) + }, 'execute') await process.emitExit() await vi.waitFor(() => @@ -315,7 +370,6 @@ describe('AgentOwnedAcpPrompt', () => { bindingId: 'binding-1', controllerId: 'controller-1', workspaceDirectory: '/workspace', - workMode: 'execute', process: new MemoryProcess(), transcript, completePrompt: async () => undefined, @@ -338,7 +392,7 @@ describe('AgentOwnedAcpPrompt', () => { operationId: 'operation-1', requestId: 'operation-1', prompt: [{ type: 'text', text: 'run one tool' }] - }) + }, 'execute') await expect( client.requestPermission({ sessionId: 'session-1', @@ -388,7 +442,6 @@ describe('AgentOwnedAcpPrompt', () => { bindingId: 'binding-1', controllerId: 'controller-1', workspaceDirectory: '/workspace', - workMode: 'ask', process: new MemoryProcess(), transcript, completePrompt: async () => undefined, @@ -411,7 +464,7 @@ describe('AgentOwnedAcpPrompt', () => { operationId: 'operation-1', requestId: 'operation-1', prompt: [{ type: 'text', text: 'inspect one file' }] - }) + }, 'ask') const permission = ( kind: 'read' | 'edit' ): Parameters[0] => ({ diff --git a/src/agent-daemon/agent-owned-acp-prompt.ts b/src/agent-daemon/agent-owned-acp-prompt.ts index 1ac1c11e..22258ab6 100644 --- a/src/agent-daemon/agent-owned-acp-prompt.ts +++ b/src/agent-daemon/agent-owned-acp-prompt.ts @@ -16,6 +16,7 @@ import type { RemoteOwnedPromptStartResult } from '../shared/remote-agent-contracts' import type { RuntimeAcpProcessOwner } from './runtime-acp-backend' +import { AgentAcpConnection } from './agent-acp-connection' import { SemanticPromptStore, SemanticPromptStoreError @@ -25,9 +26,10 @@ export type AgentOwnedAcpPromptOptions = { bindingId: string controllerId: string workspaceDirectory: string - workMode: 'ask' | 'execute' expectedModel?: string process: RuntimeAcpProcessOwner + transport?: AgentAcpConnection + prepareSession?: (sessionId: string, operationId: string, workMode: 'ask' | 'execute') => Promise transcript: SemanticPromptStore completePrompt: ( operationId: string, @@ -44,18 +46,18 @@ export type AgentOwnedAcpPromptOptions = { } /** - * Owns the ACP ClientSideConnection and the original prompt Promise. No - * transport-connection signal is part of this lifetime. + * Owns a native Session and its original prompt Promise. Desktop connection + * signals are not part of this lifetime; the native transport may be shared. */ export class AgentOwnedAcpPrompt { readonly #options: AgentOwnedAcpPromptOptions readonly #connection: ClientSideConnection - readonly #input: ReadableStream - readonly #unsubscribeOutput: () => void - readonly #unsubscribeExit: () => void + readonly #transport: AgentAcpConnection + #unregisterSession?: () => void readonly #processExit: Promise #sessionId?: string #promptPromise?: Promise + #promptSettled?: Promise #closed = false #initialized = false readonly #questions = new Map | undefined - let rejectProcessExit!: (error: Error) => void - this.#processExit = new Promise((_resolve, reject) => { - rejectProcessExit = reject - }) - void this.#processExit.catch(() => undefined) - this.#input = new ReadableStream({ - start(value) { - controller = value - } - }) - const output = new WritableStream({ - write: async (chunk) => { - await options.process.writeStdin(chunk) - } - }) - const unsubscribe = options.process.subscribeOutput((event) => { - if ( - event.stream === 'stdout' && - event.data.byteLength > 0 && - !this.#closed - ) { - controller?.enqueue(event.data.slice()) - } - }) - this.#unsubscribeOutput = unsubscribe ?? (() => undefined) - const unsubscribeExit = options.process.subscribeExit?.(() => { - if (this.#closed) { - return - } - const error = new Error('ACP Runtime process exited') - this.#acceptQuestions = false - this.#questions.clear() - controller?.error(error) - controller = undefined - rejectProcessExit(error) - }) - this.#unsubscribeExit = unsubscribeExit ?? (() => undefined) - const stream = ndJsonStream(output, this.#input) - this.#connection = - options.createConnection?.(() => this.#client(), stream) ?? - new ClientSideConnection(() => this.#client(), stream) + this.#transport = options.transport ?? new AgentAcpConnection( + options.process, () => this.#client(), options.createConnection + ) + this.#connection = this.#transport.connection + this.#processExit = this.#transport.exited } + get sessionId(): string | undefined { return this.#sessionId } + async start( - request: RemoteOwnedPromptStartRequest + request: RemoteOwnedPromptStartRequest, + workMode: 'ask' | 'execute' ): Promise { if ( request.bindingId !== this.#options.bindingId @@ -142,7 +111,7 @@ export class AgentOwnedAcpPrompt { if (!this.#initialized) { const initialization = await this.#whileProcessAlive( - this.#connection.initialize({ + this.#transport.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: { terminal: false, @@ -170,7 +139,7 @@ export class AgentOwnedAcpPrompt { }) ) } else if ( - initialization.agentCapabilities?.sessionCapabilities?.resume === true + initialization.agentCapabilities?.sessionCapabilities?.resume != null ) { await this.#whileProcessAlive( this.#connection.resumeSession({ @@ -210,6 +179,7 @@ export class AgentOwnedAcpPrompt { throw new Error('ACP Runtime did not select the prepared model') } } + this.#unregisterSession = this.#transport.register(this.#sessionId!, this.#client()) this.#initialized = true } else if ( request.acpSessionId !== undefined && @@ -217,8 +187,10 @@ export class AgentOwnedAcpPrompt { ) { throw new Error('ACP session identity cannot change within a binding') } + await this.#options.prepareSession?.(this.#sessionId!, request.operationId, workMode) this.#active = { - operationId: request.operationId + operationId: request.operationId, + workMode } this.#acceptQuestions = true const begun = this.#options.transcript.begin({ @@ -229,13 +201,14 @@ export class AgentOwnedAcpPrompt { }) // Keep this exact Promise alive on Agent. The caller receives only the // durable operation identity and may disconnect immediately. - this.#promptPromise = this.#whileProcessAlive( + const promptOperation = this.#whileProcessAlive( this.#connection.prompt({ - sessionId: this.#sessionId, + sessionId: this.#sessionId!, prompt: request.prompt }) ) - .then( + this.#promptSettled = promptOperation.then(() => undefined, () => undefined) + this.#promptPromise = promptOperation.then( async (response) => { const proposed = response.stopReason === 'cancelled' @@ -305,6 +278,17 @@ export class AgentOwnedAcpPrompt { this.#clear() } + async releaseSession(): Promise { + await this.cancel() + if (this.#sessionId && !this.#closed) { + await this.#connection.closeSession({ sessionId: this.#sessionId }) + } + // Wait for the original native Prompt, not its completion callback, which + // may be queued behind the caller's backend control operation. + await this.#promptSettled + this.close() + } + async respondToQuestion(request: RemoteQuestionResponse): Promise { const pending = this.#questions.get(request.questionId) if (this.#closed || !pending || this.#active?.operationId !== request.operationId || @@ -384,14 +368,15 @@ export class AgentOwnedAcpPrompt { #handlePermission( request: RequestPermissionRequest ): RequestPermissionResponse { + const active = this.#requireActive() const selected = - this.#options.workMode === 'execute' - ? request.options.find((option) => option.kind === 'allow_always') ?? - request.options.find((option) => option.kind === 'allow_once') - : request.toolCall.kind === 'read' + active.workMode === 'execute' + ? request.options.find((option) => option.kind === 'allow_once') ?? + request.options.find((option) => option.kind === 'allow_always') + : request.toolCall.kind === 'read' || request.toolCall.kind === 'search' ? request.options.find((option) => option.kind === 'allow_once') - : request.options.find((option) => option.kind === 'reject_always') ?? - request.options.find((option) => option.kind === 'reject_once') + : request.options.find((option) => option.kind === 'reject_once') ?? + request.options.find((option) => option.kind === 'reject_always') const response: RequestPermissionResponse = selected === undefined ? { outcome: { outcome: 'cancelled' } } @@ -403,7 +388,7 @@ export class AgentOwnedAcpPrompt { } this.#options.transcript.append({ bindingId: this.#options.bindingId, - operationId: this.#requireActive().operationId, + operationId: active.operationId, kind: 'permission-decision', payload: { sessionId: request.sessionId, @@ -434,15 +419,15 @@ export class AgentOwnedAcpPrompt { this.#closed = true this.#acceptQuestions = false this.#questions.clear() - this.#unsubscribeOutput() - this.#unsubscribeExit() + this.#unregisterSession?.() + if (!this.#options.transport) this.#transport.dispose() } async #whileProcessAlive(operation: Promise): Promise { return await Promise.race([operation, this.#processExit]) } - #requireActive(): NonNullable { + #requireActive(): { operationId: string; workMode: 'ask' | 'execute' } { if (this.#active === undefined) { throw new Error('ACP notification has no active prompt') } diff --git a/src/agent-daemon/cli.ts b/src/agent-daemon/cli.ts index 04b229bc..61b1a870 100644 --- a/src/agent-daemon/cli.ts +++ b/src/agent-daemon/cli.ts @@ -186,6 +186,7 @@ async function runModelBridgeHelper( 'model', 'supports-image-input', 'work-mode', + 'shared-sessions', 'opencode-entrypoint' ]) requireOptions(options, [ @@ -212,6 +213,9 @@ async function runModelBridgeHelper( if (workMode !== 'ask' && workMode !== 'execute') { throw new Error('Invalid model bridge work-mode option') } + if (options['shared-sessions'] !== undefined && options['shared-sessions'] !== 'true') { + throw new Error('Invalid model bridge shared-sessions option') + } return await ( dependencies.runModelBridgeHelper ?? runOpenCodeModelBridgeHelper @@ -221,6 +225,7 @@ async function runModelBridgeHelper( model: options.model!, supportsImageInput: imageInput === 'true', workMode, + ...(options['shared-sessions'] === 'true' ? { sharedSessions: true } : {}), opencodeEntrypoint: options['opencode-entrypoint']! }) } diff --git a/src/agent-daemon/diagnostic-log.ts b/src/agent-daemon/diagnostic-log.ts index b2d64e43..9a5a5612 100644 --- a/src/agent-daemon/diagnostic-log.ts +++ b/src/agent-daemon/diagnostic-log.ts @@ -513,7 +513,7 @@ function diagnosticErrorName(error: unknown): string { return error instanceof Error ? 'Error' : 'NonError' } -const SAFE_DIAGNOSTIC_ERROR_CODES = new Set([ +const SAFE_DIAGNOSTIC_ERROR_CODES: ReadonlySet = new Set([ 'EACCES', 'EADDRINUSE', 'ECONNREFUSED', diff --git a/src/agent-daemon/direct-linux-stdio-process-owner.test.ts b/src/agent-daemon/direct-linux-stdio-process-owner.test.ts index 5e275942..b59da214 100644 --- a/src/agent-daemon/direct-linux-stdio-process-owner.test.ts +++ b/src/agent-daemon/direct-linux-stdio-process-owner.test.ts @@ -24,6 +24,32 @@ afterEach(() => { }) describe('direct Linux stdio Runtime ownership', () => { + it('keeps shared stdin available across Session completion without a cumulative prompt quota', async () => { + const registry = createRegistry() + const child = fakeChild() + const owner = await launchDirectLinuxStdioProcessOwner({ + manifest: manifest(), profile: profile(), sharedSessions: true, + identity: { launchId: 'shared-launch', processId: 'shared-process' }, + installationId: 'installation-1', registry, platform: 'linux', + deadlineAt: '2030-01-01T00:00:00.000Z', maximumInputBytes: 16, + spawn: () => { queueMicrotask(() => child.emit('spawn')); return child }, + randomOwnerToken: () => 'a'.repeat(32), + readProcessIdentity: async () => identity() + }) + try { + for (let index = 0; index < 8; index++) { + owner.beginPrompt({ deadlineAt: '2030-01-01T00:00:00.000Z', maximumInputBytes: 16 }) + await owner.writeStdin(Uint8Array.from(Buffer.from('12345678'))) + await owner.completePrompt() + } + await owner.writeStdin(Uint8Array.from(Buffer.from('cancel-peer'))) + } finally { + child.exitCode = 0 + child.emit('close', 0, null) + registry.close() + } + }) + it('spawns Ask directly without systemd using detached fixed stdio', async () => { const registry = createRegistry() const child = fakeChild() diff --git a/src/agent-daemon/direct-linux-stdio-process-owner.ts b/src/agent-daemon/direct-linux-stdio-process-owner.ts index 1ea15759..00e3e733 100644 --- a/src/agent-daemon/direct-linux-stdio-process-owner.ts +++ b/src/agent-daemon/direct-linux-stdio-process-owner.ts @@ -108,6 +108,7 @@ export type DirectLinuxStdioProcessOwnerOptions = { bridgeDirectory: string socketPath: string policy: ModelBridgePolicy + sharedSessions?: boolean } } identity: { launchId: string; processId: string } @@ -115,6 +116,7 @@ export type DirectLinuxStdioProcessOwnerOptions = { registry: RuntimeOwnerRegistry deadlineAt: string maximumInputBytes: number + sharedSessions?: boolean signal?: AbortSignal platform?: NodeJS.Platform spawn?: DirectLinuxStdioSpawn @@ -151,6 +153,7 @@ export class DirectLinuxStdioProcessOwner { readonly processIdentity: LinuxRuntimeProcessIdentity readonly #child: DirectLinuxStdioChild + readonly #sharedSessions: boolean readonly #registry: RuntimeOwnerRegistry readonly #now: () => number readonly #readIdentity: typeof readLinuxRuntimeProcessIdentity @@ -194,6 +197,7 @@ export class DirectLinuxStdioProcessOwner { listPidNamespaceMembers: typeof listLinuxPidNamespaceMembers sendSignal: typeof process.kill maximumInputBytes: number + sharedSessions?: boolean maximumPendingStdinBytes: number maximumStdinWriteBytes: number maximumOutputBytes: number @@ -208,6 +212,7 @@ export class DirectLinuxStdioProcessOwner { this.ownerId = input.ownerId this.processIdentity = Object.freeze({ ...input.processIdentity }) this.#child = input.child + this.#sharedSessions = input.sharedSessions === true this.#registry = input.registry this.#now = input.now this.#readIdentity = input.readProcessIdentity @@ -222,7 +227,7 @@ export class DirectLinuxStdioProcessOwner { input.maximumPromptRuntimeMilliseconds this.#maximumOutputQueueChunks = input.maximumOutputQueueChunks this.#maximumListeners = input.maximumListeners - this.#scheduleDeadline(input.deadlineAt) + if (!this.#sharedSessions) this.#scheduleDeadline(input.deadlineAt) input.child.stdout.on('data', (chunk: Buffer | Uint8Array) => { this.#acceptOutput(chunk) }) @@ -245,13 +250,13 @@ export class DirectLinuxStdioProcessOwner { } if ( payload.byteLength > this.#maximumStdinWriteBytes || - this.#inputBytes + payload.byteLength > this.#maximumInputBytes || + (!this.#sharedSessions && this.#inputBytes + payload.byteLength > this.#maximumInputBytes) || this.#pendingInputBytes + payload.byteLength > this.#maximumPendingStdinBytes ) { throw capacityError('Runtime stdin quota reached') } const copy = Buffer.from(payload) - this.#inputBytes += copy.byteLength + if (!this.#sharedSessions) this.#inputBytes += copy.byteLength this.#pendingInputBytes += copy.byteLength const write = this.#writeTail.then(async () => { if (this.#closed || this.#stdinClosed || !this.#promptActive) { @@ -268,6 +273,7 @@ export class DirectLinuxStdioProcessOwner { } beginPrompt(input: { deadlineAt: string; maximumInputBytes: number }): void { + if (this.#sharedSessions && !this.#closed && !this.#stdinClosed) return if (this.#closed || this.#stdinClosed || this.#promptActive) { throw new DirectLinuxStdioProcessOwnerError( this.#promptActive ? 'A Runtime prompt is already active' : 'Runtime process is closed', @@ -293,6 +299,7 @@ export class DirectLinuxStdioProcessOwner { } async completePrompt(): Promise { + if (this.#sharedSessions) return if (!this.#promptActive) return await this.#writeTail this.#promptActive = false @@ -655,6 +662,7 @@ export async function launchDirectLinuxStdioProcessOwner( options.listPidNamespaceMembers ?? listLinuxPidNamespaceMembers, sendSignal: options.sendSignal ?? process.kill, maximumInputBytes, + sharedSessions: options.sharedSessions, maximumPendingStdinBytes, maximumStdinWriteBytes, maximumOutputBytes: manifest.limits.maximumPromptOutputBytes, @@ -975,7 +983,7 @@ function defaultSpawn( args: readonly string[], options: Parameters[2] ): DirectLinuxStdioChild { - return nodeSpawn(executable, [...args], options) + return nodeSpawn(executable, [...args], { ...options, stdio: [...options.stdio] }) } async function waitForSpawn( diff --git a/src/agent-daemon/model-bridge-helper.ts b/src/agent-daemon/model-bridge-helper.ts index dfa03f83..4d95e9dd 100644 --- a/src/agent-daemon/model-bridge-helper.ts +++ b/src/agent-daemon/model-bridge-helper.ts @@ -132,24 +132,27 @@ const FORWARDED_HEADER_NAMES = [ export class ModelBridgeLoopbackProxy { readonly #exchange: ModelBridgeExchange + readonly #sharedSessions: boolean + readonly #sessionRoutes = new Map() readonly #routeToken: string readonly #maximumConnections: number readonly #requestTimeoutMs: number readonly #sockets = new Set() - readonly #dispatchWaiters: DispatchWaiter[] = [] + readonly #dispatches = new Map() #server?: Server #origin?: string - #dispatchBusy = false #closing = false #closePromise?: Promise constructor(options: { exchange: ModelBridgeExchange + sharedSessions?: boolean routeToken?: string maximumConnections?: number requestTimeoutMs?: number }) { this.#exchange = options.exchange + this.#sharedSessions = options.sharedSessions === true this.#routeToken = parseModelBridgeRouteToken( options.routeToken ?? randomBytes(32).toString('base64url') @@ -259,6 +262,7 @@ export class ModelBridgeLoopbackProxy { ) } finally { this.#sockets.clear() + this.#sessionRoutes.clear() this.#closing = false } } @@ -280,6 +284,9 @@ export class ModelBridgeLoopbackProxy { let responseFlushed = false let deliveryAcknowledged = false let dispatchHeld = false + let dispatchKey = '' + let sessionId: string | undefined + let sessionRoute: { operationId: string; socketPath: string } | undefined const onAborted = (): void => { cancellation.abort( new ModelBridgeBrokerError('request-cancelled') @@ -303,14 +310,58 @@ export class ModelBridgeLoopbackProxy { timeout?.unref?.() try { + if (this.#sharedSessions && incoming.url?.startsWith(`/${this.#routeToken}/session`)) { + const url = new URL(incoming.url, 'http://localhost') + if (url.pathname !== `/${this.#routeToken}/session`) throw new HttpRequestError(404, 'path-not-allowed') + if (incoming.method === 'GET') { + const route = this.#sessionRoutes.get(url.searchParams.get('sessionId') ?? '') + if (!route) throw new HttpRequestError(409, 'session-inactive') + outgoing.setHeader('content-type', 'application/json') + outgoing.end(JSON.stringify({ operationId: route.operationId, workMode: route.workMode })) + return + } + if (incoming.method !== 'POST') throw new HttpRequestError(405, 'method-not-allowed') + const value = JSON.parse((await readBoundedBody(incoming)).toString('utf8')) as { + sessionId?: string; operationId?: string; socketPath?: string; release?: boolean; workMode?: 'ask' | 'execute' + } + if ( + typeof value.sessionId !== 'string' || value.sessionId.length > 128 || + typeof value.operationId !== 'string' || value.operationId.length > 128 + ) throw new HttpRequestError(400, 'request-invalid') + if (value.release) { + if (this.#sessionRoutes.get(value.sessionId)?.operationId === value.operationId) { + this.#sessionRoutes.delete(value.sessionId) + } + } else { + if (value.workMode !== 'ask' && value.workMode !== 'execute') throw new HttpRequestError(400, 'request-invalid') + const socketPath = normalizedAbsolutePath(value.socketPath ?? '', 'Model bridge socket') + this.#sessionRoutes.set(value.sessionId, { operationId: value.operationId, socketPath, workMode: value.workMode }) + } + outgoing.end('{}') + return + } const request = await parseHttpRequest( incoming, this.#routeToken ) - await this.#acquireDispatch(cancellation.signal) + let exchange = this.#exchange + if (this.#sharedSessions) { + const sessionHeader = incoming.headers['x-goodbuddy-session'] + const operationId = incoming.headers['x-goodbuddy-operation'] + sessionId = typeof sessionHeader === 'string' ? sessionHeader : undefined + const route = sessionId ? this.#sessionRoutes.get(sessionId) : undefined + if (!route || route.operationId !== operationId) throw new HttpRequestError(409, 'session-inactive') + sessionRoute = route + dispatchKey = `${sessionId}\0${operationId}` + exchange = createUnixModelBridgeExchange({ socketPath: route.socketPath }) + } + await this.#acquireDispatch(dispatchKey, cancellation.signal) dispatchHeld = true + if (sessionId && this.#sessionRoutes.get(sessionId) !== sessionRoute) { + throw new HttpRequestError(409, 'session-inactive') + } dispatched = normalizeExchangeResult( - await raceWithAbort(this.#exchange(request, { + await raceWithAbort(exchange(request, { requestId: `model-${randomUUID()}`, signal: cancellation.signal }), cancellation.signal) @@ -351,34 +402,36 @@ export class ModelBridgeLoopbackProxy { incoming.off('aborted', onAborted) outgoing.off('close', onResponseClose) if (dispatchHeld) { - this.#releaseDispatch() + this.#releaseDispatch(dispatchKey) } } } - async #acquireDispatch(signal: AbortSignal): Promise { + async #acquireDispatch(key: string, signal: AbortSignal): Promise { signal.throwIfAborted() - if (!this.#dispatchBusy) { - this.#dispatchBusy = true + const queue = this.#dispatches.get(key) + if (!queue) { + this.#dispatches.set(key, []) return } await new Promise((resolve, reject) => { const waiter: DispatchWaiter = { resolve, reject, signal } waiter.abort = (): void => { - const index = this.#dispatchWaiters.indexOf(waiter) + const index = queue.indexOf(waiter) if (index >= 0) { - this.#dispatchWaiters.splice(index, 1) + queue.splice(index, 1) } reject(signal.reason) } signal.addEventListener('abort', waiter.abort, { once: true }) - this.#dispatchWaiters.push(waiter) + queue.push(waiter) }) } - #releaseDispatch(): void { - while (this.#dispatchWaiters.length > 0) { - const waiter = this.#dispatchWaiters.shift()! + #releaseDispatch(key: string): void { + const queue = this.#dispatches.get(key) + while (queue && queue.length > 0) { + const waiter = queue.shift()! if (waiter.abort !== undefined) { waiter.signal.removeEventListener('abort', waiter.abort) } @@ -389,7 +442,7 @@ export class ModelBridgeLoopbackProxy { waiter.resolve() return } - this.#dispatchBusy = false + this.#dispatches.delete(key) } } @@ -399,6 +452,7 @@ export async function runOpenCodeModelBridgeHelper(options: { model: string supportsImageInput: boolean workMode: 'ask' | 'execute' + sharedSessions?: boolean opencodeEntrypoint: string environment?: Readonly spawn?: ModelBridgeHelperSpawn @@ -412,19 +466,19 @@ export async function runOpenCodeModelBridgeHelper(options: { 'OpenCode entrypoint' ) const exchange = createUnixModelBridgeExchange({ socketPath }) - const proxy = new ModelBridgeLoopbackProxy({ exchange }) + const proxy = new ModelBridgeLoopbackProxy({ exchange, sharedSessions: options.sharedSessions }) const origin = await proxy.listen() let pluginDirectory: string | undefined try { pluginDirectory = await mkdtemp(join(tmpdir(), 'goodbuddy-opencode-plugin-')) const pluginPath = join(pluginDirectory, 'subagent.mjs') - await writeFile(pluginPath, openCodeSubagentPluginSource(), 'utf8') + await writeFile(pluginPath, openCodeSubagentPluginSource(options.sharedSessions ? origin : undefined), 'utf8') const config = createOpenCodeModelBridgeProviderConfig({ protocol: options.protocol, model: options.model, loopbackOrigin: origin, supportsImageInput: options.supportsImageInput, - workMode: options.workMode + workMode: options.sharedSessions ? 'ask' : options.workMode }) const environment = credentialFreeHelperEnvironment( options.environment ?? process.env, @@ -433,6 +487,11 @@ export async function runOpenCodeModelBridgeHelper(options: { plugin: [pathToFileURL(pluginPath).href] }) ) + if (options.sharedSessions) { + process.stdout.write(JSON.stringify({ + jsonrpc: '2.0', method: 'goodbuddy/modelBridgeReady', params: { origin } + }) + '\n') + } const child = (options.spawn ?? defaultHelperSpawn)( opencodeEntrypoint, ['acp'], diff --git a/src/agent-daemon/model-bridge.test.ts b/src/agent-daemon/model-bridge.test.ts index 5cb2b389..3162d3d9 100644 --- a/src/agent-daemon/model-bridge.test.ts +++ b/src/agent-daemon/model-bridge.test.ts @@ -615,6 +615,50 @@ describe('model bridge loopback helper', () => { describe('model bridge Unix broker', () => { const runOnUnix = process.platform === 'win32' ? it.skip : it + runOnUnix('routes shared Sessions concurrently and rejects queued work after its operation ends', async () => { + let unblock!: () => void + const blocked = new Promise(resolve => { unblock = resolve }) + const dispatchOne = vi.fn(async () => { await blocked; return validResponse }) + const dispatchTwo = vi.fn(async () => validResponse) + const brokers = [dispatchOne, dispatchTwo].map(dispatch => new ModelBridgeBrokerServer({ + scratchDirectory: privateTemporaryDirectory(), dispatch + })) + const fallback = vi.fn(async () => validResponse) + const proxy = new ModelBridgeLoopbackProxy({ exchange: fallback, sharedSessions: true }) + await Promise.all(brokers.map(broker => broker.listen())) + const origin = await proxy.listen() + const route = (sessionId: string, operationId: string, index: number) => fetch(`${origin}/session`, { + method: 'POST', body: JSON.stringify({ sessionId, operationId, socketPath: brokers[index]!.socketPath, workMode: 'execute' }) + }) + const prompt = (sessionId: string, operationId: string) => fetch(`${origin}/v1/messages`, { + method: 'POST', headers: { 'content-type': 'application/json', 'x-goodbuddy-session': sessionId, 'x-goodbuddy-operation': operationId }, + body: '{"model":"fixture"}' + }) + try { + expect((await route('one', 'operation-1', 0)).ok).toBe(true) + expect((await route('two', 'operation-2', 1)).ok).toBe(true) + const first = prompt('one', 'operation-1') + await vi.waitFor(() => expect(dispatchOne).toHaveBeenCalledOnce()) + const queued = prompt('one', 'operation-1') + expect((await prompt('two', 'operation-2')).status).toBe(201) + await route('one', 'next-operation', 0) + unblock() + expect((await first).status).toBe(201) + expect((await queued).status).toBe(409) + expect((await prompt('one', 'operation-1')).status).toBe(409) + expect((await prompt('one', 'next-operation')).status).toBe(201) + expect(dispatchOne).toHaveBeenCalledTimes(2) + expect(fallback).not.toHaveBeenCalled() + for (const dispatch of [dispatchOne, dispatchTwo]) { + expect(dispatch.mock.calls[0]?.[0]).not.toHaveProperty('headers.x-goodbuddy-session') + } + } finally { + unblock() + await proxy.close() + await Promise.all(brokers.map(broker => broker.close())) + } + }) + it('allows a delayed connection with the default unlimited request timeout', async () => { const socket = new Duplex({ read() {}, diff --git a/src/agent-daemon/opencode-runtime-profile.ts b/src/agent-daemon/opencode-runtime-profile.ts index 1957f9de..a856ed3d 100644 --- a/src/agent-daemon/opencode-runtime-profile.ts +++ b/src/agent-daemon/opencode-runtime-profile.ts @@ -41,6 +41,7 @@ export function createOpenCodeLaunchProfile(input: { bridgeDirectory: string; socketPath: string; policy: ModelBridgePolicy; + sharedSessions?: boolean; }; }): OpenCodeLaunchProfile { const manifest = remoteRuntimeBundleManifestSchema.parse(input.manifest); @@ -90,6 +91,7 @@ export function createOpenCodeLaunchProfile(input: { "Model bridge socket", ), policy: modelBridgePolicySchema.parse(input.modelBridge.policy), + sharedSessions: input.modelBridge.sharedSessions === true, }; if ( modelBridge !== undefined && @@ -132,6 +134,7 @@ export function createOpenCodeLaunchProfile(input: { modelBridge.policy.supportsImageInput ? "true" : "false", "--work-mode", input.workMode, + ...(modelBridge.sharedSessions ? ["--shared-sessions", "true"] : []), "--opencode-entrypoint", executablePath, ], diff --git a/src/agent-daemon/opencode-subagent-plugin.test.ts b/src/agent-daemon/opencode-subagent-plugin.test.ts index 3b6e393f..e3b7942c 100644 --- a/src/agent-daemon/opencode-subagent-plugin.test.ts +++ b/src/agent-daemon/opencode-subagent-plugin.test.ts @@ -5,6 +5,65 @@ import { describe, expect, it, vi } from 'vitest' import { openCodeSubagentPluginSource } from './opencode-subagent-plugin' describe('OpenCode ACP child event plugin', () => { + it('attributes child and compaction model rounds to the frozen root operation', async () => { + const operations = new Map([['one', 'operation-1'], ['two', 'operation-2']]) + const fetchRoute = vi.fn(async (url: string) => { + const session = new URL(url).searchParams.get('sessionId')! + return { ok: operations.has(session), json: async () => ({ operationId: operations.get(session), workMode: 'execute' }) } + }) + const factory = runInNewContext( + `(${openCodeSubagentPluginSource('http://127.0.0.1:12345/fixture').replace(/^import .*\n/gmu, '').replace('export default ', '')})`, + { process: { stdout: { write: vi.fn() } }, createServer, randomBytes, fetch: fetchRoute } + ) + const patch = vi.fn() + const hooks = await factory({ client: { + _client: { get: vi.fn(), post: vi.fn(), patch }, + session: { get: async ({ path }: { path: { id: string } }) => ({ + data: { + id: path.id, parentID: path.id === 'child' ? 'one' : undefined, + permission: path.id === 'child' ? [{ permission: 'task', pattern: '*', action: 'deny' }] : undefined + } + }) } + } }) + const headers = async (sessionID: string, id: string) => { + const output = { headers: {} } + await hooks['chat.headers']({ sessionID, message: { id } }, output) + return output.headers + } + try { + await hooks['chat.message']({ sessionID: 'one' }, { message: { id: 'first' } }) + expect(patch).toHaveBeenCalledWith({ + url: '/session/one', body: { permission: [{ permission: '*', pattern: '*', action: 'allow' }] }, throwOnError: true + }) + expect(await headers('child', 'child-message')).toEqual({ + 'x-goodbuddy-session': 'one', 'x-goodbuddy-operation': 'operation-1' + }) + await hooks['chat.message']({ sessionID: 'child' }, { message: { id: 'child-message' } }) + expect(patch).toHaveBeenLastCalledWith({ + url: '/session/child', body: { permission: [ + { permission: '*', pattern: '*', action: 'allow' }, + { permission: 'task', pattern: '*', action: 'deny' } + ] }, throwOnError: true + }) + expect(await headers('two', 'compact')).toEqual({ + 'x-goodbuddy-session': 'two', 'x-goodbuddy-operation': 'operation-2' + }) + operations.set('one', 'next-operation') + expect(await headers('one', 'first')).toEqual({ + 'x-goodbuddy-session': 'one', 'x-goodbuddy-operation': 'operation-1' + }) + await hooks.event({ event: { type: 'session.status', properties: { sessionID: 'one', status: { type: 'idle' } } } }) + expect(await headers('one', 'next-message')).toEqual({ + 'x-goodbuddy-session': 'one', 'x-goodbuddy-operation': 'next-operation' + }) + operations.delete('two') + await expect(headers('two', 'stale-new-message')).rejects.toThrow('no longer active') + expect(await headers('two', 'compact')).toEqual({ + 'x-goodbuddy-session': 'two', 'x-goodbuddy-operation': 'operation-2' + }) + } finally { await hooks.dispose() } + }) + it('authenticates native replies, checks live ownership, rejects stale events and closes its endpoint', async () => { const write = vi.fn() const question = { id: 'q', sessionID: 'grandchild', questions: [ diff --git a/src/agent-daemon/opencode-subagent-plugin.ts b/src/agent-daemon/opencode-subagent-plugin.ts index 1ca5171e..c075bfda 100644 --- a/src/agent-daemon/opencode-subagent-plugin.ts +++ b/src/agent-daemon/opencode-subagent-plugin.ts @@ -2,10 +2,10 @@ * OpenCode's ACP adapter omits native questions and child sessions. Native * events use ACP metadata; question replies use a process-local capability. */ -export function openCodeSubagentPluginSource(): string { +export function openCodeSubagentPluginSource(modelBridgeOrigin?: string): string { return `import { createServer } from 'node:http' import { randomBytes } from 'node:crypto' -export default (input) => (${plugin.toString()})(input, { createServer, randomBytes })` +export default (input) => (${plugin.toString()})(input, { createServer, randomBytes, modelBridgeOrigin: ${JSON.stringify(modelBridgeOrigin)} })` } async function plugin(input?: { @@ -13,17 +13,41 @@ async function plugin(input?: { _client: { get(input: { url: string; throwOnError: true }): Promise<{ data: unknown }> post(input: { url: string; body?: unknown; throwOnError: true }): Promise + patch(input: { url: string; body: unknown; throwOnError: true }): Promise } session: { get(input: { path: { id: string }; throwOnError: true }): Promise<{ - data: { id: string; parentID?: string } + data: { id: string; parentID?: string; permission?: Array<{ permission: string; pattern: string; action: string }> } }> } } }, modules?: { createServer: typeof import('node:http').createServer randomBytes: typeof import('node:crypto').randomBytes + modelBridgeOrigin?: string }) { const tasks = new Map() const pending = new Map() + const modelMessages = new Map() + const modelRoute = async (sessionId: string, messageId: string) => { + const key = `${sessionId}\0${messageId}` + const previous = modelMessages.get(key) + if (previous) return previous + if (!input || !modules?.modelBridgeOrigin) return undefined + let root = sessionId + const seen = new Set() + for (;;) { + if (seen.has(root)) throw new Error('OpenCode Session parent cycle') + seen.add(root) + const session = await input.client.session.get({ path: { id: root }, throwOnError: true }) + if (!session.data.parentID) break + root = session.data.parentID + } + const response = await fetch(`${modules.modelBridgeOrigin}/session?sessionId=${encodeURIComponent(root)}`) + if (!response.ok) throw new Error('GoodBuddy model operation is no longer active') + const { operationId, workMode } = await response.json() as { operationId: string; workMode: 'ask' | 'execute' } + const route = { sessionId: root, operationId, workMode } + modelMessages.set(key, route) + return route + } let endpoint: string | undefined let closed = false let closeServer: (() => void) | undefined @@ -82,8 +106,44 @@ async function plugin(input?: { closed = true pending.clear() tasks.clear() + modelMessages.clear() closeServer?.() }, + 'chat.message': async ( + request: { sessionID: string }, + output: { message: { id: string } } + ) => { + const route = await modelRoute(request.sessionID, output.message.id) + if (!route) return + // Native ACP only forwards permissions for its registered root Sessions. + // Use native Session rules so child tools use Execute and Ask's + // unavailable tools are omitted instead of interrupting the prompt. + const permission = route.workMode === 'execute' + ? [{ permission: '*', pattern: '*', action: 'allow' }] + : [ + { permission: '*', pattern: '*', action: 'deny' }, + ...['read', 'glob', 'grep', 'list', 'lsp', 'webfetch', 'websearch', 'codesearch', 'question', 'external_directory'] + .map(permission => ({ permission, pattern: '*', action: 'allow' })) + ] + if (route.sessionId !== request.sessionID) { + const child = await input!.client.session.get({ path: { id: request.sessionID }, throwOnError: true }) + // OpenCode copies parent denies, not parent allows, into children. + // Keep its explicit subagent restrictions after the request rules. + permission.push(...(child.data.permission ?? []).filter(rule => rule.permission !== '*')) + } + await input!.client._client.patch({ + url: `/session/${encodeURIComponent(request.sessionID)}`, body: { permission }, throwOnError: true + }) + }, + 'chat.headers': async ( + request: { sessionID: string; message: { id: string } }, + output: { headers: Record } + ) => { + const route = await modelRoute(request.sessionID, request.message.id) + if (!route) return + output.headers['x-goodbuddy-session'] = route.sessionId + output.headers['x-goodbuddy-operation'] = route.operationId + }, event: async ({ event }: { event: { type: string properties: { @@ -105,6 +165,9 @@ async function plugin(input?: { const properties = event.properties if (closed) return if (event.type === 'session.status' && properties.status?.type === 'idle' && properties.sessionID) { + for (const [key, route] of modelMessages) { + if (route.sessionId === properties.sessionID) modelMessages.delete(key) + } for (const [id, question] of pending) { if (question.root !== properties.sessionID && question.sessionId !== properties.sessionID) continue pending.delete(id) diff --git a/src/agent-daemon/remote-question-binary.test.ts b/src/agent-daemon/remote-question-binary.test.ts index f41ef4fc..8d8ed855 100644 --- a/src/agent-daemon/remote-question-binary.test.ts +++ b/src/agent-daemon/remote-question-binary.test.ts @@ -74,7 +74,7 @@ it.skipIf(!existsSync(binary)).each(['parent', 'child', 'reject', 'cancel'] as c controllerId: 'controller', preparationDigest: `sha256:${'a'.repeat(64)}`, promptSequence: 0 }) let complete = false const owner = new AgentOwnedAcpPrompt({ bindingId: 'binding', controllerId: 'controller', - workspaceDirectory: root, workMode: 'execute', transcript, + workspaceDirectory: root, transcript, process: { writeStdin: async (data: Uint8Array) => { child.stdin.write(data) }, subscribeOutput: (listener: (event: { stream: string; data: Uint8Array }) => void) => { @@ -88,7 +88,7 @@ it.skipIf(!existsSync(binary)).each(['parent', 'child', 'reject', 'cancel'] as c }) try { await owner.start({ bindingId: 'binding', operationId: 'operation', requestId: 'operation', - prompt: [{ type: 'text', text: 'Ask the native question tool for the decision.' }] }) + prompt: [{ type: 'text', text: 'Ask the native question tool for the decision.' }] }, 'execute') let questionId: string | undefined await expect.poll(async () => { const page = transcript.page({ bindingId: 'binding', operationId: 'operation', diff --git a/src/agent-daemon/runtime-acp-backend.test.ts b/src/agent-daemon/runtime-acp-backend.test.ts index ae07d7ba..fe53b753 100644 --- a/src/agent-daemon/runtime-acp-backend.test.ts +++ b/src/agent-daemon/runtime-acp-backend.test.ts @@ -1,4 +1,7 @@ import { describe, expect, it, vi } from 'vitest' +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' import { type AcpJournalCursor, type RemotePromptOperationPreparation, @@ -7,7 +10,7 @@ import { import type { RemoteRuntimeBundleManifest } from '../shared/remote-runtime-launch-contracts' import type { ControllerLease } from './controller-registry' import { AgentModelGatewayError } from './agent-model-gateway' -import { EventJournalCapacityError } from './event-journal' +import { EventJournal, EventJournalCapacityError } from './event-journal' import type { ProtocolMethodContext } from './protocol-server' import type { ModelBridgeBrokerDispatch, @@ -280,6 +283,8 @@ function harness(input: { uniqueProcesses?: boolean outputGate?: Promise agentOwned?: boolean + shareOwnedProcesses?: boolean + journal?: RuntimeAcpJournal modelGateway?: { dispatch: ReturnType finalizePrompt: ReturnType @@ -319,7 +324,9 @@ function harness(input: { } : undefined const backend = new RuntimeAcpBackend({ - journal, + shareOwnedProcesses: input.shareOwnedProcesses, + sharedProcessIdleMs: 20, + journal: input.journal ?? journal, resolveRuntimeBundle: vi.fn(async () => resolved), loadRegisteredRuntimeBundle: vi.fn(async () => verifiedBundle()), resolveWorkspace: vi.fn(async (preparation) => ({ @@ -444,6 +451,66 @@ function harness(input: { } describe('RuntimeAcpBackend', () => { + it.each([false, true])('allows mode changes only on a shared Session (shared=%s)', async (shared) => { + const fixture = harness({ agentOwned: true, shareOwnedProcesses: shared, workMode: 'execute' }) + try { + await open(fixture) + await invoke(fixture, 'runtime/preparePrompt', fixture.preparation()) + await invoke(fixture, 'runtime/completePrompt', { + bindingId: 'binding-1', operationId: 'request-1', requestId: 'request-1' + }) + const next = fixture.preparation({ + operationId: 'request-2', requestId: 'request-2', promptSequence: 1, workMode: 'ask' + }) + if (shared) { + await expect(invoke(fixture, 'runtime/preparePrompt', next)).resolves.toMatchObject({ workMode: 'ask' }) + await invoke(fixture, 'runtime/completePrompt', { + bindingId: 'binding-1', operationId: 'request-2', requestId: 'request-2' + }) + await expect(invoke(fixture, 'runtime/preparePrompt', fixture.preparation({ + operationId: 'request-3', requestId: 'request-3', promptSequence: 2, workMode: 'execute' + }))).resolves.toMatchObject({ workMode: 'execute' }) + } else { + await expect(invoke(fixture, 'runtime/preparePrompt', next)).rejects.toThrow('work mode cannot change') + } + expect(fixture.launches).toHaveLength(1) + } finally { + await fixture.backend.dispose() + } + }) + + 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 }> = [] + try { + for (let index = 0; index < 4; index++) { + const bindingId = `shared-${index}` + const workspaceIdentity = `workspace-${index % 2}` + const channel = await invoke(fixture, 'runtime/openAcpChannel', { + ...fixture.openRequest, bindingId, workspaceIdentity + }) as (typeof channels)[number] + channels.push({ bindingId, channelId: channel.channelId, channelEpoch: channel.channelEpoch }) + await invoke(fixture, 'runtime/preparePrompt', fixture.preparation({ + bindingId, workspaceIdentity, channelEpoch: channel.channelEpoch, + workMode: index % 2 ? 'execute' : 'ask', + operationId: `operation-${index}`, requestId: `operation-${index}` + })) + } + expect(fixture.launches).toHaveLength(1) + for (const channel of channels.slice(0, 3)) { + await invoke(fixture, 'runtime/closeAcpChannel', { + ...channel, reason: 'released' + }) + } + await new Promise(resolve => setTimeout(resolve, 40)) + expect(fixture.process.stops).toEqual([]) + await invoke(fixture, 'runtime/closeAcpChannel', { ...channels[3], reason: 'released' }) + await vi.waitFor(() => expect(fixture.process.stops).toEqual(['binding-closed'])) + } finally { + await fixture.backend.dispose() + } + }) + it('rejects Agent-first unknown blob channels before prompt authority exists', async () => { const fixture = harness() await expect( @@ -1536,6 +1603,71 @@ describe('RuntimeAcpBackend', () => { ).rejects.toMatchObject({ code: 'stale-controller' }) }) + it('resumes shared semantic bindings with no binary output in the real journal', async () => { + const directory = await mkdtemp(join(tmpdir(), 'goodbuddy-empty-acp-replay-')) + const journal = new EventJournal(join(directory, 'events.sqlite')) + const fixture = harness({ + agentOwned: true, + shareOwnedProcesses: true, + journal + }) + const channels: Array<{ + bindingId: string + channelId: string + channelEpoch: string + }> = [] + try { + for (let index = 0; index < 4; index++) { + const bindingId = `empty-${index}` + const workspaceIdentity = `workspace-${index % 2}` + const channel = await invoke(fixture, 'runtime/openAcpChannel', { + ...fixture.openRequest, bindingId, workspaceIdentity + }) as (typeof channels)[number] + channels.push({ + bindingId, + channelId: channel.channelId, + channelEpoch: channel.channelEpoch + }) + await invoke(fixture, 'runtime/preparePrompt', fixture.preparation({ + bindingId, workspaceIdentity, + channelEpoch: channel.channelEpoch, + operationId: `operation-${index}`, + requestId: `operation-${index}`, + workMode: index % 2 ? 'execute' : 'ask' + })) + expect(journal.getAcpCursor( + bindingId, channel.channelEpoch, 'runtime-to-main' + )).toBeUndefined() + } + fixture.context.abort.abort() + await new Promise(resolve => setImmediate(resolve)) + const resumed = protocolContext({ + generation: 2, + connectionId: 'connection-2' + }) + for (const channel of channels) { + await fixture.backend.methods['runtime/resumeAcpChannel']!( + channel, resumed + ) + await expect(fixture.backend.methods['runtime/replayAcpChannel']!( + { ...channel, acknowledgedSequence: '1' }, resumed + )).rejects.toThrow('ACK exceeds durable output') + await expect(fixture.backend.methods['runtime/replayAcpChannel']!( + { ...channel, acknowledgedSequence: '0' }, resumed + )).resolves.toMatchObject({ + ...channel, replayedThroughSequence: '0', live: true + }) + } + expect(fixture.launches).toHaveLength(1) + expect(fixture.process.stops).toEqual([]) + expect(fixture.process.writes).toEqual([]) + } finally { + await fixture.backend.dispose() + journal.close() + await rm(directory, { recursive: true, force: true }) + } + }) + it('fails closed on supervised process identity conflict', async () => { const fixture = harness() await open(fixture) diff --git a/src/agent-daemon/runtime-acp-backend.ts b/src/agent-daemon/runtime-acp-backend.ts index 843b7fc0..f923f30f 100644 --- a/src/agent-daemon/runtime-acp-backend.ts +++ b/src/agent-daemon/runtime-acp-backend.ts @@ -59,6 +59,7 @@ import type { ProtocolMethodHandler } from './protocol-server' import { AgentOwnedAcpPrompt } from './agent-owned-acp-prompt' +import { AgentAcpConnection } from './agent-acp-connection' import type { SemanticPromptStore } from './semantic-prompt-store' import { AgentModelGatewayError, @@ -83,6 +84,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 OWNED_PROMPT_START_TIMEOUT_MS = 2 * 60_000 const ACP_JOURNAL_RETRY_INITIAL_MILLISECONDS = 25 const ACP_JOURNAL_RETRY_MAXIMUM_MILLISECONDS = 1_000 @@ -228,6 +230,7 @@ export type RuntimeAcpProcessLaunch = { workMode: 'ask' | 'execute' deadlineAt: string budget: RemotePromptOperationPreparation['budget'] + sharedSessions?: boolean modelBridge?: { socketPath: string policy: ModelBridgePolicy @@ -249,6 +252,8 @@ export type RuntimeAcpBackendLimits = { } export type RuntimeAcpBackendOptions = { + shareOwnedProcesses?: boolean + sharedProcessIdleMs?: number journal: RuntimeAcpJournal resolveRuntimeBundle( runtimeId: string, @@ -298,6 +303,7 @@ type PreparedOperation = { budget: RemotePromptOperationPreparation['budget'] completion?: z.infer terminalState?: 'completed' | 'failed' | 'cancelled' | 'interrupted' + processTree?: 'running' | 'empty' modelProfile?: AgentPromptModelProfile promptSequence: number ownedPromptStarted?: boolean @@ -314,6 +320,7 @@ type BindingState = { state: 'open' | 'running' | 'stopping' | 'closed' | 'outcome-unknown' workMode?: 'ask' | 'execute' process?: RuntimeAcpProcessOwner + sharedProcess?: SharedProcess unsubscribeOutput?: () => void unsubscribeExit?: () => void stopRequested: boolean @@ -338,12 +345,23 @@ type BindingState = { poisoned: boolean } +type SharedProcess = { + key: string + process: RuntimeAcpProcessOwner + transport: AgentAcpConnection + bindings: Set + unsubscribeExit?: () => void + idleTimer?: NodeJS.Timeout + exited: boolean +} + export class RuntimeAcpBackend { readonly methods: Readonly> readonly #options: RuntimeAcpBackendOptions readonly #limits: RuntimeAcpBackendLimits readonly #bindings = new Map() readonly #processIdentityBindings = new Map() + readonly #sharedProcesses = new Map() readonly #boundConnectionSignals = new WeakSet() #nextChannelEpoch: bigint #lifetimeBindings = 0 @@ -636,6 +654,14 @@ export class RuntimeAcpBackend { result.status === 'rejected' ) .map((result) => result.reason) + const sharedResults = await Promise.allSettled( + [...this.#sharedProcesses.values()].map(group => + this.#stopSharedProcess(group, 'binding-closed') + ) + ) + for (const result of sharedResults) { + if (result.status === 'rejected') failures.push(result.reason) + } if (failures.length > 0) { throw new AggregateError( failures, @@ -930,11 +956,15 @@ export class RuntimeAcpBackend { 'identity' ) } - this.#options.journal.acknowledgeAcpFromMain?.({ - bindingId: binding.request.bindingId, - channelEpoch: binding.openResult.channelEpoch, - sequence: request.acknowledgedSequence - }) + // Agent-owned prompts may have semantic events but no binary journal. + // In that case the validated zero cursor has nothing to acknowledge. + if (initialCursor !== undefined) { + this.#options.journal.acknowledgeAcpFromMain?.({ + bindingId: binding.request.bindingId, + channelEpoch: binding.openResult.channelEpoch, + sequence: request.acknowledgedSequence + }) + } if (this.#options.journal.replayAcpFrames === undefined) { throw new RuntimeAcpBackendError( 'Durable ACP replay is unavailable', @@ -1011,7 +1041,8 @@ export class RuntimeAcpBackend { } if ( binding.workMode !== undefined && - binding.workMode !== preparation.workMode + binding.workMode !== preparation.workMode && + binding.sharedProcess === undefined ) { throw new RuntimeAcpBackendError( 'Runtime process work mode cannot change', @@ -1124,12 +1155,22 @@ export class RuntimeAcpBackend { if (binding.process === undefined) { let process: RuntimeAcpProcessOwner + const sharedKey = + this.#options.shareOwnedProcesses && + preparation.modelProfile && this.#options.modelGateway && + this.#options.semanticPrompts && binding.modelBridgePolicy + ? canonicalJson([ + verified.manifest.bundleDigest, + verified.manifest.adapterDigest, + binding.modelBridgePolicy + ]) + : undefined this.#options.diagnostics?.tryRecord('runtime.starting', { runtimeId: verified.manifest.runtimeId, workMode: preparation.workMode }) try { - process = await this.#options.launchProcess({ + const launch: RuntimeAcpProcessLaunch = { manifest: verified.manifest, bundle: verified, workspace, @@ -1137,6 +1178,7 @@ export class RuntimeAcpBackend { workMode: preparation.workMode, deadlineAt: preparation.deadlineAt, budget: preparation.budget, + ...(sharedKey === undefined ? {} : { sharedSessions: true }), ...(binding.modelBridgePolicy === undefined || binding.modelBridgeBroker === undefined ? {} @@ -1146,7 +1188,15 @@ export class RuntimeAcpBackend { policy: binding.modelBridgePolicy } }) - }) + } + if (sharedKey !== undefined) { + const group = await this.#acquireSharedProcess(sharedKey, launch) + binding.sharedProcess = group + group.bindings.add(binding) + process = group.process + } else { + process = await this.#options.launchProcess(launch) + } assertProcessIdentity(process.identity) } catch (error) { this.#options.diagnostics?.tryRecord('runtime.start.failed', { @@ -1166,7 +1216,7 @@ export class RuntimeAcpBackend { const identityOwner = this.#processIdentityBindings.get(processKey) if ( identityOwner !== undefined && - identityOwner !== binding.request.bindingId + identityOwner !== (binding.sharedProcess?.key ?? binding.request.bindingId) ) { await Promise.resolve( process.stop({ @@ -1182,7 +1232,7 @@ export class RuntimeAcpBackend { } this.#processIdentityBindings.set( processKey, - binding.request.bindingId + binding.sharedProcess?.key ?? binding.request.bindingId ) binding.process = process binding.workspaceDirectory = workspace.workspaceDirectory @@ -1208,7 +1258,7 @@ export class RuntimeAcpBackend { binding.unsubscribeOutput = unsubscribe } } - const unsubscribeExit = process.subscribeExit?.(() => { + const unsubscribeExit = binding.sharedProcess ? undefined : process.subscribeExit?.(() => { void this.#enqueueControl(async () => { await this.#handleProcessExit(binding) }).catch(() => undefined) @@ -1230,7 +1280,7 @@ export class RuntimeAcpBackend { runtimeId: verified.manifest.runtimeId, workMode: preparation.workMode }) - } else { + } else if (!binding.sharedProcess) { try { await binding.process.beginPrompt({ deadlineAt: preparation.deadlineAt, @@ -1246,6 +1296,7 @@ export class RuntimeAcpBackend { ) } } + binding.workMode = preparation.workMode binding.inputBytes = 0 if (!binding.operations.has(preparation.operationId)) { binding.operations.set(preparation.operationId, { @@ -1389,12 +1440,14 @@ export class RuntimeAcpBackend { 'conflict' ) } + if (Buffer.byteLength(canonicalJson(request.prompt)) > prepared.budget.maximumInputBytes) { + throw new RuntimeAcpBackendError('Prompt input quota reached', 'capacity') + } if (binding.ownedAcp === undefined) { binding.ownedAcp = new AgentOwnedAcpPrompt({ bindingId: binding.request.bindingId, controllerId: binding.controllerId, workspaceDirectory: binding.workspaceDirectory, - workMode: binding.workMode!, ...(binding.modelBridgePolicy === undefined ? {} : { @@ -1404,6 +1457,14 @@ export class RuntimeAcpBackend { ) }), process: binding.process, + ...(binding.sharedProcess ? { + transport: binding.sharedProcess.transport, + prepareSession: async (sessionId: string, operationId: string, workMode: 'ask' | 'execute') => { + await binding.sharedProcess!.transport.setModelRoute( + sessionId, operationId, binding.modelBridgeBroker!.socketPath, workMode + ) + } + } : {}), transcript: this.#options.semanticPrompts, completePrompt: async (operationId, status) => { await this.#enqueueControl(async () => { @@ -1418,7 +1479,7 @@ export class RuntimeAcpBackend { binding.poisoned ? 'outcome-unknown' : status }) } - const result = await binding.ownedAcp.start(request) + const result = await binding.ownedAcp.start(request, prepared.acceptance.workMode) prepared.ownedPromptStarted = true if (binding.ownedPromptStartTimer !== undefined) { clearTimeout(binding.ownedPromptStartTimer) @@ -1532,7 +1593,7 @@ export class RuntimeAcpBackend { } await this.#closeModelBridge(binding, true) try { - await binding.process.completePrompt() + if (!binding.sharedProcess) await binding.process.completePrompt() } catch { binding.state = 'outcome-unknown' prepared.modelProfile = undefined @@ -1556,6 +1617,7 @@ export class RuntimeAcpBackend { : status === 'failed' ? 'failed' : 'completed' + prepared.processTree = 'running' if (status === 'completed') { prepared.completion = acpCompletePromptResultSchema.parse({ bindingId: binding.request.bindingId, @@ -1759,6 +1821,11 @@ export class RuntimeAcpBackend { binding.modelBridgeBroker = undefined binding.modelBridgeClient = undefined let brokerError: unknown + if (binding.sharedProcess && binding.ownedAcp?.sessionId && binding.activeOperationId) { + await binding.sharedProcess.transport.setModelRoute( + binding.ownedAcp.sessionId, binding.activeOperationId + ).catch(() => undefined) + } try { await broker?.close() } catch (error) { @@ -1902,7 +1969,7 @@ export class RuntimeAcpBackend { status: 'terminal', terminalState: prepared.terminalState, processTree: - prepared.terminalState === 'completed' ? 'running' : 'empty' + prepared.processTree ?? (prepared.terminalState === 'completed' ? 'running' : 'empty') } } if (binding.activeOperationId !== operation.operationId) { @@ -2113,6 +2180,25 @@ export class RuntimeAcpBackend { this.#finishBinding(binding, 'interrupted') return } + if (binding.sharedProcess) { + const group = binding.sharedProcess + const prepared = binding.activeOperationId + ? binding.operations.get(binding.activeOperationId) : undefined + try { + await rejectAfter( + binding.ownedAcp?.releaseSession() ?? Promise.resolve(), + OWNED_PROMPT_CANCEL_GRACE_MS * 3, + 'Shared Runtime Session release timed out' + ) + if (prepared) prepared.processTree = group.exited ? 'empty' : 'running' + this.#finishBinding(binding, reason === 'user-cancelled' ? 'cancelled' : 'interrupted') + } catch { + // A non-responsive native control plane affects the whole process. + // Ordinary Session cancellation never reaches this fallback. + await this.#stopSharedProcess(group, reason) + } + return + } binding.state = 'stopping' if (!binding.stopRequested) { binding.stopRequested = true @@ -2277,7 +2363,12 @@ export class RuntimeAcpBackend { } #finishProcess(binding: BindingState): void { - if (binding.process !== undefined) { + const group = binding.sharedProcess + if (group) { + group.bindings.delete(binding) + binding.sharedProcess = undefined + this.#scheduleSharedIdle(group) + } else if (binding.process !== undefined) { this.#processIdentityBindings.delete( processIdentityKey(binding.process.identity) ) @@ -2305,6 +2396,68 @@ export class RuntimeAcpBackend { } } + async #acquireSharedProcess(key: string, launch: RuntimeAcpProcessLaunch): Promise { + const existing = this.#sharedProcesses.get(key) + if (existing && !existing.exited) { + if (existing.idleTimer) clearTimeout(existing.idleTimer) + existing.idleTimer = undefined + return existing + } + const process = await this.#options.launchProcess(launch) + assertProcessIdentity(process.identity) + const group: SharedProcess = { + key, process, transport: new AgentAcpConnection(process), + bindings: new Set(), exited: false + } + group.unsubscribeExit = process.subscribeExit?.(() => { + void this.#enqueueControl(() => this.#handleSharedExit(group)).catch(() => undefined) + }) ?? undefined + this.#sharedProcesses.set(key, group) + return group + } + + #scheduleSharedIdle(group: SharedProcess): void { + if (group.bindings.size || 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') + }).catch(() => undefined) + }, this.#options.sharedProcessIdleMs ?? SHARED_PROCESS_IDLE_MS) + group.idleTimer.unref?.() + } + + async #stopSharedProcess(group: SharedProcess, reason: RuntimeAcpProcessStopReason): Promise { + if (group.exited) return + if (group.idleTimer) clearTimeout(group.idleTimer) + group.idleTimer = undefined + await group.process.stop({ + reason, deadlineAt: new Date(this.#now() + 10_000).toISOString() + }) + const result = await group.process.reconcile() + if (!sameProcessIdentity(result.identity, group.process.identity) || result.processTree !== 'empty') { + throw new RuntimeAcpBackendError('Shared Runtime process stop did not reconcile', 'process') + } + await this.#handleSharedExit(group) + } + + async #handleSharedExit(group: SharedProcess): Promise { + if (group.exited) return + group.exited = true + if (group.idleTimer) clearTimeout(group.idleTimer) + group.unsubscribeExit?.() + group.transport.dispose() + if (this.#sharedProcesses.get(group.key) === group) this.#sharedProcesses.delete(group.key) + this.#processIdentityBindings.delete(processIdentityKey(group.process.identity)) + for (const binding of [...group.bindings]) { + if (binding.activeOperationId) { + const prepared = binding.operations.get(binding.activeOperationId) + if (prepared) prepared.processTree = 'empty' + } + await this.#handleProcessExit(binding) + } + } + #scheduleDeadline(binding: BindingState, deadlineAt: string): void { if (binding.deadlineTimer !== undefined) { clearTimeout(binding.deadlineTimer) diff --git a/src/agent-daemon/runtime-composition.ts b/src/agent-daemon/runtime-composition.ts index b2d7f2a8..93fae429 100644 --- a/src/agent-daemon/runtime-composition.ts +++ b/src/agent-daemon/runtime-composition.ts @@ -245,6 +245,7 @@ export async function createProductionRuntimeProtocol( stateDirectory ) backend = new RuntimeAcpBackend({ + shareOwnedProcesses: true, journal: options.events, resolveRuntimeBundle: (runtimeId, bundleDigest) => currentRegistry().resolve( @@ -254,7 +255,9 @@ export async function createProductionRuntimeProtocol( ), loadRegisteredRuntimeBundle: async (resolved) => { return await loadRegistered( - resolved.entry, + // This composition's resolver above returns the complete registry + // entry; the backend itself only needs its public identity fields. + resolved.entry as RuntimeRegistryEntry, resolved.bundleDirectory ) }, @@ -283,7 +286,8 @@ export async function createProductionRuntimeProtocol( bridgeDirectory: launch.workspace.bridgeDirectory!, socketPath: launch.modelBridge.socketPath, - policy: launch.modelBridge.policy + policy: launch.modelBridge.policy, + sharedSessions: launch.sharedSessions } }) }, @@ -294,7 +298,8 @@ export async function createProductionRuntimeProtocol( installationId, registry: ownerRegistry, deadlineAt: launch.deadlineAt, - maximumInputBytes: launch.budget.maximumInputBytes + maximumInputBytes: launch.budget.maximumInputBytes, + sharedSessions: launch.sharedSessions }), outputSink: options.outputSink, semanticPrompts, diff --git a/src/main/agent/acp-remote-runtime.test.ts b/src/main/agent/acp-remote-runtime.test.ts index 6fc8541c..1d7109f9 100644 --- a/src/main/agent/acp-remote-runtime.test.ts +++ b/src/main/agent/acp-remote-runtime.test.ts @@ -20,6 +20,8 @@ import { import type { RuntimeEvent } from './runtime' import { AcpRemoteRuntime, + RemotePromptCancelledError, + RemotePromptRecoveryUnavailableError, type AcpRemoteRuntimeOptions } from './acp-remote-runtime' import type { @@ -2979,6 +2981,42 @@ describe('AcpRemoteRuntime Agent-owned prompts', () => { expect(fixture.preparePrompt).toHaveBeenCalledOnce() }) + it('uses the recovered generation for retained and new Sessions and mode changes', async () => { + const fixture = ownedChannel() + const fresh = ownedChannel() + try { + await collect(fixture.instance.run( + { ...request, workMode: 'ask' }, new AbortController().signal + )) + Object.defineProperty(fixture.channel, 'generation', { value: 2 }) + await collect(fixture.instance.run( + { ...request, requestId: 'after-reconnect', workMode: 'execute' }, + new AbortController().signal + )) + Object.defineProperty(fresh.channel, 'generation', { value: 2 }) + await collect(fresh.instance.run( + { ...request, requestId: 'new-after-reconnect', conversationId: 'another-conversation' }, + new AbortController().signal + )) + expect([ + ...fixture.preparePrompt.mock.calls.slice(1), + ...fresh.preparePrompt.mock.calls + ].map(([prepared]) => ({ + controllerGeneration: prepared.controllerGeneration, + connectionGeneration: prepared.connectionGeneration + }))).toEqual([ + { controllerGeneration: 2, connectionGeneration: 2 }, + { controllerGeneration: 2, connectionGeneration: 2 } + ]) + expect(fixture.startOwnedPrompt).toHaveBeenNthCalledWith( + 2, expect.objectContaining({ acpSessionId: 'owned-session' }) + ) + expect(fixture.attachOwnedPrompt).not.toHaveBeenCalled() + } finally { + await Promise.all([fixture.instance.dispose(), fresh.instance.dispose()]) + } + }) + it('does not abandon an accepted Agent prompt for a later request', async () => { const fixture = ownedChannel({ waitForCancellation: true }) const first = fixture.instance.run( @@ -3115,6 +3153,30 @@ describe('AcpRemoteRuntime Agent-owned prompts', () => { expect(fixture.attachOwnedPrompt).not.toHaveBeenCalled() }) + it('definitively ends recovery after the original Agent installation is replaced', async () => { + const fixture = ownedChannel({ waitForCancellation: true }) + const first = fixture.instance.run(request, new AbortController().signal) + await first.next() + await first.return() + const channelFactory = vi.fn(async () => fixture.channel) + const replacement = factoryRuntime(channelFactory, fixture.store, { + identity: { ...identity, agentInstallationId: 'replacement-agent' }, + modelBridgePolicy, + modelProfile: ownedModelProfile + }) + try { + await expect(collect(replacement.run( + { ...request, remoteRecoveryOnly: true }, + new AbortController().signal + ))).rejects.toBeInstanceOf(RemotePromptRecoveryUnavailableError) + expect(channelFactory).not.toHaveBeenCalled() + expect(fixture.startOwnedPrompt).toHaveBeenCalledOnce() + expect(fixture.attachOwnedPrompt).not.toHaveBeenCalled() + } finally { + await Promise.all([replacement.dispose(), fixture.instance.dispose()]) + } + }) + it('reconciles an owned cancellation before the next prompt in the same conversation', async () => { const fixture = ownedChannel() fixture.pageOwnedPromptTranscript.mockImplementationOnce(() => new Promise(() => {})) @@ -3124,7 +3186,7 @@ describe('AcpRemoteRuntime Agent-owned prompts', () => { const pending = stream.next() await vi.waitFor(() => expect(fixture.pageOwnedPromptTranscript).toHaveBeenCalledOnce()) controller.abort(new Error('cancel pending question')) - await expect(pending).rejects.toThrow('cancel pending question') + await expect(pending).rejects.toBeInstanceOf(RemotePromptCancelledError) expect(await fixture.store.getByConversation(request.conversationId)).toBeUndefined() const events = await collect(fixture.instance.run( { ...request, requestId: 'after-cancel' }, new AbortController().signal @@ -3170,7 +3232,7 @@ describe('AcpRemoteRuntime Agent-owned prompts', () => { expect(settled).toBe(false) releaseEscalation() - await expect(pending).rejects.toThrow('cancel owned prompt') + await expect(pending).rejects.toBeInstanceOf(RemotePromptCancelledError) }) it.each([ diff --git a/src/main/agent/acp-remote-runtime.ts b/src/main/agent/acp-remote-runtime.ts index ba07c326..5940c3a2 100644 --- a/src/main/agent/acp-remote-runtime.ts +++ b/src/main/agent/acp-remote-runtime.ts @@ -138,6 +138,13 @@ export class RemotePromptRecoveryUnavailableError extends Error { } } +export class RemotePromptCancelledError extends Error { + constructor(message: string, options?: ErrorOptions) { + super(message, options) + this.name = 'RemotePromptCancelledError' + } +} + type ActivePrompt = { reportedQuestions?: Set subagentProgress?: OpenCodeSubagentProgress @@ -1016,9 +1023,7 @@ export class AcpRemoteRuntime implements AgentRuntime { false ).catch(() => undefined) if ( - result?.status === 'terminal' && - (result.processTree === 'empty' || - result.terminalState === 'completed') + result?.status === 'terminal' ) { const terminal = await this.persist( context, @@ -1310,6 +1315,7 @@ export class AcpRemoteRuntime implements AgentRuntime { return runtimeSessionBindingSchema.parse({ bindingId, ...this.options.identity, + controllerGeneration: context.channel.generation, conversationId, runtimeId: this.options.runtimeId, acpSessionId: `opening-${randomUUID()}`, @@ -1353,6 +1359,15 @@ export class AcpRemoteRuntime implements AgentRuntime { '没有可安全附加的远端 Agent 请求,且恢复不会重放任务' ) } + if ( + request.remoteRecoveryOnly === true && + binding && + !this.bindingIdentityMatches(binding) + ) { + throw new RemotePromptRecoveryUnavailableError( + '原远端 Agent 或 Runtime 已变更,无法恢复原请求且不会重放任务' + ) + } if ( request.remoteRecoveryOnly !== true && this.options.modelProfile !== undefined && @@ -1741,6 +1756,11 @@ export class AcpRemoteRuntime implements AgentRuntime { current.context, current.binding ) + current.binding = await this.rotateBindingTransport( + current.binding, + current.context, + signal + ) current.binding = await this.persist( current.context, current.binding, @@ -2685,9 +2705,10 @@ export class AcpRemoteRuntime implements AgentRuntime { ) session.binding = binding if (terminal.state === 'cancelled') { - throw signal.aborted - ? abortReason(signal) - : new Error('远端 Runtime 请求已取消') + throw new RemotePromptCancelledError( + '远端 Runtime 请求已取消', + signal.aborted ? { cause: abortReason(signal) } : undefined + ) } if (terminal.state === 'failed') { throw new Error( @@ -2810,6 +2831,14 @@ export class AcpRemoteRuntime implements AgentRuntime { signal, authorize ) + } catch (error) { + if (signal.aborted && session.binding.state === 'closed') { + throw new RemotePromptCancelledError( + error instanceof Error ? error.message : '远端 Runtime 请求已取消', + { cause: error } + ) + } + throw error } finally { this.sessionReservations.delete(request.conversationId) this.notifyDrain() diff --git a/src/main/agent/create-runtime.test.ts b/src/main/agent/create-runtime.test.ts index ae551bad..c2ea963a 100644 --- a/src/main/agent/create-runtime.test.ts +++ b/src/main/agent/create-runtime.test.ts @@ -134,6 +134,10 @@ describe('createAgentRuntime model compatibility', () => { expect(runtime.runtimeId).toBe('deepseek-harness') await expect(runtime.getStatus()).resolves.toMatchObject({ + available: true + }) + expect(deepseekHarnessLauncher).not.toHaveBeenCalled() + await expect(runtime.testConnection!()).resolves.toMatchObject({ available: false }) expect(deepseekHarnessLauncher).toHaveBeenCalledWith( diff --git a/src/main/agent/create-runtime.ts b/src/main/agent/create-runtime.ts index 5d3250a9..bb0b5119 100644 --- a/src/main/agent/create-runtime.ts +++ b/src/main/agent/create-runtime.ts @@ -3,7 +3,8 @@ import { type ModelRuntimeOptions } from './model-runtime' import { ContinueAgentRuntime } from './continue-runtime' -import { OpenCodeRuntime } from './opencode-runtime' +import { OpenCodeRuntime, type OpenCodeRuntimeOptions } from './opencode-runtime' +import type { LocalRuntimeRegistry } from './local-runtime-registry' import { DeepSeekHarnessRuntime, type DeepSeekHarnessRuntimeOptions @@ -57,6 +58,7 @@ const noModelTaskTools: ModelToolProviderLike = { } export type AgentCapabilityContext = { + localRuntimeRegistry?: LocalRuntimeRegistry skillInstructions?: string skillPackages?: RuntimeSkillPackage[] mcpServers?: ResolvedMcpServer[] @@ -213,8 +215,8 @@ export function createAgentRuntime( if (!capabilities.deepseekHarnessLauncher) { throw new Error('DeepSeek Harness 受控 Host 启动器不可用') } - return new DeepSeekHarnessRuntime({ - defaultWorkspace: workspace, + const options: DeepSeekHarnessRuntimeOptions = { + defaultWorkspace: capabilities.localRuntimeRegistry ? defaultWorkspace : workspace, baseUrl: profile.baseUrl, model: profile.modelName, supportsImageInput: profile.supportsImageInput === true, @@ -225,16 +227,26 @@ export function createAgentRuntime( }, skillPackages: capabilities.skillPackages, extensionPackages: capabilities.deepseekHarnessExtensions, - toolProvider: new ModelToolProvider( - getWorkspaceAccess(), - capabilities.mcpServers, - undefined, - capabilities.knowledgeGateway, - capabilities.webSearchEnabled === true, - {}, - capabilities.launchEnvironmentProvider + createToolProvider: (directory) => new ModelToolProvider( + new LocalWorkspaceAccess(directory), capabilities.mcpServers, undefined, + capabilities.knowledgeGateway, capabilities.webSearchEnabled === true, + {}, capabilities.launchEnvironmentProvider ) - }) + } + const create = () => new DeepSeekHarnessRuntime(options) + if (!capabilities.localRuntimeRegistry) return create() + const configuration = { ...options, defaultWorkspace: undefined, createToolProvider: undefined } + return capabilities.localRuntimeRegistry.acquire( + `deepseek-harness:${profile.id}`, + { + ...configuration, + mcpServers: capabilities.mcpServers, + webSearchEnabled: capabilities.webSearchEnabled === true, + launchEnvironment: capabilities.launchEnvironmentProvider?.() + }, + workspace, + create + ) } if (provider === 'continue') { @@ -293,7 +305,7 @@ export function createAgentRuntime( 'OpenCode 独立模型连接仅支持文本对话协议,不支持图像生成协议' ) } - return new OpenCodeRuntime({ + const options: OpenCodeRuntimeOptions = { baseUrl, embedded, binaryPath: @@ -311,13 +323,25 @@ export function createAgentRuntime( skillInstructions: capabilities.skillInstructions, skillPackages: capabilities.skillPackages, sharedCacheRoot: capabilities.opencodeSharedCacheRoot, - defaultWorkspace: workspace, + defaultWorkspace: capabilities.localRuntimeRegistry ? defaultWorkspace : workspace, knowledgeGateway: capabilities.knowledgeGateway, mcpServers: capabilities.mcpServers, customization: settings?.runtimeCustomization.opencode, launchEnvironmentProvider: capabilities.launchEnvironmentProvider - }) + } + const create = () => new OpenCodeRuntime(options) + if (!capabilities.localRuntimeRegistry) return create() + const configuration = { ...options, defaultWorkspace: undefined } + return capabilities.localRuntimeRegistry.acquire( + `opencode:${settings?.opencodeModelProfile?.id ?? 'native'}`, + { + ...configuration, + launchEnvironment: capabilities.launchEnvironmentProvider?.() + }, + workspace, + create + ) } const defaultModelProfile = diff --git a/src/main/agent/deepseek-harness-acp-e2e.test.ts b/src/main/agent/deepseek-harness-acp-e2e.test.ts index 4e7f1909..66eb8cc4 100644 --- a/src/main/agent/deepseek-harness-acp-e2e.test.ts +++ b/src/main/agent/deepseek-harness-acp-e2e.test.ts @@ -37,6 +37,10 @@ import { createOpenAIChatCompletionsUrl } from './openai-endpoint' import { createModelRequestProbe } from '../../../tests/support/model-request-probe' import { ExecutionSpaceResolver } from '../execution-space/execution-space-resolver' import { SelectedRuntimeManager } from './selected-runtime-manager' +import { LocalRuntimeRegistry } from './local-runtime-registry' +import { createAgentRuntime } from './create-runtime' +import { defaultRuntimeSettings } from '../../shared/contracts' +import type { ResolvedRuntimeSettings } from '../runtime-settings-store' const CREDENTIAL_REF = 'GOODBUDDY_HARNESS_MODEL_API_KEY' const SKILL_CALL_ID = 'e2e-skill-call' @@ -385,6 +389,81 @@ function createInProcessLaunch( } describe('DeepSeek Harness real ACP control-plane E2E', () => { + it.each([2, 10])('shares one production-composed Host across %i projects with independent native file and shell tools', async (projectCount) => { + const root = await realpath(await mkdtemp(join(tmpdir(), 'goodbuddy-dsh-reuse-'))) + const directories = Array.from( + { length: projectCount }, (_, index) => join(root, `project-${index}`) + ) + await Promise.all(directories.map(async (directory, index) => { + await mkdir(directory) + await writeFile(join(directory, 'marker.txt'), `PROJECT_${index}`) + })) + const inProcess = createInProcessLaunch(root, { + stream(options) { + const id = latestUserText(options) + const read = toolResultText(options, `${id}-read`) + if (read === undefined) return toolCall(`${id}-read`, 'read', { file_path: 'marker.txt' }) + const write = toolResultText(options, `${id}-write`) + if (write === undefined) return toolCall(`${id}-write`, 'write', { + file_path: `${id}.txt`, content: read + }) + const shell = toolResultText(options, `${id}-shell`) + if (shell === undefined) return toolCall(`${id}-shell`, process.platform === 'win32' ? 'pwsh' : 'bash', { + command: process.platform === 'win32' ? 'Get-Content marker.txt' : 'cat marker.txt', + description: 'Read the session workspace fixture' + }) + return textResponse(shell) + } + }) + const launch = vi.fn(inProcess.launch) + const registry = new LocalRuntimeRegistry() + const resolver = new ExecutionSpaceResolver() + const profile = { + id: '00000000-0000-4000-8000-000000000009', + name: 'Reuse fixture', baseUrl: 'http://127.0.0.1:9', + modelName: 'reuse', protocol: 'openai-chat-completions' as const, + authentication: 'api-key' as const, apiKey: 'unused-deterministic-model' + } + const settings = { + ...defaultRuntimeSettings, provider: 'deepseek-harness', + modelProfiles: [profile], deepseekHarnessModelProfile: profile + } as ResolvedRuntimeSettings + const manager = new SelectedRuntimeManager(async (_selection, space) => + createAgentRuntime(root, settings, { + executionSpace: space, localRuntimeRegistry: registry, + deepseekHarnessLauncher: launch + }), 1, 1, undefined, undefined, (id) => registry.releaseConversation(id)) + try { + const selection = { provider: 'deepseek-harness' as const } + for (let wave = 0; wave < 2; wave++) { + const outputs = await Promise.all(directories.flatMap((directory, project) => + [0, 1].map(async (conversation) => { + const runtime = await manager.getRuntime(selection, resolver.resolveLocal(directory)) + const id = `p${project}-c${conversation}-w${wave}` + const events = await collect(runtime.run({ + requestId: id, conversationId: `p${project}-c${conversation}`, + prompt: id, workMode: 'execute' + }, AbortSignal.timeout(20_000))) + expect(events.at(-1)?.type).toBe('done') + expect(await readFile(join(directory, `${id}.txt`), 'utf8')).toContain(`PROJECT_${project}`) + expect(events.filter((event) => event.type === 'text').map((event) => event.delta).join('')) + .toContain(`PROJECT_${project}`) + return events + }) + )) + expect(outputs).toHaveLength(projectCount * 2) + expect(launch).toHaveBeenCalledOnce() + } + await manager.releaseConversation('p0-c0') + expect(launch).toHaveBeenCalledOnce() + expect(inProcess.hosts).toHaveLength(1) + } finally { + await manager.dispose() + await registry.dispose() + await rm(root, { recursive: true, force: true }) + } + }, 60_000) + it.skipIf(process.platform !== 'win32')('reuses a working runtime for mixed-separator and canonical Windows paths', async () => { const root = await realpath(await mkdtemp(join(tmpdir(), 'goodbuddy-dsh-path-'))) const workspace = join(root, 'workspace') @@ -428,6 +507,103 @@ describe('DeepSeek Harness real ACP control-plane E2E', () => { } }) + it('keeps native Skills, plugin inventory, MCP calls and images scoped across shared projects', async () => { + const root = await realpath(await mkdtemp(join(tmpdir(), 'goodbuddy-dsh-shared-capabilities-'))) + const directories = [join(root, 'one'), join(root, 'two')] + await Promise.all(directories.map(directory => mkdir(directory))) + const plugin = join(root, 'inventory-plugin.mjs') + await writeFile(plugin, [ + "export const name = 'shared-inventory'", + "export const inject = ['skills']", + 'export function apply(ctx) {', + "ctx.skills.register({ name: 'shared-plugin-skill', description: 'Shared plugin fixture', content: '# Shared plugin', source: 'custom' })", + '}' + ].join('\n')) + const models = [new FakeGameModel(), new FakeGameModel()] + const images = [createCanvas(1, 1).toBuffer('image/png'), createCanvas(2, 1).toBuffer('image/png')] + const seenImages = new Map() + const inProcess = createInProcessLaunch(root, { + stream(options) { + const project = latestUserText(options).includes('PROJECT_1') ? 1 : 0 + seenImages.set(project, options.messages.flatMap(message => message.content).filter(block => block.type === 'image')) + return models[project]!.stream(options) + } + }) + const launch = vi.fn(inProcess.launch) + const calls = vi.spyOn(ModelToolProvider.prototype, 'callTool') + const registry = new LocalRuntimeRegistry() + const profile = { + id: '00000000-0000-4000-8000-000000000010', + name: 'Capabilities fixture', baseUrl: 'http://127.0.0.1:9', + modelName: 'capabilities', protocol: 'openai-chat-completions' as const, + authentication: 'api-key' as const, apiKey: 'unused-deterministic-model', + supportsImageInput: true + } + const resolver = new ExecutionSpaceResolver() + const runtimes = directories.map(directory => createAgentRuntime(root, { + ...defaultRuntimeSettings, provider: 'deepseek-harness', + modelProfiles: [profile], deepseekHarnessModelProfile: profile + } as ResolvedRuntimeSettings, { + executionSpace: resolver.resolveLocal(directory), localRuntimeRegistry: registry, + deepseekHarnessLauncher: launch, + skillPackages: [{ id: 'web-3d-game', directory: resolve('tests/fixtures/web-3d-game-skill') }], + deepseekHarnessExtensions: [{ id: 'shared-inventory', entrypoint: plugin, configuration: {} }], + mcpServers: [{ + id: 'fbf42200-4e60-48d0-b5f2-e816db38ac54', name: 'Shared blueprint', description: '', + enabled: true, allowDynamicTools: false, assignments: ['deepseek-harness'], + secretConfigured: false, transport: 'stdio', + command: process.versions.electron ? 'node' : process.execPath, + args: [resolve('tests/fixtures/web-3d-game-mcp.mjs')] + }] + })) + try { + for (const mode of ['execute', 'ask'] as const) { + await Promise.all(runtimes.map(async (runtime, project) => { + const events = await collect(runtime.run({ + requestId: `capabilities-${project}-${mode}`, conversationId: `capabilities-${project}`, + workMode: mode, + prompt: `${mode === 'ask' ? 'ASK_BOUNDARY_PROBE' : 'Use Skill and MCP'} PROJECT_${project}`, + ...(mode === 'execute' ? { images: [{ + name: `project-${project}.png`, mediaType: 'image/png', data: images[project]!.toString('base64') + }] } : {}) + }, AbortSignal.timeout(30_000), async () => 'once')) + expect(events.at(-1)?.type).toBe('done') + const model = models[project]! + if (mode === 'execute') { + expect(model.skillResult).toContain('window.__GOODBUDDY_GAME__') + expect(model.blueprint).toMatchObject({ title: 'Prism Relay' }) + const seen = seenImages.get(project)! + expect(seen).toHaveLength(1) + const image = seen[0]! + if (image.type !== 'image') throw new Error('Expected image block') + expect(image.attachment).toMatchObject({ width: project + 1, height: 1 }) + const stored = await inProcess.hosts[0]!.context.attachments.readImage(image.attachment) + expect(Buffer.from(stored.data).equals(images[project]!)).toBe(true) + } else { + expect(model.askToolNames).not.toContain(model.mcpToolName) + expect(model.askToolResult).toContain('Ask 模式不允许执行非只读工具') + } + expect(await runtime.getNativeSnapshot?.()).toMatchObject({ + skills: expect.arrayContaining([expect.objectContaining({ id: 'shared-plugin-skill', source: 'plugin' })]) + }) + })) + expect(launch).toHaveBeenCalledOnce() + expect(calls).toHaveBeenCalledTimes(2) + } + expect(new Set(calls.mock.contexts).size).toBe(2) + expect(calls.mock.calls.map(call => call[3]?.conversationId).sort()) + .toEqual(['capabilities-0', 'capabilities-1']) + await runtimes[0]!.releaseConversation?.('capabilities-0') + expect(await runtimes[1]!.getStatus()).toMatchObject({ available: true }) + expect(launch).toHaveBeenCalledOnce() + } finally { + calls.mockRestore() + await Promise.all(runtimes.map(runtime => runtime.dispose())) + await registry.dispose() + await rm(root, { recursive: true, force: true }) + } + }, 60_000) + it('writes outside the Workspace in Execute without approval and rejects the same tool in Ask', async () => { const root = await realpath(await mkdtemp(join(tmpdir(), 'goodbuddy-dsh-directory-'))) const workspace = join(root, 'workspace') @@ -780,7 +956,7 @@ describe('DeepSeek Harness real ACP control-plane E2E', () => { ) try { - await runtime.getStatus() + await runtime.testConnection() expect(inProcess.hosts[0]?.extensionFailures).toEqual([]) await expect(runtime.getNativeSnapshot()).resolves.toMatchObject({ provider: 'deepseek-harness', diff --git a/src/main/agent/deepseek-harness-runtime.test.ts b/src/main/agent/deepseek-harness-runtime.test.ts index a9a23bba..83292293 100644 --- a/src/main/agent/deepseek-harness-runtime.test.ts +++ b/src/main/agent/deepseek-harness-runtime.test.ts @@ -443,6 +443,16 @@ function toolProvider( } describe('DeepSeekHarnessRuntime', () => { + it('reports passive readiness without launching a Host or reading a model', async () => { + const harness = setup() + await expect(harness.runtime.getStatus()).resolves.toMatchObject({ + available: true, detail: expect.stringContaining('首次使用') + }) + expect(harness.launch).not.toHaveBeenCalled() + await harness.runtime.dispose() + expect(harness.launch).not.toHaveBeenCalled() + }) + it('includes bounded failed-extension cleanup in the startup budget', () => { expect(deepSeekHarnessStartupBudget(11)).toEqual({ hostTimeoutMs: 76_000, @@ -474,7 +484,7 @@ describe('DeepSeekHarnessRuntime', () => { ] }) - const status = harness.runtime.getStatus() + const status = harness.runtime.testConnection() await vi.waitFor(() => expect(harness.launch).toHaveBeenCalledOnce()) await vi.advanceTimersByTimeAsync(10_001) @@ -504,7 +514,7 @@ describe('DeepSeekHarnessRuntime', () => { } }) - const status = harness.runtime.getStatus() + const status = harness.runtime.testConnection() await vi.waitFor(() => expect(harness.launch).toHaveBeenCalledOnce()) await vi.advanceTimersByTimeAsync(12_000) @@ -528,7 +538,7 @@ describe('DeepSeekHarnessRuntime', () => { } }) - const status = harness.runtime.getStatus() + const status = harness.runtime.testConnection() await vi.waitFor(() => expect(harness.launch).toHaveBeenCalledOnce()) await harness.runtime.dispose() @@ -633,7 +643,7 @@ describe('DeepSeekHarnessRuntime', () => { advertisedImageInput: false }) - await expect(harness.runtime.getStatus()).resolves.toMatchObject({ + await expect(harness.runtime.testConnection()).resolves.toMatchObject({ available: false, detail: expect.stringContaining('图片能力') }) @@ -904,11 +914,14 @@ describe('DeepSeekHarnessRuntime', () => { } ]) const harness = setup({ toolProvider: provider }) - await harness.runtime.getStatus() + const running = collect(harness.runtime.run( + request('catalog', 'execute'), new AbortController().signal + )) + await vi.waitFor(() => expect(harness.promptGates).toHaveLength(1)) await expect( harness.extension('goodbuddy/tools/list', { - sessionId: 'session-catalog' + sessionId: 'session-1' }) ).resolves.toEqual({ tools: [ @@ -938,10 +951,12 @@ describe('DeepSeekHarnessRuntime', () => { expect( JSON.stringify( await harness.extension('goodbuddy/tools/list', { - sessionId: 'session-catalog' + sessionId: 'session-1' }) ) ).not.toContain('secret') + harness.promptGates[0]!.resolve({ stopReason: 'end_turn' }) + await running await harness.runtime.dispose() }) @@ -1396,8 +1411,9 @@ describe('DeepSeekHarnessRuntime', () => { await harness.runtime.dispose() }) - it('bounds cancellation when the harness prompt never settles', async () => { - const harness = setup({ useProductionPromptTimeout: true }) + it('releases an uncooperative cancelled session without terminating its peer', async () => { + const provider = toolProvider() + const harness = setup({ useProductionPromptTimeout: true, toolProvider: provider }) const controller = new AbortController() const running = collect( harness.runtime.run( @@ -1409,6 +1425,10 @@ describe('DeepSeekHarnessRuntime', () => { expect(harness.promptGates).toHaveLength(1) ) + const peer = collect(harness.runtime.run( + request('cancel-peer'), new AbortController().signal + )) + await vi.waitFor(() => expect(harness.promptGates).toHaveLength(2)) controller.abort(new Error('cancelled by user')) await expect(running).rejects.toThrow('cancelled by user') @@ -1416,7 +1436,14 @@ describe('DeepSeekHarnessRuntime', () => { method: 'session/cancel', params: { sessionId: 'session-1' } }) - expect(harness.child.terminate).toHaveBeenCalled() + expect(harness.requests).toContainEqual({ + method: 'goodbuddy/session/release', + params: { sessionId: 'session-1' } + }) + expect(harness.child.terminate).not.toHaveBeenCalled() + expect(provider.releaseConversation).toHaveBeenCalledWith(request('uncooperative-cancel').conversationId) + harness.promptGates[1]!.resolve({ stopReason: 'end_turn' }) + expect((await peer).at(-1)?.type).toBe('done') await harness.runtime.dispose() }) @@ -1447,7 +1474,7 @@ describe('DeepSeekHarnessRuntime', () => { it('reports process exit and fully disposes the connection and child', async () => { const harness = setup() - await expect(harness.runtime.getStatus()).resolves.toMatchObject({ + await expect(harness.runtime.testConnection()).resolves.toMatchObject({ available: true }) harness.exit.resolve({ exitCode: 9 }) @@ -1469,7 +1496,7 @@ describe('DeepSeekHarnessRuntime', () => { new Error('method not found') ) - await expect(harness.runtime.getStatus()).resolves.toMatchObject({ + await expect(harness.runtime.testConnection()).resolves.toMatchObject({ available: false, detail: 'method not found' }) diff --git a/src/main/agent/deepseek-harness-runtime.ts b/src/main/agent/deepseek-harness-runtime.ts index 6f6756d4..6ba14de6 100644 --- a/src/main/agent/deepseek-harness-runtime.ts +++ b/src/main/agent/deepseek-harness-runtime.ts @@ -204,11 +204,13 @@ export type DeepSeekHarnessRuntimeOptions = { skillPackages?: RuntimeSkillPackage[] extensionPackages?: ControlledHarnessExtensionPackage[] toolProvider?: ModelToolProviderLike + createToolProvider?: (workspace: string) => ModelToolProviderLike loadAcpSdk?: () => Promise } type ActiveRun = { request: AgentExecutionRequest + toolProvider?: ModelToolProviderLike toolController: AbortController authorize?: RuntimeAuthorizer updates: AcpSessionNotification['update'][] @@ -454,7 +456,8 @@ export class DeepSeekHarnessRuntime implements AgentRuntime { private disposed = false private fatalError?: Error private stderrBytes = 0 - private readonly sessions = new Map() + private readonly sessions = new Map() + private readonly workspaceTools = new Map() private readonly proxyToolCatalogs = new Map() private readonly sessionInitializations = new Map< string, @@ -832,10 +835,8 @@ export class DeepSeekHarnessRuntime implements AgentRuntime { 'DeepSeek Harness MCP 工具上下文不可用' ) } - if (!this.options.toolProvider) { - return { tools: [] } - } const run = this.activeRuns.get(params.sessionId) + if (!run?.toolProvider || run.closed) return { tools: [] } const context = { conversationId: run?.request.conversationId ?? @@ -848,7 +849,7 @@ export class DeepSeekHarnessRuntime implements AgentRuntime { knowledgeCapabilityToken: run?.request.knowledgeCapabilityToken } - const tools = await this.options.toolProvider.listTools( + const tools = await run.toolProvider.listTools( context, connection.signal ) @@ -869,7 +870,7 @@ export class DeepSeekHarnessRuntime implements AgentRuntime { if ( !run || run.closed || - !this.options.toolProvider || + !run.toolProvider || typeof name !== 'string' || !argumentsValue || typeof argumentsValue !== 'object' || @@ -935,7 +936,7 @@ export class DeepSeekHarnessRuntime implements AgentRuntime { } if (!isWebTool) { const approval = - this.options.toolProvider.getApproval( + run.toolProvider.getApproval( tool, argumentsValue as Record, argumentSummary, @@ -950,7 +951,7 @@ export class DeepSeekHarnessRuntime implements AgentRuntime { ) } } - const result = await this.options.toolProvider.callTool( + const result = await run.toolProvider.callTool( name, argumentsValue as Record, run.toolController.signal, @@ -1091,6 +1092,35 @@ export class DeepSeekHarnessRuntime implements AgentRuntime { } async getStatus(): Promise { + return { + id: 'deepseek-harness', + label: 'DeepSeek Harness', + available: !this.disposed && !this.fatalError, + supportsToolExecution: true, + detail: this.disposed + ? 'DeepSeek Harness Runtime 已关闭' + : this.fatalError?.message ?? + (this.state + ? `DeepSeek Harness ${this.state.capabilities.harnessVersion} · 当前用户权限` + : 'DeepSeek Harness 已配置,将在首次使用时启动') + } + } + + get hasRetainedSessions(): boolean { + return this.sessions.size > 0 || this.sessionInitializations.size > 0 + } + + private toolsFor(workspace: string): ModelToolProviderLike | undefined { + if (!this.options.createToolProvider) return this.options.toolProvider + let provider = this.workspaceTools.get(workspace) + if (!provider) { + provider = this.options.createToolProvider(workspace) + this.workspaceTools.set(workspace, provider) + } + return provider + } + + async testConnection(): Promise { try { await this.getState() return { @@ -1114,10 +1144,13 @@ export class DeepSeekHarnessRuntime implements AgentRuntime { } } - async getNativeSnapshot(): Promise { + async getNativeSnapshot(workspace?: string): Promise { const state = await this.getState() const response = await withTimeout( - state.agent.extMethod(GOODBUDDY_NATIVE_SNAPSHOT, {}), + state.agent.extMethod( + GOODBUDDY_NATIVE_SNAPSHOT, + workspace ? { workspace } : {} + ), this.initializationTimeoutMs, '原生能力清单' ) @@ -1302,11 +1335,15 @@ export class DeepSeekHarnessRuntime implements AgentRuntime { private async getSession( state: HarnessState, - conversationId: string + conversationId: string, + workspace: string ): Promise { const current = this.sessions.get(conversationId) + if (current?.workspace === workspace) { + return current.id + } if (current) { - return current + await this.releaseConversation(conversationId) } const pending = this.sessionInitializations.get(conversationId) if (pending) { @@ -1314,14 +1351,14 @@ export class DeepSeekHarnessRuntime implements AgentRuntime { } const creation = state.agent .newSession({ - cwd: state.workspace, + cwd: workspace, mcpServers: [] }) .then((response) => { if (!response.sessionId) { throw new Error('DeepSeek Harness 未返回 ACP 会话 ID') } - this.sessions.set(conversationId, response.sessionId) + this.sessions.set(conversationId, { id: response.sessionId, workspace }) return response.sessionId }) this.sessionInitializations.set(conversationId, creation) @@ -1445,12 +1482,17 @@ export class DeepSeekHarnessRuntime implements AgentRuntime { try { state = await this.getState() signal.throwIfAborted() + const workspace = request.executionWorkspace + ? await realpath(request.executionWorkspace) + : state.workspace sessionId = await this.getSession( state, - request.conversationId + request.conversationId, + workspace ) run = { request, + toolProvider: this.toolsFor(workspace), toolController, authorize, updates: [], @@ -1586,7 +1628,14 @@ export class DeepSeekHarnessRuntime implements AgentRuntime { this.shutdownTimeoutMs, '取消请求' ).catch(async () => { - await this.terminate(state!.child) + await withTimeout( + state!.agent.extMethod(GOODBUDDY_RELEASE, { sessionId }), + this.shutdownTimeoutMs, + '释放已取消会话' + ).catch(async () => { + await this.terminate(state!.child) + }) + await this.forgetSession(request.conversationId) }) } } @@ -1609,25 +1658,39 @@ export class DeepSeekHarnessRuntime implements AgentRuntime { } async releaseConversation(conversationId: string): Promise { - const sessionId = this.sessions.get(conversationId) - this.sessions.delete(conversationId) - if (sessionId) this.proxyToolCatalogs.delete(sessionId) - if (!sessionId || !this.state) { - await this.options.toolProvider - ?.releaseConversation(conversationId) - .catch(() => undefined) - return + await this.sessionInitializations.get(conversationId)?.catch(() => undefined) + const session = this.sessions.get(conversationId) + const sessionId = session?.id + if (sessionId && this.state) { + await this.state.agent + .extMethod(GOODBUDDY_RELEASE, { sessionId }) + .catch(async () => { + await this.state?.agent + .cancel({ sessionId }) + .catch(() => undefined) + }) } - await this.state.agent - .extMethod(GOODBUDDY_RELEASE, { sessionId }) - .catch(async () => { - await this.state?.agent - .cancel({ sessionId }) - .catch(() => undefined) - }) - await this.options.toolProvider + await this.forgetSession(conversationId) + } + + private async forgetSession(conversationId: string): Promise { + const session = this.sessions.get(conversationId) + const provider = session + ? this.workspaceTools.get(session.workspace) ?? this.options.toolProvider + : this.options.toolProvider + this.sessions.delete(conversationId) + if (session) this.proxyToolCatalogs.delete(session.id) + await provider ?.releaseConversation(conversationId) .catch(() => undefined) + if ( + session && provider && + this.workspaceTools.get(session.workspace) === provider && + ![...this.sessions.values()].some((other) => other.workspace === session.workspace) + ) { + this.workspaceTools.delete(session.workspace) + await provider.dispose() + } } async dispose(): Promise { @@ -1662,8 +1725,16 @@ export class DeepSeekHarnessRuntime implements AgentRuntime { this.proxyToolCatalogs.clear() this.sessionInitializations.clear() this.conversationTails.clear() + const providers = new Set([ + ...this.workspaceTools.values(), + ...(this.options.toolProvider ? [this.options.toolProvider] : []) + ]) + this.workspaceTools.clear() + const disposeTools = () => Promise.allSettled( + [...providers].map((provider) => provider.dispose()) + ) if (!state) { - await this.options.toolProvider?.dispose().catch(() => undefined) + await disposeTools() return } await withTimeout( @@ -1671,7 +1742,7 @@ export class DeepSeekHarnessRuntime implements AgentRuntime { this.shutdownTimeoutMs, '内部控制面关闭' ).catch(() => undefined) - await this.options.toolProvider?.dispose().catch(() => undefined) + await disposeTools() await this.terminate(state.child) await withTimeout( Promise.allSettled([ diff --git a/src/main/agent/goodbuddy-harness-control-plane.ts b/src/main/agent/goodbuddy-harness-control-plane.ts index 0ff93bb4..02c1fa3c 100644 --- a/src/main/agent/goodbuddy-harness-control-plane.ts +++ b/src/main/agent/goodbuddy-harness-control-plane.ts @@ -926,7 +926,9 @@ export class GoodBuddyHarnessControlPlane { } } - private async nativeSnapshot(): Promise> { + private async nativeSnapshot( + workspace = this.config.workspace + ): Promise> { const controller = new AbortController() const timer = setTimeout( () => @@ -938,7 +940,7 @@ export class GoodBuddyHarnessControlPlane { try { const skills = await Promise.race([ this.ctx.skills.list({ - cwd: this.config.workspace, + cwd: workspace, signal: controller.signal }), new Promise((_resolve, reject) => { @@ -1132,12 +1134,11 @@ export class GoodBuddyHarnessControlPlane { } if ( !isAbsolute(params.cwd) || - params.cwd !== this.config.workspace || params.mcpServers.length > 0 ) { throw RequestError.invalidParams( undefined, - 'the controlled workspace and no MCP servers are required' + 'an absolute workspace and no MCP servers are required' ) } const sessionId = SessionId(randomUUID()) @@ -1393,7 +1394,11 @@ export class GoodBuddyHarnessControlPlane { return { released: true } } if (method === GOODBUDDY_NATIVE_SNAPSHOT) { - return this.nativeSnapshot() + const workspace = params.workspace === undefined + ? this.config.workspace + : requiredString(params, 'workspace') + if (!isAbsolute(workspace)) throw RequestError.invalidParams(undefined, 'workspace must be absolute') + return this.nativeSnapshot(workspace) } if (method === GOODBUDDY_SHUTDOWN) { await this.dispose() diff --git a/src/main/agent/local-runtime-registry.test.ts b/src/main/agent/local-runtime-registry.test.ts new file mode 100644 index 00000000..235691c6 --- /dev/null +++ b/src/main/agent/local-runtime-registry.test.ts @@ -0,0 +1,139 @@ +import { describe, expect, it, vi } from 'vitest' +import { LocalRuntimeRegistry } from './local-runtime-registry' +import type { AgentExecutionRequest, AgentRuntime, RuntimeEvent } from './runtime' + +function fixture() { + const requests: AgentExecutionRequest[] = [] + const sessions = new Set() + const runtime: AgentRuntime & { readonly hasRetainedSessions: boolean } = { + runtimeId: 'opencode', + requiresToolApproval: false, + supportsToolExecution: true, + get hasRetainedSessions() { return sessions.size > 0 }, + getStatus: vi.fn(async () => ({ + id: 'opencode' as const, label: 'OpenCode', available: true, + supportsToolExecution: true, detail: 'ready' + })), + async *run(request) { + requests.push(request) + sessions.add(request.conversationId) + yield { requestId: request.requestId, type: 'done' } + }, + releaseConversation: vi.fn(async (id) => { sessions.delete(id) }), + dispose: vi.fn(async () => { sessions.clear() }) + } + return { runtime, requests, sessions } +} + +async function run(runtime: AgentRuntime, id: string) { + const events: RuntimeEvent[] = [] + for await (const event of runtime.run({ + requestId: id, conversationId: id, prompt: 'continue', workMode: 'execute' + }, new AbortController().signal)) events.push(event) + return events +} + +describe('LocalRuntimeRegistry', () => { + it('shares the Runtime but fixes each request to its resolved workspace', async () => { + const registry = new LocalRuntimeRegistry() + const host = fixture() + const create = vi.fn(() => host.runtime) + const first = registry.acquire('opencode:profile', { model: 'one' }, '/first', create) + const second = registry.acquire('opencode:profile', { model: 'one' }, '/second', create) + await Promise.all([run(first, 'a'), run(second, 'b')]) + expect(create).toHaveBeenCalledOnce() + expect(host.requests.map((request) => request.executionWorkspace).sort()).toEqual(['/first', '/second']) + await first.dispose() + expect(host.runtime.dispose).not.toHaveBeenCalled() + await run(second, 'b') + await registry.releaseConversation('a') + expect(host.sessions).toEqual(new Set(['b'])) + await registry.dispose() + expect(host.runtime.dispose).toHaveBeenCalledOnce() + }) + + it('closes a no-session idle host and recreates it through an existing facade', async () => { + vi.useFakeTimers() + const registry = new LocalRuntimeRegistry(100) + const first = fixture() + const second = fixture() + const create = vi.fn().mockReturnValueOnce(first.runtime).mockReturnValueOnce(second.runtime) + try { + const facade = registry.acquire('opencode:profile', {}, '/first', create) + await facade.getStatus() + await vi.advanceTimersByTimeAsync(100) + expect(first.runtime.dispose).toHaveBeenCalledOnce() + await run(facade, 'a') + await vi.advanceTimersByTimeAsync(1_000) + expect(second.runtime.dispose).not.toHaveBeenCalled() + await registry.releaseConversation('a') + await vi.advanceTimersByTimeAsync(100) + expect(second.runtime.dispose).toHaveBeenCalledOnce() + } finally { + await registry.dispose() + vi.useRealTimers() + } + }) + + it('drains an old configuration without closing a parallel replacement', async () => { + const registry = new LocalRuntimeRegistry() + const old = fixture() + let release!: () => void + const pending = new Promise((resolve) => { release = resolve }) + old.runtime.run = async function* (request) { + await pending + yield { requestId: request.requestId, type: 'done' } + } + old.runtime.respondToQuestion = vi.fn(async () => undefined) + const original = registry.acquire('opencode:profile', { model: 'old' }, '/one', () => old.runtime) + const active = run(original, 'a') + await Promise.resolve() + const next = fixture() + const replacement = registry.acquire('opencode:profile', { model: 'new' }, '/two', () => next.runtime) + expect(old.runtime.dispose).not.toHaveBeenCalled() + await original.respondToQuestion?.('active-question', []) + expect(old.runtime.respondToQuestion).toHaveBeenCalledWith('active-question', []) + await run(replacement, 'b') + release() + await active + await vi.waitFor(() => expect(old.runtime.dispose).toHaveBeenCalledOnce()) + expect(next.runtime.dispose).not.toHaveBeenCalled() + await registry.dispose() + }) + + it('does not renew or reacquire an idle owner during passive status polling', async () => { + vi.useFakeTimers() + const registry = new LocalRuntimeRegistry(100) + const host = fixture() + const inspection = fixture() + const restarted = fixture() + const create = vi.fn() + .mockReturnValueOnce(host.runtime) + .mockReturnValueOnce(inspection.runtime) + .mockReturnValueOnce(restarted.runtime) + try { + const facade = registry.acquire('opencode:profile', {}, '/first', create) + for (let poll = 0; poll < 3; poll++) { + await vi.advanceTimersByTimeAsync(30) + expect(await facade.getStatus()).toMatchObject({ available: true }) + } + expect(host.runtime.dispose).not.toHaveBeenCalled() + await vi.advanceTimersByTimeAsync(10) + expect(host.runtime.dispose).toHaveBeenCalledOnce() + expect(create).toHaveBeenCalledOnce() + + await facade.getStatus() + expect(inspection.runtime.dispose).toHaveBeenCalledOnce() + await vi.advanceTimersByTimeAsync(200) + expect(create).toHaveBeenCalledTimes(2) + expect(restarted.runtime.dispose).not.toHaveBeenCalled() + + await run(facade, 'a') + expect(create).toHaveBeenCalledTimes(3) + expect(restarted.sessions).toEqual(new Set(['a'])) + } finally { + await registry.dispose() + vi.useRealTimers() + } + }) +}) diff --git a/src/main/agent/local-runtime-registry.ts b/src/main/agent/local-runtime-registry.ts new file mode 100644 index 00000000..c373b203 --- /dev/null +++ b/src/main/agent/local-runtime-registry.ts @@ -0,0 +1,282 @@ +import { isDeepStrictEqual } from 'node:util' +import type { + AgentExecutionRequest, + AgentRuntime, + RuntimeAuthorizer, + RuntimeEvent +} from './runtime' +import type { + AgentQuestionAnswer, + RuntimeConversationCompactInput +} from '../../shared/contracts' + +const IDLE_RUNTIME_MS = 60_000 + +type LocalProcessRuntime = AgentRuntime & { + readonly hasRetainedSessions: boolean +} + +class LocalRuntimeEntry { + private instance?: LocalProcessRuntime + private closing?: Promise + private active = 0 + private retired = false + private idle?: ReturnType + readonly description: Pick + + constructor( + readonly configuration: unknown, + private readonly create: () => LocalProcessRuntime, + private readonly idleMs: number, + private readonly onClosed: () => void + ) { + const runtime = this.instance = create() + this.description = { + runtimeId: runtime.runtimeId, + requiresToolApproval: runtime.requiresToolApproval, + supportsToolExecution: runtime.supportsToolExecution, + supportsScopedDataTools: runtime.supportsScopedDataTools, + capability: runtime.capability + } + this.scheduleIdle() + } + + private clearIdle(): void { + if (this.idle) clearTimeout(this.idle) + this.idle = undefined + } + + private scheduleIdle(): void { + this.clearIdle() + if (this.active > 0) return + if (this.retired) { + void this.close().finally(this.onClosed).catch(() => undefined) + } else if (!this.instance?.hasRetainedSessions) { + this.idle = setTimeout(() => { + this.idle = undefined + void this.close().catch(() => undefined) + }, this.idleMs) + this.idle.unref?.() + } + } + + private close(): Promise { + this.clearIdle() + if (this.closing) return this.closing + const runtime = this.instance + this.instance = undefined + const closing = Promise.resolve().then(() => runtime?.dispose()).finally(() => { + if (this.closing === closing) this.closing = undefined + }) + this.closing = closing + return closing + } + + async enter(): Promise { + if (this.retired) throw new Error('Runtime 配置已退役,请重新选择') + this.clearIdle() + this.active += 1 + try { + await this.closing + if (this.retired) throw new Error('Runtime 配置已退役,请重新选择') + return this.instance ??= this.create() + } catch (error) { + this.leave() + throw error + } + } + + leave(): void { + this.active -= 1 + this.scheduleIdle() + } + + async invoke(operation: (runtime: AgentRuntime) => Promise): Promise { + const runtime = await this.enter() + try { + return await operation(runtime) + } finally { + this.leave() + } + } + + async getStatus() { + if (this.retired) throw new Error('Runtime 配置已退役,请重新选择') + if (this.instance) return this.instance.getStatus() + // Status inspection must not acquire or extend the execution owner's + // lifetime. Both local runtimes can inspect configuration without starting. + const inspection = this.create() + try { + return await inspection.getStatus() + } finally { + await inspection.dispose() + } + } + + async respondToQuestion(questionId: string, answers?: AgentQuestionAnswer[]): Promise { + const runtime = this.instance + if (!runtime?.respondToQuestion) throw new Error('当前 Runtime 不支持回答交互式问题') + // An active request may finish on a retired configuration, including its + // outstanding native question. This does not admit a new request. + this.clearIdle() + this.active += 1 + try { + await runtime.respondToQuestion(questionId, answers) + } finally { + this.leave() + } + } + + async releaseConversation(conversationId: string): Promise { + const runtime = this.instance + if (!runtime) return + this.clearIdle() + this.active += 1 + try { + await runtime.releaseConversation?.(conversationId) + } finally { + this.leave() + } + } + + retire(): void { + this.retired = true + this.scheduleIdle() + } + + async dispose(): Promise { + this.retired = true + await this.close() + this.onClosed() + } +} + +/** Main-owned local processes; workspace facades never own their lifetime. */ +export class LocalRuntimeRegistry { + private readonly current = new Map() + private readonly entries = new Set() + private disposed = false + + constructor(private readonly idleMs = IDLE_RUNTIME_MS) {} + + acquire( + key: string, + configuration: unknown, + workspace: string, + create: () => LocalProcessRuntime + ): AgentRuntime { + if (this.disposed) throw new Error('Runtime 正在关闭') + let entry = this.current.get(key) + if (entry && !isDeepStrictEqual(entry.configuration, configuration)) { + this.current.delete(key) + entry.retire() + entry = undefined + } + if (!entry) { + const created = new LocalRuntimeEntry(configuration, create, this.idleMs, () => { + this.entries.delete(created) + if (this.current.get(key) === created) this.current.delete(key) + }) + this.current.set(key, created) + this.entries.add(created) + entry = created + } + return new WorkspaceRuntime(entry, workspace) + } + + async releaseConversation(conversationId: string): Promise { + await Promise.all([...this.entries].map((entry) => entry.releaseConversation(conversationId))) + } + + reset(keep?: AgentRuntime): void { + const retained = keep instanceof WorkspaceRuntime ? keep.owner : undefined + for (const [key, entry] of this.current) { + if (entry === retained) continue + this.current.delete(key) + entry.retire() + } + } + + async dispose(): Promise { + this.disposed = true + this.current.clear() + await Promise.all([...this.entries].map((entry) => entry.dispose())) + } +} + +class WorkspaceRuntime implements AgentRuntime { + readonly sharedProcess = true + private disposed = false + + constructor( + private readonly entry: LocalRuntimeEntry, + private readonly workspace: string + ) {} + + get owner(): LocalRuntimeEntry { return this.entry } + get runtimeId() { return this.entry.description.runtimeId } + get requiresToolApproval() { return this.entry.description.requiresToolApproval } + get supportsToolExecution() { return this.entry.description.supportsToolExecution } + get supportsScopedDataTools() { return this.entry.description.supportsScopedDataTools } + get capability() { return this.entry.description.capability } + + private check(): void { + if (this.disposed) throw new Error('Runtime 工作区已释放') + } + + getStatus() { + this.check() + return this.entry.getStatus() + } + + testConnection() { + this.check() + return this.entry.invoke((runtime) => runtime.testConnection?.() ?? runtime.getStatus()) + } + + getNativeSnapshot() { + this.check() + return this.entry.invoke((runtime) => { + if (!runtime.getNativeSnapshot) throw new Error('当前 Runtime 不支持原生能力清单') + return runtime.getNativeSnapshot(this.workspace) + }) + } + + async *run( + request: AgentExecutionRequest, + signal: AbortSignal, + authorize?: RuntimeAuthorizer + ): AsyncGenerator { + this.check() + signal.throwIfAborted() + const runtime = await this.entry.enter() + try { + yield* runtime.run({ ...request, executionWorkspace: this.workspace }, signal, authorize) + } finally { + this.entry.leave() + } + } + + compactConversation(request: RuntimeConversationCompactInput, signal: AbortSignal) { + this.check() + return this.entry.invoke((runtime) => { + if (!runtime.compactConversation) throw new Error('当前 Runtime 不支持手动压缩') + return runtime.compactConversation(request, signal) + }) + } + + respondToQuestion(questionId: string, answers?: AgentQuestionAnswer[]) { + this.check() + return this.entry.respondToQuestion(questionId, answers) + } + + releaseConversation(conversationId: string) { + return this.entry.releaseConversation(conversationId) + } + + async dispose(): Promise { + this.disposed = true + } +} diff --git a/src/main/agent/local-runtime-reuse.test.ts b/src/main/agent/local-runtime-reuse.test.ts new file mode 100644 index 00000000..e167f8a8 --- /dev/null +++ b/src/main/agent/local-runtime-reuse.test.ts @@ -0,0 +1,220 @@ +// @vitest-environment node +import { existsSync } from 'node:fs' +import { mkdir, mkdtemp, readFile, realpath, rm, writeFile } from 'node:fs/promises' +import { createServer } from 'node:http' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' +import { expect, it, vi } from 'vitest' +import { createCanvas } from '@napi-rs/canvas' +import { defaultRuntimeSettings } from '../../shared/contracts' +import type { ResolvedRuntimeSettings } from '../runtime-settings-store' +import { ExecutionSpaceResolver } from '../execution-space/execution-space-resolver' +import { createAgentRuntime } from './create-runtime' +import { LocalRuntimeRegistry } from './local-runtime-registry' +import { OpenCodeRuntime } from './opencode-runtime' +import { SelectedRuntimeManager } from './selected-runtime-manager' +import { KnowledgeMcpGateway } from './knowledge-mcp-gateway' +import { KnowledgeService } from '../knowledge/knowledge-service' + +const binaryPath = join( + process.cwd(), '.runtime-resources', process.arch, + process.platform === 'win32' ? 'opencode.exe' : 'opencode' +) + +it.skipIf(!existsSync(binaryPath)).each([2, 10])('uses one real OpenCode server across %i projects for tools, continuation and compaction', async (projectCount) => { + const root = await realpath(await mkdtemp(join(tmpdir(), 'goodbuddy-opencode-reuse-'))) + const directories = Array.from( + { length: projectCount }, (_, index) => join(root, `project-${index}`) + ) + await Promise.all(directories.map(async (directory, index) => { + await mkdir(directory) + await writeFile(join(directory, 'marker.txt'), `PROJECT_${index}`) + })) + let modelCalls = 0 + const toolResults: unknown[] = [] + const capabilityResults: string[] = [] + const observedImages = new Set() + const requestImages = new Map() + const observedRequestImages = new Map>() + const images = directories.slice(0, 2).map((_, index) => + createCanvas(index + 1, 1).toBuffer('image/png').toString('base64')) + const knowledge = projectCount === 2 ? new KnowledgeService({ + databasePath: join(root, 'knowledge.sqlite'), managedRoot: join(root, 'knowledge') + }) : undefined + const gateway = knowledge ? new KnowledgeMcpGateway(knowledge) : undefined + await knowledge?.initialize() + await gateway?.start() + const grants = gateway ? vi.spyOn(gateway, 'grantCustomMcp') : undefined + const mcpCalls = gateway ? vi.spyOn(gateway as unknown as { + callCustomMcpTool(token: string, binding: unknown, args: Record, signal: AbortSignal): Promise + }, 'callCustomMcpTool') : undefined + const model = createServer((request, response) => { + void (async () => { + let content = '' + for await (const chunk of request) content += String(chunk) + const body = JSON.parse(content) as { + messages: Array<{ role: string; content: unknown }> + tools?: Array<{ function: { name: string } }> + } + modelCalls++ + let userIndex = -1 + body.messages.forEach((message, index) => { if (message.role === 'user') userIndex = index }) + const results = body.messages.slice(userIndex + 1).filter((message) => message.role === 'tool') + const result = results.at(-1) + if (result) toolResults.push(result.content) + const resultText = JSON.stringify(results.map(message => message.content)) + const marker = resultText.match(/PROJECT_\d+/u)?.[0] + const read = body.tools?.some((tool) => tool.function.name === 'read') + let tool: { name: string; arguments: Record } | undefined = + read && !result ? { name: 'read', arguments: { filePath: 'marker.txt' } } : undefined + if (projectCount === 2 && read) { + const requestId = JSON.stringify(body.messages[userIndex]?.content).match(/REQUEST_([a-f0-9-]{36})/u)?.[1] + for (const image of images) { + if (JSON.stringify(body.messages).includes(image)) { + observedImages.add(image) + if (requestId) { + const seen = observedRequestImages.get(requestId) ?? new Set() + seen.add(image) + observedRequestImages.set(requestId, seen) + } + } + } + if (results.length === 1) { + tool = { name: 'skill', arguments: { name: 'web-3d-game' } } + } else if (results.length === 2) { + const name = body.tools?.find(item => item.function.name.endsWith('_create_game_blueprint'))?.function.name + if (!name) throw new Error('Shared OpenCode MCP fixture is not exposed') + tool = { + name, arguments: { + theme: 'neon-ruins', + seed: JSON.stringify(body.messages[userIndex]?.content).match(/REQUEST_([a-f0-9-]{36})/u)?.[1], + targetCount: 5 + } + } + } else if (results.length === 3) { + capabilityResults.push(resultText) + } + } + const callTool = tool !== undefined + response.writeHead(200, { 'Content-Type': 'text/event-stream' }) + const delta = callTool + ? { + role: 'assistant', + tool_calls: [{ + index: 0, id: `read-${modelCalls}`, type: 'function', + function: { name: tool!.name, arguments: JSON.stringify(tool!.arguments) } + }] + } + : { role: 'assistant', content: marker ?? 'CONTEXT_SUMMARY' } + for (const [value, finish] of [[delta, null], [{}, callTool ? 'tool_calls' : 'stop']]) { + response.write(`data: ${JSON.stringify({ + id: `reuse-${modelCalls}`, object: 'chat.completion.chunk', created: 1, + model: 'reuse', choices: [{ index: 0, delta: value, finish_reason: finish }] + })}\n\n`) + } + response.end('data: [DONE]\n\n') + })().catch(() => response.destroy()) + }) + await new Promise((resolve) => model.listen(0, '127.0.0.1', resolve)) + const port = (model.address() as { port: number }).port + const profile = { + id: '00000000-0000-4000-8000-000000000008', + name: 'Reuse fixture', modelName: 'reuse', baseUrl: `http://127.0.0.1:${port}/v1`, + protocol: 'openai-chat-completions' as const, authentication: 'none' as const, + supportsImageInput: projectCount === 2 + } + const settings = { + ...defaultRuntimeSettings, provider: 'opencode', opencodeBinaryPath: binaryPath, + modelProfiles: [profile], opencodeModelProfile: profile + } as ResolvedRuntimeSettings + const registry = new LocalRuntimeRegistry() + const resolver = new ExecutionSpaceResolver() + const create = vi.fn(async (_selection, executionSpace) => createAgentRuntime(root, settings, { + executionSpace, localRuntimeRegistry: registry, knowledgeGateway: gateway, + ...(projectCount === 2 ? { + skillPackages: [{ id: 'web-3d-game', directory: resolve('tests/fixtures/web-3d-game-skill') }], + mcpServers: [{ + id: 'fbf42200-4e60-48d0-b5f2-e816db38ac54', name: 'Shared blueprint', description: '', + enabled: true, allowDynamicTools: false, assignments: ['opencode' as const], + secretConfigured: false, transport: 'stdio' as const, + command: process.versions.electron ? 'node' : process.execPath, + args: [resolve('tests/fixtures/web-3d-game-mcp.mjs')] + }] + } : {}) + })) + const manager = new SelectedRuntimeManager(create, 1, 1, undefined, undefined, (id) => registry.releaseConversation(id)) + const connections = vi.spyOn(OpenCodeRuntime.prototype, 'testConnection') + const launches = vi.spyOn( + OpenCodeRuntime.prototype as unknown as { launchEmbedded(): Promise }, + 'launchEmbedded' + ) + const nativeSessions = new Set() + try { + const selection = { provider: 'opencode' as const } + const facades = await Promise.all(directories.map((directory) => + manager.getRuntime(selection, resolver.resolveLocal(directory)) + )) + for (let wave = 0; wave < 2; wave++) { + await Promise.all(facades.flatMap((runtime, project) => [0, 1].map(async (conversation) => { + let output = '' + const requestId = crypto.randomUUID() + if (projectCount === 2 && wave === 0) requestImages.set(requestId, images[project]!) + for await (const event of runtime.run({ + requestId, conversationId: `project-${project}-${conversation}`, + prompt: `Read marker.txt for wave ${wave}. REQUEST_${requestId}`, workMode: 'execute', + ...(projectCount === 2 && wave === 0 ? { images: [{ + name: `project-${project}.png`, mediaType: 'image/png', data: images[project]! + }] } : {}) + }, AbortSignal.timeout(40_000))) { + if (event.type === 'text') output += event.delta + if (event.type === 'done' && event.sessionId) nativeSessions.add(event.sessionId) + } + expect(output, JSON.stringify(toolResults)).toBe(`PROJECT_${project}`) + }))) + } + expect(nativeSessions.size).toBe(projectCount * 2) + if (projectCount === 2) { + expect(observedImages.size).toBe(2) + for (const [requestId, image] of requestImages) { + expect(observedRequestImages.get(requestId)).toEqual(new Set([image])) + } + expect(capabilityResults).toHaveLength(8) + for (const result of capabilityResults) { + expect(result).toContain('window.__GOODBUDDY_GAME__') + expect(result).toContain('Prism Relay') + } + expect(mcpCalls).toHaveBeenCalledTimes(8) + for (const [token, , args] of mcpCalls!.mock.calls) { + const grantIndex = grants!.mock.results.findIndex(result => result.value === token) + expect(grantIndex).toBeGreaterThanOrEqual(0) + expect(args.seed).toBe(grants!.mock.calls[grantIndex]![0]) + } + } + for (let index = 0; index < facades.length; index++) { + await expect(facades[index]!.compactConversation({ + requestId: crypto.randomUUID(), conversationId: `project-${index}-0`, + runtimeSelection: selection, history: [], historyMessageIds: [] + }, AbortSignal.timeout(30_000))).resolves.toMatchObject({ result: { compacted: true } }) + } + await Promise.all(facades.map((runtime) => runtime.testConnection())) + // Every facade invokes the same process-owning Runtime, even beyond capacity 1. + expect(new Set(connections.mock.instances).size).toBe(1) + expect(launches).toHaveBeenCalledOnce() + for (const [index, directory] of directories.entries()) { + expect(await readFile(join(directory, 'marker.txt'), 'utf8')).toBe(`PROJECT_${index}`) + } + await manager.releaseConversation('project-0-0') + } finally { + connections.mockRestore() + launches.mockRestore() + mcpCalls?.mockRestore() + grants?.mockRestore() + await manager.dispose() + await registry.dispose() + await gateway?.dispose() + await knowledge?.dispose() + model.closeAllConnections() + await new Promise((resolve) => model.close(() => resolve())) + await rm(root, { recursive: true, force: true }) + } +}, 120_000) diff --git a/src/main/agent/opencode-runtime.test.ts b/src/main/agent/opencode-runtime.test.ts index 7585aea0..477c53f2 100644 --- a/src/main/agent/opencode-runtime.test.ts +++ b/src/main/agent/opencode-runtime.test.ts @@ -1692,6 +1692,31 @@ describe("OpenCodeRuntime embedded launcher", () => { expect(child.kill).toHaveBeenCalledWith("SIGTERM"); }); + it("keeps a shared cold start alive when only its first waiter cancels", async () => { + const child = fakeChild(); + let healthy = false; + const { deps, spawnMock } = dependencies(child, { + startupTimeoutMs: 2_000, + checkServerHealth: vi.fn(async () => healthy), + }); + const runtime = new OpenCodeRuntime(options(), deps); + const controller = new AbortController(); + const pending = runtime.run({ + requestId: "cancel-initializer", conversationId: "first", + prompt: "test", executionWorkspace: resolve("one"), + }, controller.signal).next(); + const rejected = expect(pending).rejects.toThrow("启动已取消"); + await vi.waitFor(() => expect(spawnMock).toHaveBeenCalledOnce()); + const peer = runtime.testConnection(); + controller.abort(); + await rejected; + expect(child.kill).not.toHaveBeenCalled(); + healthy = true; + await expect(peer).resolves.toMatchObject({ available: true }); + expect(spawnMock).toHaveBeenCalledOnce(); + await runtime.dispose(); + }); + it("bounds session setup without imposing a full-run deadline", async () => { const harness = runClient([]); ( diff --git a/src/main/agent/opencode-runtime.ts b/src/main/agent/opencode-runtime.ts index 017436ef..3cd78267 100644 --- a/src/main/agent/opencode-runtime.ts +++ b/src/main/agent/opencode-runtime.ts @@ -729,7 +729,9 @@ export class OpenCodeRuntime implements AgentRuntime { private clientInitialization?: Promise; private server?: OpenCodeServer; private startingChild?: SpawnedProcess; - private readonly sessions = new Map(); + private readonly sessions = new Map(); + private initializationController?: AbortController; + private initializationWaiters = 0; private readonly sessionInitializations = new Map>(); private readonly pendingQuestions = new Map< string, @@ -761,6 +763,10 @@ export class OpenCodeRuntime implements AgentRuntime { }; } + get hasRetainedSessions(): boolean { + return this.sessions.size > 0 || this.sessionInitializations.size > 0; + } + private usesEmbeddedPermissionMediation(): boolean { return this.options.embedded && !this.options.baseUrl; } @@ -1276,19 +1282,38 @@ export class OpenCodeRuntime implements AgentRuntime { if (this.client && !this.server) { return this.client; } - const existingInitialization = this.clientInitialization; const cached = Boolean(this.client && this.server); - // Cached health checks are shared, but each caller owns only its wait. - const initialization = this.clientInitialization ??= this.initializeClient( - cached ? undefined : signal, - ).finally(() => { - if (this.clientInitialization === initialization) { - this.clientInitialization = undefined; + this.initializationWaiters += 1; + if (!this.clientInitialization) { + const controller = this.initializationController = new AbortController(); + const initialization = this.initializeClient( + cached ? undefined : controller.signal, + ).finally(() => { + if (this.clientInitialization === initialization) { + this.clientInitialization = undefined; + this.initializationController = undefined; + } + }); + this.clientInitialization = initialization; + } + try { + return signal + ? await awaitWithAbort(this.clientInitialization, signal) + : await this.clientInitialization; + } catch (error) { + if (signal?.aborted && !cached && !this.client) { + throw new Error("OpenCode Server 启动已取消", { cause: error }); } - }); - return signal && (cached || existingInitialization) - ? await awaitWithAbort(initialization, signal) - : await initialization; + throw error; + } finally { + this.initializationWaiters -= 1; + if (this.initializationWaiters === 0 && !this.client) { + this.initializationController?.abort( + new Error("OpenCode 初始化已无等待请求"), + ); + await this.clientInitialization?.catch(() => undefined); + } + } } private async controlRequest( @@ -1460,6 +1485,7 @@ export class OpenCodeRuntime implements AgentRuntime { private async discoverAgents( client: OpencodeClient, signal?: AbortSignal, + directory = this.options.defaultWorkspace, ): Promise< Array<{ id: string; @@ -1475,7 +1501,7 @@ export class OpenCodeRuntime implements AgentRuntime { (controlSignal) => client.app.agents( { - directory: this.options.defaultWorkspace, + directory, }, { signal: controlSignal }, ), @@ -1521,7 +1547,9 @@ export class OpenCodeRuntime implements AgentRuntime { if (!this.supportsNativeCustomization()) { throw new Error("外部 OpenCode Server 不支持由 GoodBuddy 选择 Agent"); } - const agents = await this.discoverAgents(client, signal); + const agents = await this.discoverAgents( + client, signal, request.executionWorkspace ?? this.options.defaultWorkspace, + ); if ( !agents.some( (agent) => @@ -1537,7 +1565,9 @@ export class OpenCodeRuntime implements AgentRuntime { return selected; } - async getNativeSnapshot(): Promise { + async getNativeSnapshot( + directory = this.options.defaultWorkspace, + ): Promise { const controlled = this.supportsNativeCustomization(); const empty: RuntimeNativeSnapshot = { provider: "opencode", @@ -1572,7 +1602,7 @@ export class OpenCodeRuntime implements AgentRuntime { const response = await this.controlRequest("连接检查", (signal) => client.session.list( { - directory: this.options.defaultWorkspace, + directory, limit: 1, }, { signal }, @@ -1602,7 +1632,6 @@ export class OpenCodeRuntime implements AgentRuntime { }; } - const directory = this.options.defaultWorkspace; const assignedSkillIds = new Set( (this.options.skillPackages ?? []).map((skill) => skill.id), ); @@ -1626,7 +1655,7 @@ export class OpenCodeRuntime implements AgentRuntime { { signal }, ), ), - this.discoverAgents(client), + this.discoverAgents(client, undefined, directory), this.controlRequest("读取工具清单", (signal) => client.tool.ids({ directory }, { signal }), ), @@ -1914,8 +1943,11 @@ export class OpenCodeRuntime implements AgentRuntime { permission?: PermissionRuleset, ): Promise<{ id: string; created: boolean }> { const current = this.sessions.get(request.conversationId); + if (current?.directory === directory) { + return { id: current.id, created: false }; + } if (current) { - return { id: current, created: false }; + await this.releaseConversation(request.conversationId); } const pending = this.sessionInitializations.get(request.conversationId); if (pending) { @@ -1959,7 +1991,7 @@ export class OpenCodeRuntime implements AgentRuntime { const stillCurrent = this.sessionInitializations.get(request.conversationId) === creation; if (signal.aborted || !stillCurrent) { - if (this.sessions.get(request.conversationId) !== sessionId) { + if (this.sessions.get(request.conversationId)?.id !== sessionId) { void client.session .delete( { @@ -1975,7 +2007,7 @@ export class OpenCodeRuntime implements AgentRuntime { } throw new Error("OpenCode 会话初始化已失效"); } - this.sessions.set(request.conversationId, sessionId); + this.sessions.set(request.conversationId, { id: sessionId, directory }); return sessionId; }); this.sessionInitializations.set(request.conversationId, creation); @@ -2072,7 +2104,7 @@ export class OpenCodeRuntime implements AgentRuntime { signal: AbortSignal, client: OpencodeClient, ): AsyncGenerator { - const directory = this.options.defaultWorkspace; + const directory = resolve(request.executionWorkspace ?? this.options.defaultWorkspace); const runtimeControl = request.runtimeControl?.provider === "opencode" ? request.runtimeControl @@ -3065,7 +3097,8 @@ export class OpenCodeRuntime implements AgentRuntime { executionSignal = AbortSignal.any([executionSignal, server.signal]); } } - const sessionId = this.sessions.get(request.conversationId); + const session = this.sessions.get(request.conversationId); + const sessionId = session?.id; if (!sessionId || !client) { return { result: { @@ -3076,6 +3109,7 @@ export class OpenCodeRuntime implements AgentRuntime { }, }; } + const directory = session!.directory; const context = await this.controlRequest( "读取原生上下文", (controlSignal) => @@ -3122,7 +3156,7 @@ export class OpenCodeRuntime implements AgentRuntime { "订阅 Compact 事件流", (controlSignal) => client.event.subscribe( - { directory: this.options.defaultWorkspace }, + { directory }, eventSubscriptionOptions(controlSignal), ), subscriptionSignal, @@ -3162,7 +3196,7 @@ export class OpenCodeRuntime implements AgentRuntime { const compact = await client.session.summarize( { sessionID: sessionId, - directory: this.options.defaultWorkspace, + directory, providerID: configuredModel.providerID, modelID: configuredModel.modelID, auto: false, @@ -3220,6 +3254,7 @@ export class OpenCodeRuntime implements AgentRuntime { } async dispose(): Promise { + this.initializationController?.abort(new Error("OpenCode Runtime 已关闭")); this.pendingQuestions.clear(); const startingChild = this.startingChild; this.startingChild = undefined; @@ -3238,16 +3273,16 @@ export class OpenCodeRuntime implements AgentRuntime { } async releaseConversation(conversationId: string): Promise { - const sessionId = this.sessions.get(conversationId); + const session = this.sessions.get(conversationId); this.sessions.delete(conversationId); - if (!sessionId || !this.client) { + if (!session || !this.client) { return; } await this.controlRequest("释放会话", (signal) => this.client!.session.delete( { - sessionID: sessionId, - directory: this.options.defaultWorkspace, + sessionID: session.id, + directory: session.directory, }, { signal }, ), diff --git a/src/main/agent/runtime-controller.test.ts b/src/main/agent/runtime-controller.test.ts index 46433173..d6818a52 100644 --- a/src/main/agent/runtime-controller.test.ts +++ b/src/main/agent/runtime-controller.test.ts @@ -11,6 +11,39 @@ import { RuntimeReplacementCapacityError } from './runtime-controller' +it('routes a shared request question to its original Runtime across replacement', async () => { + let release!: () => void + const gate = new Promise(resolve => { release = resolve }) + const previous: AgentRuntime = { + sharedProcess: true, requiresToolApproval: false, supportsToolExecution: true, + getStatus: vi.fn(), dispose: vi.fn(async () => undefined), + respondToQuestion: vi.fn(async () => undefined), + async *run(request) { + yield { type: 'question', requestId: request.requestId, questionId: 'question-old', questions: [] } + await gate + yield { type: 'done', requestId: request.requestId } + } + } + const next: AgentRuntime = { ...previous, respondToQuestion: vi.fn(async () => undefined) } + const controller = new AgentRuntimeController(previous, 1) + const stream = controller.run({ + requestId: 'old-request', conversationId: 'old-conversation', prompt: 'test', workMode: 'execute' + }, new AbortController().signal) + try { + expect((await stream.next()).value?.type).toBe('question') + await controller.replace(next) + await controller.respondToQuestion('question-old', []) + expect(previous.respondToQuestion).toHaveBeenCalledWith('question-old', []) + expect(next.respondToQuestion).not.toHaveBeenCalled() + release() + expect((await stream.next()).value?.type).toBe('done') + } finally { + release() + await stream.return() + await controller.dispose() + } +}) + class TestRuntime implements AgentRuntime { readonly dispose = vi.fn(async () => {}) readonly started: Promise @@ -176,6 +209,26 @@ describe('AgentRuntimeController', () => { await controller.dispose() }) + it('lets a shared local request finish while replacing its workspace facade', async () => { + const previous = Object.assign(new TestRuntime(true), { sharedProcess: true }) + const controller = new AgentRuntimeController(previous, 1) + const stream = controller.run({ + requestId: '00000000-0000-4000-8000-000000000071', + conversationId: 'shared-replacement', prompt: 'continue', workMode: 'ask' + }, new AbortController().signal) + const pending = stream.next() + await previous.started + await controller.replace(new TestRuntime()) + expect(previous.dispose).not.toHaveBeenCalled() + previous.finish() + await expect(pending).resolves.toMatchObject({ + done: false, value: { type: 'text' } + }) + await stream.next() + expect(previous.dispose).toHaveBeenCalledOnce() + await controller.dispose() + }) + it('drains remote sessions without interrupting their active prompt', async () => { const previous = new DrainableTestRuntime() const next = new TestRuntime() diff --git a/src/main/agent/runtime-controller.ts b/src/main/agent/runtime-controller.ts index df985963..6d034342 100644 --- a/src/main/agent/runtime-controller.ts +++ b/src/main/agent/runtime-controller.ts @@ -80,6 +80,7 @@ export class AgentRuntimeController implements AgentRuntime { private retirement?: Promise private disposal?: Promise private readonly ownedConversationIds = new Set() + private readonly questionOwners = new Map() constructor( runtime: AgentRuntime, @@ -147,11 +148,15 @@ export class AgentRuntimeController implements AgentRuntime { return this.ownedConversationIds.size } + get sharedProcess(): boolean { + return this.current.runtime.sharedProcess === true + } + get canRetire(): boolean { return ( !this.closing && this.activeRequestCount === 0 && - this.ownedConversationIds.size === 0 + (this.sharedProcess || this.ownedConversationIds.size === 0) ) } @@ -269,7 +274,7 @@ export class AgentRuntimeController implements AgentRuntime { slot.activeRequests += 1 try { const status = await operation(slot.runtime) - if (slot !== this.current) { + if (slot !== this.current && !(slot.runtime.sharedProcess && stage === 'compact')) { throw new Error('Runtime 已切换,请重试') } return status @@ -347,13 +352,16 @@ export class AgentRuntimeController implements AgentRuntime { signal, effectiveAuthorize )) { - if (slot !== this.current && !slot.drainable) { + if (slot !== this.current && !slot.drainable && !slot.runtime.sharedProcess) { throw new Error('Runtime 已切换,当前请求已中断') } toolDenied = false + if (event.type === 'question') { + this.questionOwners.set(event.questionId, { slot, requestId: request.requestId }) + } yield event } - if (slot !== this.current && !slot.drainable) { + if (slot !== this.current && !slot.drainable && !slot.runtime.sharedProcess) { throw new Error('Runtime 已切换,当前请求已中断') } } catch (error) { @@ -368,6 +376,9 @@ export class AgentRuntimeController implements AgentRuntime { throw error } } finally { + for (const [questionId, owner] of this.questionOwners) { + if (owner.requestId === request.requestId) this.questionOwners.delete(questionId) + } slot.activeRequests -= 1 if ( slot.retiring && @@ -419,14 +430,16 @@ export class AgentRuntimeController implements AgentRuntime { questionId: string, answers?: AgentQuestionAnswer[] ): Promise { - if (this.closing) { + const owner = this.questionOwners.get(questionId) + if (this.closing && !owner) { throw new Error('Agent Runtime 正在关闭') } - const runtime = this.current.runtime + const runtime = (owner?.slot ?? this.current).runtime if (!runtime.respondToQuestion) { throw new Error('当前 Runtime 不支持回答交互式问题') } await runtime.respondToQuestion(questionId, answers) + this.questionOwners.delete(questionId) } private retireSlot(slot: RuntimeSlot): Promise { diff --git a/src/main/agent/runtime.ts b/src/main/agent/runtime.ts index 9386af4d..f98ca889 100644 --- a/src/main/agent/runtime.ts +++ b/src/main/agent/runtime.ts @@ -86,6 +86,9 @@ export type RuntimeConversationCompactOutcome = { } export interface AgentRuntime { + /** A workspace facade whose sessions are owned by a shared local Runtime. */ + readonly sharedProcess?: boolean + readonly hasRetainedSessions?: boolean readonly runtimeId?: AgentRuntimeStatus['id'] readonly requiresToolApproval: boolean readonly supportsToolExecution: boolean @@ -94,7 +97,7 @@ export interface AgentRuntime { readonly capability?: 'chat' | 'image-generation' getStatus(): Promise testConnection?(): Promise - getNativeSnapshot?(): Promise + getNativeSnapshot?(workspace?: string): Promise compactConversation?( request: RuntimeConversationCompactInput, signal: AbortSignal @@ -132,6 +135,8 @@ export type RemoteRecoveredSubagent = ConversationSubagentActivity export type AgentExecutionRequest = Omit & { + /** Resolved Main-side workspace, never taken from the current UI selection. */ + executionWorkspace?: string workMode?: WorkMode images?: AgentImage[] imageContextNotice?: ConversationMessage['imageContextNotice'] diff --git a/src/main/agent/selected-runtime-manager.test.ts b/src/main/agent/selected-runtime-manager.test.ts index 4a836669..f5a32190 100644 --- a/src/main/agent/selected-runtime-manager.test.ts +++ b/src/main/agent/selected-runtime-manager.test.ts @@ -510,6 +510,44 @@ describe('SelectedRuntimeManager', () => { await manager.dispose() }) + it('answers owned questions after settings reset while rejecting new work', async () => { + const active = runtime() + active.value.respondToQuestion = vi.fn(async () => undefined) + active.value.run = async function* (request) { + yield { + requestId: request.requestId, type: 'question', questionId: 'retained-question', + questions: [{ header: 'Choice', question: 'Continue?', options: [], multiple: false, custom: true }] + } + yield { requestId: request.requestId, type: 'done' } + } + const manager = new SelectedRuntimeManager(async () => active.value) + const controller = await manager.getRuntime({ provider: 'opencode' }, executionSpace('project-one')) + const stream = controller.run({ + requestId: 'retained-request', conversationId: 'retained-conversation', + prompt: 'Ask a question', workMode: 'execute' + }, new AbortController().signal) + try { + expect((await stream.next()).value?.type).toBe('question') + await manager.reset() + expect(active.dispose).not.toHaveBeenCalled() + await expect(controller.respondToQuestion('unknown-question', [['Yes']])) + .rejects.toThrow('正在关闭') + await expect(controller.respondToQuestion('retained-question', [['Yes']])) + .resolves.toBeUndefined() + expect(active.value.respondToQuestion).toHaveBeenCalledWith('retained-question', [['Yes']]) + await expect(controller.run({ + requestId: 'new-request', conversationId: 'new-conversation', + prompt: 'New work', workMode: 'execute' + }, new AbortController().signal).next()).rejects.toThrow('正在关闭') + expect((await stream.next()).value?.type).toBe('done') + await stream.next() + await vi.waitFor(() => expect(active.dispose).toHaveBeenCalledOnce()) + } finally { + await stream.return() + await manager.dispose() + } + }) + it('routes application exit through cached runtime controllers without ordinary disposal', async () => { const active = runtime() const detachForApplicationExit = vi.fn(() => undefined) @@ -595,7 +633,8 @@ describe('SelectedRuntimeManager', () => { await expect( manager.getRuntime(selection('43')) ).rejects.toThrow(/退役容量/iu) - expect(create).toHaveBeenCalledTimes(2) + expect(create).toHaveBeenCalledTimes(3) + expect(created[2]!.dispose).toHaveBeenCalledOnce() await manager.dispose() expect(created[0]!.dispose).toHaveBeenCalledOnce() expect(created[1]!.dispose).toHaveBeenCalledOnce() @@ -607,7 +646,10 @@ describe('SelectedRuntimeManager', () => { finishCreate = resolve }) const created = runtime() - const create = vi.fn(async () => await pendingCreate) + const rejected = runtime() + const create = vi.fn() + .mockReturnValueOnce(pendingCreate) + .mockResolvedValueOnce(rejected.value) const manager = new SelectedRuntimeManager(create, 1, 1) const selection = (suffix: string) => ({ provider: 'model' as const, @@ -622,7 +664,8 @@ describe('SelectedRuntimeManager', () => { await expect( manager.getRuntime(selection('52')) ).rejects.toThrow(/退役容量/iu) - expect(create).toHaveBeenCalledOnce() + expect(create).toHaveBeenCalledTimes(2) + expect(rejected.dispose).toHaveBeenCalledOnce() finishCreate(created.value) await expect(firstRuntime).resolves.toEqual( diff --git a/src/main/agent/selected-runtime-manager.ts b/src/main/agent/selected-runtime-manager.ts index c8ed5c7e..75ffa2e3 100644 --- a/src/main/agent/selected-runtime-manager.ts +++ b/src/main/agent/selected-runtime-manager.ts @@ -76,7 +76,8 @@ export class SelectedRuntimeManager implements SelectedRuntimeResolver { DEFAULT_MAXIMUM_RETAINED_RUNTIMES, private readonly observeFailure?: DesktopDiagnosticFailureObserver, - private readonly createStatusRuntime = createRuntime + private readonly createStatusRuntime = createRuntime, + private readonly releaseSharedConversation?: (conversationId: string) => Promise ) { if ( !Number.isSafeInteger(maximumCachedRuntimes) || @@ -111,21 +112,6 @@ export class SelectedRuntimeManager implements SelectedRuntimeResolver { return existing.operation } this.evictIdleEntries(this.maximumCachedRuntimes - 1) - if (this.entries.size >= this.maximumCachedRuntimes) { - throw new Error( - 'Agent Runtime 缓存已被活动会话占满,请先关闭不再使用的会话' - ) - } - if ( - this.entries.size + - this.retiringControllers.size + - this.pendingRetirementControllers >= - this.maximumRetainedRuntimes - ) { - throw new Error( - 'Agent Runtime 退役容量已满,请先关闭仍由旧设置持有的会话' - ) - } const operation = this.createRuntime(selection, executionSpace).then( async (runtime) => { if ( @@ -135,6 +121,21 @@ export class SelectedRuntimeManager implements SelectedRuntimeResolver { await runtime.dispose() throw new Error('Runtime 设置已更改,请重新选择') } + if (!runtime.sharedProcess) { + const currentCount = [...this.entries.values()].filter( + (candidate) => candidate !== entry && !candidate.controller?.sharedProcess + ).length + const retiredCount = [...this.retiringControllers].filter( + (candidate) => !candidate.sharedProcess + ).length + this.pendingRetirementControllers + if ( + currentCount >= this.maximumCachedRuntimes || + currentCount + retiredCount >= this.maximumRetainedRuntimes + ) { + await runtime.dispose() + throw new Error('Agent Runtime 退役容量已满,请先关闭仍由旧设置持有的会话') + } + } const controller = new AgentRuntimeController( runtime, undefined, @@ -234,6 +235,7 @@ export class SelectedRuntimeManager implements SelectedRuntimeResolver { } async releaseConversation(conversationId: string): Promise { + await this.releaseSharedConversation?.(conversationId) const controllers = await Promise.allSettled([ ...[...this.entries.values()].map(({ operation }) => operation) ]) diff --git a/src/main/ipc.test.ts b/src/main/ipc.test.ts index 9ad2c982..0ebd8892 100644 --- a/src/main/ipc.test.ts +++ b/src/main/ipc.test.ts @@ -12,10 +12,13 @@ import { join } from 'node:path' import { ipcChannels } from '../shared/ipc-channels' import type { AssistantProject, + ConversationSnapshot, ConversationQueueItem } from '../shared/assistant-contracts' import { browserTabIdSchema, + defaultRuntimeSettings, + runtimeSettingsInputSchema, type AgentEvent, type BrowserLiveState, type BrowserTabId @@ -27,6 +30,7 @@ import { defaultKnowledgeOntologySettings } from '../shared/knowledge-ontology' import { AssistantDatabase } from './assistant/assistant-database' import { KnowledgeService } from './knowledge/knowledge-service' import { BrowserNavigationStoppedError } from './browser/browser-service' +import { RemotePromptCancelledError } from './agent/acp-remote-runtime' import { registerIpcHandlers, sendRemoteEnvironmentUpdateProgress, @@ -4863,7 +4867,7 @@ describe('registerIpcHandlers Runtime customization', () => { settingsStore.updateRuntimeCustomization ).toHaveBeenCalledWith(customization) expect(onRuntimeSettingsChanged).toHaveBeenCalledOnce() - expect(approvalBroker.clear).toHaveBeenCalledOnce() + expect(approvalBroker.clear).not.toHaveBeenCalled() const activationError = new Error('customization activation failed') onRuntimeSettingsChanged .mockRejectedValueOnce(activationError) @@ -5062,7 +5066,8 @@ describe('registerIpcHandlers agent terminal state', () => { appendRemoteTaskEventOnce: vi.fn< (input: RemoteTaskEventMockInput) => boolean >(() => true), - appendRemoteConversationTaskEventOnce: vi.fn(() => true), + appendRemoteConversationTaskEventOnce: vi.fn(() => true), + listConversations: vi.fn<() => ConversationSnapshot[]>(() => []), listRecoverableRemoteTasks: vi.fn< () => Array<{ taskId: string @@ -5078,7 +5083,9 @@ describe('registerIpcHandlers agent terminal state', () => { getHighestCommittedRemoteTaskEventSequenceForTask: vi.fn( () => '0' ), - failRecoverableRemoteTask: vi.fn(), + endRecoverableRemoteTask: vi.fn(), + recordRemoteTaskQuestionAnswer: vi.fn(), + getTask: vi.fn(), updateTaskStatus: vi.fn(), createTextArtifact: vi.fn(), createImageArtifact: vi.fn(() => ({ @@ -5235,9 +5242,16 @@ describe('registerIpcHandlers agent terminal state', () => { })), updateRuntimeCustomization: vi.fn( async (settings: unknown) => settings - ) + ), + captureRollback: vi.fn(async () => ({ + publicSettings: defaultRuntimeSettings, + restore: async () => defaultRuntimeSettings + })), + update: vi.fn(async (settings: unknown) => settings) } as never, - (capabilityServiceOverride ?? {}) as never, + (capabilityServiceOverride ?? { + setWebSearchEnabled: vi.fn(async () => ({})) + }) as never, contextManager as never, (knowledgeServiceOverride ?? { database: { listKnowledgeBases: vi.fn(() => []) } @@ -5725,7 +5739,7 @@ describe('registerIpcHandlers agent terminal state', () => { await harness.dispose() }) - it('keeps a replacement lease owned after the bulk-aborted lease releases', async () => { + it('keeps the active lease through settings reload and releases it only after cancellation settles', async () => { const controls = new Map< string, { @@ -5803,7 +5817,25 @@ describe('registerIpcHandlers agent terminal state', () => { opencode: {}, continue: { presets: [] } }) + expect(controls.get(oldConversationId)?.signal.aborted).toBe(false) + + await expect(harness.handler?.(event, { + requestId, + conversationId: newConversationId, + prompt: '替换请求', + workMode: 'ask', + knowledgeLibraryIds: [] + })).rejects.toThrow('请求正在执行') + + harness.cancelHandler?.(event, requestId) expect(controls.get(oldConversationId)?.signal.aborted).toBe(true) + controls.get(oldConversationId)?.finish() + await vi.waitFor(() => + expect(goodbuddyConfigService.revokeRequest).toHaveBeenCalledOnce() + ) + expect( + harness.assistantDatabase.updateTaskStatus + ).toHaveBeenCalledWith(requestId, 'cancelled', '请求已取消') await harness.handler?.(event, { requestId, @@ -5815,15 +5847,6 @@ describe('registerIpcHandlers agent terminal state', () => { await vi.waitFor(() => expect(controls.has(newConversationId)).toBe(true) ) - - controls.get(oldConversationId)?.finish() - await vi.waitFor(() => - expect(goodbuddyConfigService.revokeRequest).toHaveBeenCalledOnce() - ) - expect( - harness.assistantDatabase.updateTaskStatus - ).toHaveBeenCalledWith(requestId, 'cancelled', '请求已取消') - harness.cancelHandler?.(event, requestId) expect(controls.get(newConversationId)?.signal.aborted).toBe(true) await expect( @@ -5868,6 +5891,64 @@ describe('registerIpcHandlers agent terminal state', () => { await harness.dispose() }) + it('cancels only an expert-team parent on settings replacement without synthesizing on the replacement model', async () => { + let rejectExpert!: (error: Error) => void + let parentSignal: AbortSignal | undefined + const subagents = { + run: vi.fn(async (input: { expert: { name: string }; signal: AbortSignal }) => { + parentSignal = input.signal + if (input.expert.name === 'First') return { childTaskId: 'first', output: 'Finished analysis' } + return new Promise((_, reject) => { rejectExpert = reject }) + }), + synthesize: vi.fn(async () => 'Must not use replacement model'), + dispose: vi.fn(async () => undefined) + } + let finishPeer!: () => void + let peerSignal: AbortSignal | undefined + const runtime = { + runtimeId: 'model', capability: 'chat', supportsToolExecution: true, + async *run(request: { requestId: string }, signal: AbortSignal) { + peerSignal = signal + await new Promise(resolve => { finishPeer = resolve }) + yield { requestId: request.requestId, type: 'done' as const } + } + } + const harness = createHarness(runtime, undefined, 'always', subagents) + harness.assistantDatabase.listExperts.mockReturnValue([ + { id: '00000000-0000-4000-8000-000000000781', name: 'First', enabled: true }, + { id: '00000000-0000-4000-8000-000000000782', name: 'Second', enabled: true } + ]) + harness.onRuntimeSettingsChanged.mockImplementation(async () => { + rejectExpert(new Error('默认模型设置已更改')) + }) + const event = trustedEvent(harness.webContents) + const teamId = '00000000-0000-4000-8000-000000000783' + try { + await harness.handler?.(event, { + requestId: teamId, conversationId: 'expert-team', + prompt: 'Analyze with the team', teamMode: true, workMode: 'ask', knowledgeLibraryIds: [] + }) + await vi.waitFor(() => expect(subagents.run).toHaveBeenCalledTimes(2)) + await harness.handler?.(event, { + requestId: '00000000-0000-4000-8000-000000000784', conversationId: 'ordinary-peer', + prompt: 'Keep working', workMode: 'ask', knowledgeLibraryIds: [] + }) + await vi.waitFor(() => expect(peerSignal).toBeDefined()) + await electronMocks.handlers.get(ipcChannels.runtimeCustomizationUpdate)!(event, { + opencode: {}, continue: { presets: [] } + }) + expect(parentSignal?.aborted).toBe(true) + expect(peerSignal?.aborted).toBe(false) + await vi.waitFor(() => expect(harness.assistantDatabase.updateTaskStatus) + .toHaveBeenCalledWith(teamId, 'cancelled', '请求已取消')) + expect(subagents.synthesize).not.toHaveBeenCalled() + } finally { + rejectExpert?.(new Error('Test cleanup')) + finishPeer?.() + await harness.dispose() + } + }) + it('detaches an accepted managed SSH run without aborting it or waiting for completion', async () => { let finishRun = (): void => undefined let remoteSignal: AbortSignal | undefined @@ -6555,6 +6636,150 @@ describe('registerIpcHandlers agent terminal state', () => { await harness.dispose() }) + it.each(['runtime', 'customization', 'capabilities'] as const)('restores concurrent live questions and their answer history across %s settings reload', async (settingsKind) => { + const directory = await mkdtemp(join(tmpdir(), 'goodbuddy-recovered-question-history-')) + const databasePath = join(directory, 'assistant.sqlite') + const database = new AssistantDatabase(databasePath) + database.initialize(directory) + const project = database.createSshProject({ + project: { + name: 'Recovered questions', description: '', rootPath: '/srv/project', + defaultWorkMode: 'execute', runtimeSelection: { provider: 'opencode' } + }, + executionSpace: { + kind: 'ssh', hostId: '00000000-0000-4000-8000-000000000850', + remoteRootPath: '/srv/project' + }, + assertCurrent: () => {} + }) + const id = (n: number) => `00000000-0000-4000-8000-${String(n).padStart(12, '0')}` + const tasks = [1, 2].map(n => ({ + taskId: id(810 + n), conversationId: id(820 + n), projectId: project.id, + currentUserMessageId: id(830 + n), currentAssistantMessageId: id(840 + n), + instructions: 'Original question', workMode: 'execute' as const, + status: 'running' as const + })) + for (const task of tasks) { + database.saveLocalConversations([{ + header: { + id: task.conversationId, projectId: project.id, + title: 'Recovered', updatedAt: Date.now(), runtimeSelection: { provider: 'opencode' } + }, + messages: [] + }]) + database.createTask({ + id: task.taskId, conversationId: task.conversationId, projectId: project.id, + title: 'Recovered question', instructions: task.instructions, workMode: task.workMode, + remoteRecovery: { + recoverable: true, currentUserMessageId: task.currentUserMessageId, + currentAssistantMessageId: task.currentAssistantMessageId + } + }) + } + const ready = new Set() + const signals: AbortSignal[] = [] + const finish = new Map void>() + const runtime = { + runtimeId: 'opencode', capability: 'chat', supportsToolExecution: true, + respondToQuestion: vi.fn(async (questionId: string) => { finish.get(questionId)?.() }), + async *run(request: { requestId: string; remoteRecoveryOnly?: boolean }, signal: AbortSignal) { + signals.push(signal) + expect(request.remoteRecoveryOnly).toBe(true) + const answered = new Promise(resolve => { finish.set(request.requestId, resolve) }) + ready.add(request.requestId) + yield { + requestId: request.requestId, type: 'question' as const, questionId: request.requestId, + questions: [{ header: 'Input', question: 'Continue?', options: [], custom: true, multiple: false }] + } + await answered + yield { + requestId: request.requestId, type: 'done' as const, sessionId: request.requestId, + remoteProvenance: { + source: 'remote-semantic-transcript' as const, bindingId: request.requestId, + operationId: request.requestId, semanticSequence: '1', eventIndex: 0 + } + } + yield { + requestId: request.requestId, type: 'remote-semantic-checkpoint' as const, + remoteProvenance: { + source: 'remote-semantic-transcript' as const, bindingId: request.requestId, + operationId: request.requestId, semanticSequence: '1', eventIndex: 1 + } + } + } + } + const { harness } = createManagedSshHarness(runtime) + harness.assistantDatabase.getProject.mockReturnValue({ + id: project.id, runtimeSelection: { provider: 'opencode' }, rootPath: '/srv/project', + executionSpace: { kind: 'ssh', hostId: id(850), remoteRootPath: '/srv/project' } + }) + harness.assistantDatabase.getConversation.mockImplementation(database.getConversation.bind(database)) + harness.assistantDatabase.listConversations.mockImplementation(database.listConversations.bind(database)) + harness.assistantDatabase.listRecoverableRemoteTasks.mockImplementation(database.listRecoverableRemoteTasks.bind(database)) + harness.assistantDatabase.appendRemoteConversationTaskEventOnce.mockImplementation(database.appendRemoteConversationTaskEventOnce.bind(database)) + harness.assistantDatabase.updateTaskStatus.mockImplementation(database.updateTaskStatus.bind(database)) + harness.assistantDatabase.getTask.mockImplementation(database.getTask.bind(database)) + harness.assistantDatabase.recordRemoteTaskQuestionAnswer.mockImplementation( + (...args) => database.recordRemoteTaskQuestionAnswer(...args) + ) + const event = trustedEvent(harness.webContents) + const list = () => electronMocks.handlers.get(ipcChannels.conversationsList)!(event) as ConversationSnapshot[] + try { + harness.recoveryGetHandler?.(event) + await vi.waitFor(() => expect(ready.size).toBe(2)) + await vi.waitFor(() => expect(list().every(c => c.activeRequest?.questions.length === 1)).toBe(true)) + expect(harness.assistantDatabase.appendRemoteConversationTaskEventOnce).not.toHaveBeenCalled() + const update = settingsKind === 'runtime' + ? [ipcChannels.runtimeSettingsUpdate, runtimeSettingsInputSchema.strip().parse({ + ...defaultRuntimeSettings, workspacePath: directory, apiKey: { action: 'keep' } + })] as const + : settingsKind === 'customization' + ? [ipcChannels.runtimeCustomizationUpdate, { opencode: { defaultAgent: 'build' }, continue: { presets: [] } }] as const + : [ipcChannels.capabilitiesToggleWebSearch, false] as const + await electronMocks.handlers.get(update[0])!(event, update[1]) + expect(signals.every(signal => !signal.aborted)).toBe(true) + expect(harness.approvalBroker.clear).not.toHaveBeenCalled() + for (const [index, task] of [...tasks].reverse().entries()) { + const snapshot = list().find(c => c.id === task.conversationId)! + expect(snapshot.activeRequest).toMatchObject({ + requestId: task.taskId, messageId: task.currentAssistantMessageId + }) + await electronMocks.handlers.get(ipcChannels.agentQuestionRespond)!(event, { + questionId: task.taskId, answers: index === 0 ? [['Yes']] : [] + }) + } + await vi.waitFor(() => expect(list().every(c => !c.activeRequest)).toBe(true)) + expect(runtime.respondToQuestion.mock.calls.map(([questionId]) => questionId)) + .toEqual([...tasks].reverse().map(task => task.taskId)) + expect(harness.assistantDatabase.appendRemoteConversationTaskEventOnce).toHaveBeenCalledTimes(4) + database.close() + const reopened = new AssistantDatabase(databasePath) + reopened.initialize(directory) + try { + for (const [index, task] of tasks.entries()) { + expect(reopened.getTask(task.taskId).status).toBe('completed') + expect(reopened.getConversation(task.conversationId).messages[1]).toMatchObject({ + id: task.currentAssistantMessageId, state: 'complete', + answeredQuestions: [{ + questionId: task.taskId, skipped: index === 0, + questions: [{ + header: 'Input', question: 'Continue?', options: [], multiple: false, custom: true, + ...(index === 1 ? { answer: ['Yes'] } : {}) + }] + }] + }) + } + } finally { + reopened.close() + } + } finally { + for (const resolve of finish.values()) resolve() + await harness.dispose() + database.close() + await rm(directory, { recursive: true, force: true }) + } + }) + it('aborts and drains a local run during Desktop shutdown', async () => { let localSignal: AbortSignal | undefined let finishLocalCleanup = (): void => undefined @@ -6627,7 +6852,7 @@ describe('registerIpcHandlers agent terminal state', () => { ).toHaveBeenCalledWith(requestId, 'cancelled', '请求已取消') }) - it('keeps an accepted managed SSH run recoverable after user Stop', async () => { + it.each([false, true])('recovers user Stop only while its remote result is uncertain (confirmed=%s)', async (confirmed) => { let remoteSignal: AbortSignal | undefined let statusConsumed = false const remoteRuntime = { @@ -6650,6 +6875,9 @@ describe('registerIpcHandlers agent terminal state', () => { once: true }) }) + if (confirmed) { + throw new RemotePromptCancelledError('请求已取消') + } signal.throwIfAborted() yield { requestId: request.requestId, type: 'done' } as const } @@ -6697,11 +6925,22 @@ describe('registerIpcHandlers agent terminal state', () => { ) expect(remoteSignal?.aborted).toBe(true) - await vi.waitFor(() => - expect( + if (confirmed) { + await vi.waitFor(() => expect( + harness.assistantDatabase.endRecoverableRemoteTask + ).toHaveBeenCalledWith(requestId, '请求已取消', 'cancelled')) + expect(harness.assistantDatabase.appendTaskEvent).not.toHaveBeenCalledWith( + requestId, 'error', expect.anything() + ) + expect(harness.webContents.send).not.toHaveBeenCalledWith( + ipcChannels.remoteProjectRecoveryProgress, + expect.anything() + ) + } else { + await vi.waitFor(() => expect( harness.assistantDatabase.updateTaskStatus - ).toHaveBeenCalledWith(requestId, 'interrupted', '请求已取消') - ) + ).toHaveBeenCalledWith(requestId, 'interrupted', '请求已取消')) + } await harness.dispose() }) diff --git a/src/main/ipc.ts b/src/main/ipc.ts index 66fae96b..3c1aa6cd 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -240,7 +240,10 @@ import { } from './agent/runtime-selection' import { safeToolErrorDetail } from './agent/approval-summary' import { ReasoningTagStreamParser } from './agent/reasoning-stream' -import { RemotePromptRecoveryUnavailableError } from './agent/acp-remote-runtime' +import { + RemotePromptCancelledError, + RemotePromptRecoveryUnavailableError +} from './agent/acp-remote-runtime' import { bundledContinueVersion, bundledDeepSeekHarnessVersion, @@ -1192,7 +1195,7 @@ export function registerIpcHandlers( assistantDatabase: AssistantDatabase, approvalBroker: ToolApprovalBroker, bundledRuntimePaths: BundledRuntimePaths, - onRuntimeSettingsChanged: () => Promise, + activateRuntimeSettings: () => Promise, onBeforeClearLocalData?: () => Promise, browserControl?: { createTab( @@ -1337,9 +1340,21 @@ export function registerIpcHandlers( controller: AbortController conversationId: string detachOnApplicationExit: boolean + teamMode?: boolean + recoveredMessageId?: string release(): void } const activeRequests = new Map() + const onRuntimeSettingsChanged = async (): Promise => { + // The expert scheduler cancels its work on replacement. Its parent must + // not synthesize partial old-generation results with the replacement model. + for (const lease of activeRequests.values()) { + if (lease.teamMode) { + lease.controller.abort(new Error('专家团队使用的模型设置已更改')) + } + } + await activateRuntimeSettings() + } const requireTerminalSessionManager = (): TerminalSessionManager => { if (!terminalSessionManager) { throw new Error('终端服务不可用') @@ -1375,7 +1390,7 @@ export function registerIpcHandlers( const activeEventBuffers = new Map() const pendingAgentQuestions = new Map< string, - { requestId: string; runtime: AgentRuntime } + { requestId: string; runtime: AgentRuntime; question: Extract } >() const heartbeatControllers = new Set() let activeSshDirectoryBrowse: AbortController | undefined @@ -1637,7 +1652,6 @@ export function registerIpcHandlers( ): Promise => { const snapshot = await operation if (reconfigureRuntime) { - abortActiveRequests('扩展能力设置已更改') await onRuntimeSettingsChanged() } return snapshot @@ -1924,6 +1938,7 @@ export function registerIpcHandlers( controller ) lease.detachOnApplicationExit = true + lease.recoveredMessageId = task.currentAssistantMessageId let sawTerminal = false try { publishRemoteProjectRecovery({ @@ -1973,6 +1988,21 @@ export function registerIpcHandlers( recoveryRequest, controller.signal )) { + if (rawEvent.type === 'question' || rawEvent.type === 'question-resolved') { + if (rawEvent.type === 'question') { + pendingAgentQuestions.set(rawEvent.questionId, { + requestId: task.taskId, runtime: recoveredRuntime, question: rawEvent + }) + assistantDatabase.updateTaskStatus(task.taskId, 'waiting_approval') + } else { + pendingAgentQuestions.delete(rawEvent.questionId) + if (![...pendingAgentQuestions.values()].some(pending => pending.requestId === task.taskId)) { + assistantDatabase.updateTaskStatus(task.taskId, 'running') + } + } + publishConversationChange() + continue + } const provenance = remoteSemanticProvenance(rawEvent) if (provenance === undefined) { if (rawEvent.type !== 'status') { @@ -2069,16 +2099,26 @@ export function registerIpcHandlers( return } if (error instanceof RemotePromptRecoveryUnavailableError) { - assistantDatabase.failRecoverableRemoteTask( + assistantDatabase.endRecoverableRemoteTask( task.taskId, - error.message + error.message, + 'failed' ) publishConversationChange() return } + if (error instanceof RemotePromptCancelledError) { + assistantDatabase.endRecoverableRemoteTask(task.taskId, '请求已取消', 'cancelled') + publishConversationChange() + return + } throw error } finally { + for (const [id, pending] of pendingAgentQuestions) { + if (pending.requestId === task.taskId) pendingAgentQuestions.delete(id) + } lease.release() + publishConversationChange() assistantDatabase.completeTaskScheduleRun(task.taskId) } } @@ -2111,9 +2151,11 @@ export function registerIpcHandlers( task.projectId === projectId && !activeRequests.has(task.taskId) ) - for (const task of tasks) { - await recoverRemoteTask(task, requestId) - } + const results = await Promise.allSettled( + tasks.map(task => recoverRemoteTask(task, requestId)) + ) + const failure = results.find(result => result.status === 'rejected') + if (failure?.status === 'rejected') throw failure.reason // Recovery may terminalize the task without a trailing checkpoint; // let the renderer converge on the persisted terminal state. publishConversationChange() @@ -4137,6 +4179,7 @@ export function registerIpcHandlers( request.conversationId, controller ) + activeRequestLease.teamMode = request.teamMode === true if (parsedInput.queueItemId) { const dispatchTimeout = queueDispatchTimers.get( parsedInput.queueItemId @@ -4638,7 +4681,8 @@ export function registerIpcHandlers( if (publicEvent.type === 'question') { pendingAgentQuestions.set(publicEvent.questionId, { requestId: request.requestId, - runtime: selectedRuntime + runtime: selectedRuntime, + question: publicEvent }) } if (publicEvent.type === 'question-resolved' && @@ -4723,16 +4767,19 @@ export function registerIpcHandlers( } catch (error) { publishReferences() eventBuffer.flush() - const errorMessage = controller.signal.aborted + const cancelled = + controller.signal.aborted || + error instanceof RemotePromptCancelledError + const errorMessage = cancelled ? '请求已取消' : safeRuntimeError(error, 'Agent Runtime 执行失败') const agentEvent: AgentEvent = - runtimeErrorEvent && !controller.signal.aborted + runtimeErrorEvent && !cancelled ? runtimeErrorEvent : { requestId: request.requestId, type: 'error', - status: controller.signal.aborted + status: cancelled ? 'cancelled' : 'failed', message: errorMessage @@ -4745,25 +4792,39 @@ export function registerIpcHandlers( const shouldRecoverAcceptedRemoteOperation = remoteConversationRecovery !== undefined && managedSshOperationAccepted && - !runtimeErrorPersistedRemotely + !runtimeErrorPersistedRemotely && + !(error instanceof RemotePromptCancelledError) remoteRecoveryPending = shouldRecoverAcceptedRemoteOperation - assistantDatabase.updateTaskStatus( - request.requestId, - shouldRecoverAcceptedRemoteOperation - ? 'interrupted' - : terminalStatus, - errorMessage - ) - if (!runtimeErrorPersistedRemotely) { - assistantDatabase.appendTaskEvent( + if ( + remoteConversationRecovery !== undefined && + error instanceof RemotePromptCancelledError && + !runtimeErrorPersistedRemotely + ) { + assistantDatabase.endRecoverableRemoteTask( request.requestId, - agentEvent.type, - agentEvent + errorMessage, + 'cancelled' ) + publishConversationChange() + } else { + assistantDatabase.updateTaskStatus( + request.requestId, + shouldRecoverAcceptedRemoteOperation + ? 'interrupted' + : terminalStatus, + errorMessage + ) + if (!runtimeErrorPersistedRemotely) { + assistantDatabase.appendTaskEvent( + request.requestId, + agentEvent.type, + agentEvent + ) + } } showDesktopNotificationWhenUnfocused(window, { - title: controller.signal.aborted + title: cancelled ? 'GoodBuddy 任务已取消' : 'GoodBuddy 任务失败', body: '打开任务工作栏查看详情。' @@ -4874,7 +4935,26 @@ export function registerIpcHandlers( response.questionId, response.answers.length > 0 ? response.answers : undefined ) + const recorded = assistantDatabase.recordRemoteTaskQuestionAnswer(pending.requestId, { + questionId: response.questionId, + skipped: response.answers.length === 0, + questions: pending.question.questions.map((question, index) => ({ + ...question, + answer: response.answers[index] + })) + }) pendingAgentQuestions.delete(response.questionId) + if (activeRequests.get(pending.requestId)?.recoveredMessageId) { + if ( + ![...pendingAgentQuestions.values()].some(value => value.requestId === pending.requestId) && + assistantDatabase.getTask(pending.requestId).status === 'waiting_approval' + ) { + assistantDatabase.updateTaskStatus(pending.requestId, 'running') + } + } + if (recorded) { + publishConversationChange() + } } ) @@ -5083,8 +5163,6 @@ export function registerIpcHandlers( persistCandidate: async () => { const saved = await settingsStore.updateRuntimeCustomization(settings) - abortActiveRequests('Runtime 定制设置已更改') - approvalBroker.clear() return saved }, activate: onRuntimeSettingsChanged, @@ -5153,8 +5231,6 @@ export function registerIpcHandlers( ...settings, workspacePath }) - abortActiveRequests('运行时设置已更改') - approvalBroker.clear() return saved }, activate: onRuntimeSettingsChanged, @@ -6624,7 +6700,27 @@ export function registerIpcHandlers( registerHandler(ipcChannels.conversationsList, (event) => { assertTrustedSender(event, window) - return assistantDatabase.listConversations() + const questions = new Map[]>() + for (const pending of pendingAgentQuestions.values()) { + const entries = questions.get(pending.requestId) ?? [] + entries.push(pending.question) + questions.set(pending.requestId, entries) + } + const recovered = new Map( + [...activeRequests].flatMap(([requestId, lease]) => + lease.recoveredMessageId + ? [[lease.conversationId, { + requestId, + messageId: lease.recoveredMessageId, + questions: questions.get(requestId) ?? [] + }] as const] + : [] + ) + ) + return assistantDatabase.listConversations().map(conversation => ({ + ...conversation, + ...(recovered.has(conversation.id) ? { activeRequest: recovered.get(conversation.id) } : {}) + })) }) registerHandler( diff --git a/src/main/remote-agent/managed-remote-acp-runtime.test.ts b/src/main/remote-agent/managed-remote-acp-runtime.test.ts index e1409c98..c3a1121e 100644 --- a/src/main/remote-agent/managed-remote-acp-runtime.test.ts +++ b/src/main/remote-agent/managed-remote-acp-runtime.test.ts @@ -448,6 +448,36 @@ describe('createManagedRemoteAcpRuntime', () => { await runtime.dispose() }) + it('retains active Sessions and admits new channels after same-Agent reconnect', async () => { + const fixture = harness() + const runtime = await createManagedRemoteAcpRuntime(fixture.options) + let releaseGate!: () => void + remoteHarness.runGate = new Promise(resolve => { releaseGate = resolve }) + const first = runtime.run(request('before-reconnect'), new AbortController().signal) + await first.next() + fixture.connection.client.generation = 6 + const second = runtime.run(request('after-reconnect'), new AbortController().signal) + try { + await expect(second.next()).resolves.toMatchObject({ + value: { type: 'status' }, done: false + }) + expect(remoteHarness.instances).toHaveLength(1) + expect(remoteHarness.instances[0]!.dispose).not.toHaveBeenCalled() + // This fixture does not implement the open RPC. Reaching it proves the + // original factory accepts the recovered transport instead of a stale lease. + await expect(remoteHarness.instances[0]!.options.channelFactory( + 'binding-after-reconnect' + )).rejects.toThrow() + expect(fixture.connection.client.request.mock.calls.some( + ([method]) => method === 'runtime/openAcpChannel' + )).toBe(true) + } finally { + releaseGate() + await Promise.all([collect(first), collect(second)]) + await runtime.dispose() + } + }) + it('abandons connected runtime ownership on application exit without remote cleanup', async () => { const fixture = harness() const runtime = await createManagedRemoteAcpRuntime( diff --git a/src/main/remote-agent/managed-remote-acp-runtime.ts b/src/main/remote-agent/managed-remote-acp-runtime.ts index 47369c1a..9ab3168a 100644 --- a/src/main/remote-agent/managed-remote-acp-runtime.ts +++ b/src/main/remote-agent/managed-remote-acp-runtime.ts @@ -286,11 +286,12 @@ class ManagedRemoteRuntimeResources { bindingId: string ) { const transport = await this.prepareTransport() - assertSameTransport( - expected, - transport, - 'Remote Runtime transport lease is stale' - ) + if ( + expected.daemonBootId !== transport.daemonBootId || + transport.controllerGeneration < expected.controllerGeneration + ) { + throw new Error('Remote Runtime transport lease is stale') + } const channel = await createProtocolRemoteRuntimeChannel({ connection: this.connection, openIdentity: { @@ -302,7 +303,7 @@ class ManagedRemoteRuntimeResources { }) try { assertSameTransport( - expected, + transport, this.currentTransportIdentity(), 'Remote Agent transport changed while opening an ACP channel' ) @@ -511,19 +512,24 @@ class ManagedRemoteAcpRuntime implements AgentRuntime { throw new Error('远端 Runtime 正在退役') } if (!sameTransport(this.transport, currentTransport)) { - if (this.#activeRuns > 0) { - throw new Error( - 'Remote Agent transport changed while another Runtime request was active' - ) + const resumedSameAgent = + this.transport.daemonBootId === currentTransport.daemonBootId && + currentTransport.controllerGeneration > this.transport.controllerGeneration + if (!resumedSameAgent) { + if (this.#activeRuns > 0) { + throw new Error( + 'Remote Agent transport changed while another Runtime request was active' + ) + } + await this.remote.dispose() + if (this.#disposed) { + throw new Error('远端 Runtime 已关闭') + } + if (this.#draining) { + throw new Error('远端 Runtime 正在退役') + } + this.remote = this.createRemote(currentTransport) } - await this.remote.dispose() - if (this.#disposed) { - throw new Error('远端 Runtime 已关闭') - } - if (this.#draining) { - throw new Error('远端 Runtime 正在退役') - } - this.remote = this.createRemote(currentTransport) this.transport = currentTransport } this.#activeRuns += 1 diff --git a/src/main/remote-agent/protocol-remote-runtime-channel.test.ts b/src/main/remote-agent/protocol-remote-runtime-channel.test.ts index b6066540..e8a83d48 100644 --- a/src/main/remote-agent/protocol-remote-runtime-channel.test.ts +++ b/src/main/remote-agent/protocol-remote-runtime-channel.test.ts @@ -1296,7 +1296,7 @@ describe('ProtocolRemoteRuntimeChannel', () => { return { status: 'terminal', terminalState: 'failed', - processTree: 'running' + processTree: 'unknown' } } return defaultResponse(method, params) @@ -1311,6 +1311,17 @@ describe('ProtocolRemoteRuntimeChannel', () => { await fixture.channel.close() }) + it.each(['failed', 'cancelled'] as const)('accepts a %s Session while its shared process serves peers', async terminalState => { + const fixture = await openChannel() + fixture.client.responder = (method, params) => method === 'runtime/reconcilePrompt' + ? { status: 'terminal', terminalState, processTree: 'running' } + : defaultResponse(method, params) + await expect(fixture.channel.reconcilePromptOperation({ + bindingId: 'binding-1', operationId: 'operation-1', requestId: 'operation-1' + })).resolves.toEqual({ status: 'terminal', terminalState, processTree: 'running' }) + await fixture.channel.close() + }) + it('keeps streams alive, seeds exact sequences, and ACKs replay duplicates without re-emitting them', async () => { const fixture = await openChannel() const recoveryAbort = new AbortController() diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index a61c3aca..12db646d 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -1436,6 +1436,19 @@ function persistedTerminalStateOverridesLocal( return persisted.state !== "streaming" && local.state !== "complete"; } +function withRecoveredQuestions(conversation: Conversation): Conversation { + const active = conversation.activeRequest; + if (!active) return conversation; + return { + ...conversation, + messages: conversation.messages.map(message => + message.id === active.messageId && message.state === "streaming" + ? { ...message, pendingQuestions: active.questions } + : message, + ), + }; +} + function mergePersistedConversations( current: readonly Conversation[], incoming: readonly ConversationSnapshot[], @@ -1454,7 +1467,7 @@ function mergePersistedConversations( const local = currentById.get(conversation.id); if (!local || local.remote) { persistedLocal.set(conversation.id, conversation); - return conversation; + return withRecoveredQuestions(conversation); } const localMessageById = new Map( local.messages.map((message) => [message.id, message]), @@ -1466,6 +1479,9 @@ function mergePersistedConversations( const messages = [ ...conversation.messages.map((message) => { const localMessage = localMessageById.get(message.id); + if (!conversation.activeRequest && local.activeRequest?.messageId === message.id) { + return { ...message, pendingQuestions: undefined }; + } if (!localIsNewer) { // Pending prompts are live-only and are omitted from persisted messages. if (localMessage?.state === "streaming" && message.state === "streaming") { @@ -1484,10 +1500,10 @@ function mergePersistedConversations( ...local.messages.filter((message) => !serverMessageIds.has(message.id)), ]; const next = localIsNewer - ? { ...local, messages } + ? { ...local, messages, activeRequest: conversation.activeRequest } : { ...conversation, messages }; persistedLocal.set(conversation.id, conversation); - return next; + return withRecoveredQuestions(next); }); for (const conversation of current) { if (!incomingById.has(conversation.id)) { @@ -5579,7 +5595,7 @@ function App(): React.JSX.Element { ) : []; let nextConversations: Conversation[] = [ - ...persistedConversations, + ...persistedConversations.map(withRecoveredQuestions), ...migratedLocalConversations, ]; let projectConversation = nextConversations.find( @@ -7404,7 +7420,9 @@ function App(): React.JSX.Element { const stop = async (): Promise => { const requestId = [...activeRuns.current.entries()].find( ([, run]) => run.conversationId === activeId, - )?.[0]; + )?.[0] ?? conversationsRef.current.find( + conversation => conversation.id === activeId, + )?.activeRequest?.requestId; if (requestId) { try { await window.goodbuddy.agent.cancel(requestId); @@ -7503,8 +7521,7 @@ function App(): React.JSX.Element { if (!question || question.questionId !== questionId) { return; } - const taskId = pendingMessage.task?.id ?? [...activeRuns.current.entries()] - .find(([, run]) => run.conversationId === conversationId && run.messageId === messageId)?.[0]; + const taskId = question.requestId; await window.goodbuddy.agent.respondQuestion(questionId, answers); updateMessage(conversationId, messageId, (message) => { const pendingQuestions = message.pendingQuestions?.filter((item) => item.questionId !== questionId); diff --git a/src/shared/agent-protocol/control-contracts.ts b/src/shared/agent-protocol/control-contracts.ts index 8122bd1e..43883762 100644 --- a/src/shared/agent-protocol/control-contracts.ts +++ b/src/shared/agent-protocol/control-contracts.ts @@ -429,20 +429,8 @@ export const acpReconcilePromptResultSchema = z.discriminatedUnion( ]), processTree: z.enum(['running', 'empty']) }) - .strict() - .superRefine((result, context) => { - if ( - result.processTree === 'running' && - result.terminalState !== 'completed' - ) { - context.addIssue({ - code: 'custom', - path: ['processTree'], - message: - 'Only a completed prompt may retain a running process tree' - }) - } - }), + // A terminal Session can leave a shared Runtime serving other Sessions. + .strict(), z .object({ status: z.literal('running'), diff --git a/src/shared/assistant-contracts.test.ts b/src/shared/assistant-contracts.test.ts index 0a2e55b3..3a6fc5d5 100644 --- a/src/shared/assistant-contracts.test.ts +++ b/src/shared/assistant-contracts.test.ts @@ -38,6 +38,23 @@ const untouchedProject: AssistantProject = { } describe('agent question contracts', () => { + it('returns recovered questions only in live snapshots, not saved headers', () => { + const id = '00000000-0000-4000-8000-000000000901' + const snapshot = { + id, title: 'Recovered', updatedAt: 1, messages: [], + activeRequest: { + requestId: id, messageId: id, + questions: [{ + requestId: id, type: 'question', questionId: 'native', + questions: [{ header: 'Input', question: 'Which?', options: [], multiple: false, custom: true }] + }] + } + } + expect(conversationSnapshotSchema.parse(snapshot)).toEqual(snapshot) + const { messages, ...header } = snapshot + expect(localConversationSaveBatchSchema.safeParse([{ header, messages }]).success).toBe(false) + }) + it('does not impose GoodBuddy-specific question answer limits', () => { const answers = Array.from({ length: 5 }, (_, questionIndex) => Array.from( diff --git a/src/shared/assistant-contracts.ts b/src/shared/assistant-contracts.ts index 0ce0fffd..e8598c4c 100644 --- a/src/shared/assistant-contracts.ts +++ b/src/shared/assistant-contracts.ts @@ -515,6 +515,21 @@ export const conversationBranchSchema = z }) .strict() +export const liveAgentQuestionSchema = z.object({ + requestId: z.string().min(1), + type: z.literal('question'), + questionId: z.string().min(1), + childTaskId: z.string().optional(), + questions: z.array(z.object({ + header: z.string(), + question: z.string(), + options: z.array(z.object({ label: z.string(), description: z.string() })), + multiple: z.boolean(), + custom: z.boolean() + })) +}) +export type LiveAgentQuestion = z.infer + export const conversationSnapshotSchema = z .object({ id: assistantIdSchema, @@ -537,6 +552,12 @@ export const conversationSnapshotSchema = z branch: conversationBranchSchema.optional(), title: z.string().trim().min(1).max(200), updatedAt: z.number().int().nonnegative(), + // Main-only live projection. Never part of a saved conversation header. + activeRequest: z.object({ + requestId: assistantIdSchema, + messageId: assistantIdSchema, + questions: z.array(liveAgentQuestionSchema) + }).optional(), messages: z.array(conversationMessageSchema) }) .strict() @@ -551,7 +572,8 @@ export const conversationSnapshotsSchema = z export const localConversationHeaderSchema = conversationSnapshotSchema .omit({ messages: true, - remote: true + remote: true, + activeRequest: true }) export type LocalConversationHeader = z.infer< diff --git a/tsconfig.agent.json b/tsconfig.agent.json new file mode 100644 index 00000000..e87fa578 --- /dev/null +++ b/tsconfig.agent.json @@ -0,0 +1,8 @@ +{ + "extends": "./tsconfig.node.json", + "compilerOptions": { + "composite": false + }, + "include": ["src/agent-daemon/**/*.ts"], + "exclude": ["src/agent-daemon/**/*.test.ts"] +}