diff --git a/docs/features/application-tool-navigation/progress.md b/docs/features/application-tool-navigation/progress.md index 849cfcd9..4fb93580 100644 --- a/docs/features/application-tool-navigation/progress.md +++ b/docs/features/application-tool-navigation/progress.md @@ -1,5 +1,51 @@ # 应用导航实施进度 +## 2026-10-05:展开状态缓存复查 + +独立复查发现,首次 A10 修复在每个折叠项挂载时保存默认值;无引用的消息也会因引用 Hook 留下一条记录。下节首次验收只检查了 DOM 上限,没有覆盖这部分缓存。新增回归在该实现上失败:分批挂载 10,000 个未操作的消息作用域后,预期缓存为 0,实际为 10,000;恢复默认状态后也没有释放条目。 + +现改为只保存偏离自动默认值的用户选择,恢复默认时立即删除覆盖项。消息更新后,清理逻辑只检查已保存的覆盖项,借用历史面板已有的消息索引定位来源,清除已删除消息、工具/输入/推理块/子代理/引用及已过期的流式默认值对应的记录。清理不在滚动或渲染期执行。正常的虚拟行卸载、子代理收起继续保留有效选择,行卸载期间完成的推理仍按原规则自动收起。 + +本次执行结果: + +- `npm test -- --project unit src/renderer/src/message-expansion.test.tsx src/renderer/src/ChatHistoryPane.test.tsx src/renderer/src/ChatTimeline.test.tsx src/renderer/src/SupervisorWorkspace.test.tsx`:4 个文件、112 项通过。其中新增 4 项覆盖 10,000 个未操作作用域零缓存、用户选择跨卸载保留、恢复默认释放、消息及折叠项删除、未挂载推理完成。 +- `npm test -- --project unit src/renderer/src/App.test.tsx -t 'keeps each conversation history window|isolates history and task strips'`:2 项通过,346 项未选中。 +- `npm test -- --project integration tests/message-expansion.electron.test.ts`:三轮交替比较后,最终测量探针调整也单独复跑通过。`npm test -- --project integration tests/frosted-glass.electron.test.ts`:1 项通过,继续覆盖浅深主题、原生点击、跳底、锚点和缓存切换。 +- `npm run typecheck`、`npm run lint` 通过。该组定向验收后,完整集成测试为 6,136 项通过、0 失败、87 跳过,见[阶段修复验收](../../review/stage-audit-fixes-2026-10-05.md#最终检查);本组模型调用 0 次。 + +Electron 环境与合成消息结构同下节。此次比较对象为首次 A10 的默认值缓存实现和本次稀疏覆盖实现,不是 `126dbc57`。测试通过 `GB_EXPANSION_DENSE_SOURCE` 加载修改前保留的模块副本,其余生产 Renderer 源码相同。fixture 临时观察首次展开状态写入,捕获该 Map 后恢复原 `Map.set`;产品未增加诊断接口。每个数据量先进行 200 次未操作导航,实际见到 1,397 个不同消息行(一轮旧实现为 1,401),再原生展开 13 条消息的推理、工具结果和输入,返回原行确认恢复,用 Space 收起一项,最后删除消息。 + +| 历史消息数 | 旧实现:未操作导航后条目 | 新实现:未操作导航后条目 | 用户操作后:旧/新条目 | 删除消息后:旧/新条目 | +| --- | --- | --- | --- | --- | +| 2,000 | 5,600 | 0 | 5,708 / 38 | 5,708 / 0 | +| 10,000 | 5,604 | 0 | 5,904 / 38 | 5,904 / 0 | + +以上计数在三轮比较中一致。交互阶段两版均最多采样到 15 个消息行、926 个 DOM 元素,原消息的三个展开项均恢复;A09 迟到来源响应检查也继续通过。计数针对缓存条目,不是浏览器堆内存字节数。 + +| 消息数 | 旧实现每轮导航中位数(ms) | 新实现每轮导航中位数(ms) | 旧实现每轮 p95(ms) | 新实现每轮 p95(ms) | +| --- | --- | --- | --- | --- | +| 2,000 | 38.8 / 38.8 / 38.4 | 38.8 / 39.1 / 38.5 | 40.4 / 39.6 / 46.9 | 39.2 / 46.7 / 40.1 | +| 10,000 | 38.7 / 39.1 / 38.9 | 38.8 / 38.9 / 38.6 | 39.7 / 39.5 / 46.9 | 39.4 / 39.3 / 46.6 | + +导航计时仍为目标聚焦后再等待两个动画帧,每轮 14 个样本;此次先扫过历史,不能与下节首次进入的计时直接比较。三轮比较中,200 次未操作导航的总耗时,旧实现为 4,753.5–5,080.5 ms,新实现为 4,820.3–5,228.8 ms。数据证明默认条目和删除后的条目不再积累,未证明提速;未测堆内存峰值、低端机节流或完整 App/生产 IPC。 + +## 2026-10-05:虚拟消息行的展开状态 + +聊天历史面板通过 `message-expansion.tsx` 按消息、子代理及折叠项的稳定标识保留展开状态。虚拟行卸载后只保留状态值,不保留 DOM;仍沿用最多 8 个近期交互行及焦点、选区、问题的挂载保护。阅读行为见[聊天阅读状态](./ui-design.md#聊天阅读状态)。本次没有修改 Runtime 执行或消息持久化。 + +旧代码上的新增组件回归在返回原消息时失败:预期展开,实际收起。最终三文件 108 项通过,覆盖嵌套工具、父子同名调用隔离、手动收起、行卸载期间流式完成、既有焦点/选区及平滑滚动。命令:`npm test -- --project unit src/renderer/src/ChatHistoryPane.test.tsx src/renderer/src/ChatTimeline.test.tsx src/renderer/src/SupervisorWorkspace.test.tsx`。另有会话存储、同步、流式缓冲、窗口模型和侧栏回归 48 项,以及 App 历史加载、重试、热切换、流式隔离和超过 500 条消息的定向回归 5 项通过。 + +真实 Electron 使用当前 `ChatHistoryPane`、样式及 Markdown 渲染,数据为 2,000/10,000 条合成助手消息,每条含正文、推理、工具结果与输入。Windows x64、Electron 44.5.1、1280×800、缩放 1;原生鼠标展开 13 条相距较远的消息,确认原行卸载后再返回,并用原生 Space 操作。基线为临时 worktree 中的 `126dbc57`。三轮交替运行 `npm test -- --project integration tests/message-expansion.electron.test.ts`;基线运行另设 `GB_HISTORY_SOURCE` 指向该 worktree。每轮、每种数据量采样 14 次导航;计时从请求消息定位到目标 article 获得焦点后的两个动画帧,含这两个帧的等待,不等同于纯渲染耗时。 + +| 消息数 | 基线每轮中位数(ms) | 修复后每轮中位数(ms) | 基线每轮 p95(ms) | 修复后每轮 p95(ms) | +| --- | --- | --- | --- | --- | +| 2,000 | 47.0 / 39.2 / 39.3 | 39.4 / 39.0 / 39.1 | 177.6 / 136.4 / 137.4 | 129.7 / 128.8 / 138.7 | +| 10,000 | 47.2 / 39.4 / 39.2 | 38.8 / 39.2 / 39.5 | 56.0 / 40.2 / 43.7 | 41.4 / 39.7 / 40.0 | + +两版在两种数据量下的采样最大值均为 15 个消息行、926 个 DOM 元素;基线三轮均丢失原消息的三个展开状态,修复后三轮均恢复。数字只证明本场景的 DOM 数量有界,计时存在波动,不据此宣称提速或整体性能预算达标。该 Electron fixture 替换了数据来源,不是完整 App、生产 IPC 或真实模型验收;模型调用 0 次。 + +既有 `npm test -- --project integration tests/frosted-glass.electron.test.ts` 单独复跑通过,覆盖浅深主题、原生点击、跳底、阅读锚点、会话缓存、加载更早消息及 960×540 短窗口。首次与监督者布局测试合并运行在工具的 60 秒期限内未返回结果,不计为通过;`tests/supervisor-layout.electron.test.ts` 随后单独后台运行通过,总耗时 65.26 秒,stderr 为空。最终完整 typecheck、全仓 lint 和修改范围差异检查通过。首次 lint 曾受其他并行修改中的三个 Runtime 错误阻断,后续复跑已通过。未运行完整测试集或低端机节流测量。 + ## 2026-10-04:新建本地项目必选目录 对应 FR-14 的项目创建入口,规则见 [PRD](./prd.md#fr-14-项目活动汇总)及[界面设计](./ui-design.md#10-项目活动汇总)。创建表单已增加中英文必填提示、无障碍关联及空目录提交检查;共享创建 schema 拒绝空白路径,更新 IPC 使用独立 schema,保留历史空目录项目的保存能力。 diff --git a/docs/features/application-tool-navigation/ui-design.md b/docs/features/application-tool-navigation/ui-design.md index 3b2a58ce..a12dd10c 100644 --- a/docs/features/application-tool-navigation/ui-design.md +++ b/docs/features/application-tool-navigation/ui-design.md @@ -270,6 +270,14 @@ CPU 4% · 内存 620 MB · 桌面进程 7 导出也适用于消息通道会话。持久化与刷新规则见 [会话置顶存储](../assistant-workbar/execution-history-storage.md#会话置顶)。 +### 聊天阅读状态 + +在同一历史面板内,滚动到别处再返回时,消息的推理、工具结果、工具输入、引用和子代理详情保留用户的展开或收起选择。子代理收起后重新展开,其内部工具详情也保留选择;不同消息及父子工具分别记忆。状态保留在历史面板中,不依赖把所有已展开的消息留在 DOM 中;面板被缓存淘汰或应用重启后不恢复这些临时阅读选择。 + +未操作的折叠项不留下默认状态记录;用户恢复默认选择、消息或对应折叠项被删除后,释放相应记录。正常滚动卸载不视为删除。 + +推理仍在流式输出时默认展开、完成时自动收起,流式期间手动收起不因滚动重挂载而重新展开。有焦点、文本选区端点、待回答问题和待审批项的消息继续按既有规则保留挂载;平滑跳到底部及滚动锚定行为不变。 + ## 9. 未来插件入口 FR-15、US-F1/US-F2 的市场 URL、YAML 导入导出及 ShareServer 分发在后续设计中定义。 diff --git a/docs/features/assistant-workbar/progress.md b/docs/features/assistant-workbar/progress.md index 1930c55f..062884e3 100644 --- a/docs/features/assistant-workbar/progress.md +++ b/docs/features/assistant-workbar/progress.md @@ -1,5 +1,14 @@ # 工作栏实现与验证进度 +## 2026-10-05:Runtime 提交失败与启动取消 + +本机 OpenCode 提交失败后会结束所属 SSE 等待;远程原生 Session 启动等待期间, +取消与其他 binding 的控制请求可继续处理。保留无默认 Prompt 总时限、共享 peer +隔离、取消后的会话清理及原有事件顺序。实现规则见 +[进程复用设计](./runtime-process-reuse-technical-design.md),旧/新回归、当前源码 +Windows/Linux x64 实测、真实数据检查及本轮 5 次文本模型调用计数统一记录在 +[统一执行进度](../unified-execution/progress.md#2026-10-05-runtime-生命周期审查修复)。 + ## 2026-10-04:移除成果页,工作区导入与文件时间 对应 [FR-WF1~FR-WF3、US-WF1~US-WF3](./workspace-files.md)。成果 Tab、目录注册及独立导入 diff --git a/docs/features/assistant-workbar/runtime-interactions.md b/docs/features/assistant-workbar/runtime-interactions.md index 64743448..e1242591 100644 --- a/docs/features/assistant-workbar/runtime-interactions.md +++ b/docs/features/assistant-workbar/runtime-interactions.md @@ -17,6 +17,11 @@ Host 环境。2026-09-14 的已发布 Desktop `0.13.2` / Agent `0.11.25` 已包 ## 运行与失败 +OpenCode 提交请求失败时直接显示该失败,不等待没有被接受的请求产生结束事件。 +远程 Runtime 在原生 Session 启动期间也可取消;启动等待不阻塞其他会话的状态读取。 +取消及清理范围仍限于所属请求,完整规则见 +[Runtime 进程复用技术设计](./runtime-process-reuse-technical-design.md)。 + - 结构化 yes/no 是普通选项,不从日志、自然语言或 Shell 输出猜测用户意图。 - 前台同一请求的待答问题按公开 `questionId` 去重,按到达顺序保存在消息的 `pendingQuestions` 中。每次显示队首表单;有多个待答请求时显示数量。后续提问和重复 diff --git a/docs/features/assistant-workbar/runtime-process-reuse-technical-design.md b/docs/features/assistant-workbar/runtime-process-reuse-technical-design.md index ed21f564..2cea1473 100644 --- a/docs/features/assistant-workbar/runtime-process-reuse-technical-design.md +++ b/docs/features/assistant-workbar/runtime-process-reuse-technical-design.md @@ -168,6 +168,11 @@ Session 和 Prompt 路由删除产品模式,按实际能力和请求归属接 使用独立的 ACP 取消路径。 现有 `snapshot: false` 必须保留。 +提交 Prompt 的 Promise 与 SSE 消费同时运行。提交返回错误、抛错或控制请求超时后, +立即中止本请求的 SSE 订阅并保留提交错误;不能等待服务端为未接受的 Prompt 发送 +`session.idle`。回收仍先 abort 订阅再结束迭代器,随后完成该 Session 的有界取消, +不关闭其他会话的订阅或共享 Server。生产运行不增加整个 Prompt 的默认总时限。 + `mcpMutationTail` 目前属于 Runtime 对象;共享后同一原生目录的变更必须由同一 owner 串行协调。注册名称、工具可见性、knowledge token 和清理仍按请求区分,不能用一个全局 “当前 MCP 列表”覆盖同目录或跨目录的其他请求。探针未覆盖动态 MCP,这是实施验收项。 @@ -282,6 +287,16 @@ Agent 当前 Runtime installation + 兼容配置 - 同一 PID 的多个 binding 不代表同一权限或同一 operation。Host/installation identity 变化仍使用已有失效和退役规则,不把不同 Host、账号或 Runtime generation 合并。 +`runtime/startPrompt` 校验及 owner 登记经过 backend 控制队列,原生握手、Session +创建/恢复和模型路由准备在队列外等待。协议接收端也不等待该 RPC 的最终响应才分派 +后续帧;未完成的响应继续占用已有 request channel,沿用连接通道容量限制及 FIFO +写入。其他方法的分派顺序保持不变,Prompt 终态仍经 backend 控制队列提交。 +同一 binding 的相同启动请求共用 Promise,内容不同的并发启动返回冲突。 + +启动等待属于 Session owner,可以被取消、启动 watchdog 或关闭中断。取消后不得 +发送 Prompt;原生调用迟到返回的 Session 只关闭自身,不能回收仍服务其他 binding +的共享进程。原生握手本身仍由进程 owner 共享,一个等待者退出不取消其他等待者。 + 原生 `chat.message` 在模型请求前设置该 Session 的工具与模型路由,不再按产品模式 过滤 Shell、编辑或写入;请求级端点与子会话归属仍独立。 OpenCode 1.18.29 的 ACP 不转发未注册子 Session 的权限请求,且原生子代理只继承父 diff --git a/docs/features/conversation-supervision/progress.md b/docs/features/conversation-supervision/progress.md index 0723cc2a..75c672b9 100644 --- a/docs/features/conversation-supervision/progress.md +++ b/docs/features/conversation-supervision/progress.md @@ -4,6 +4,24 @@ 当前记录以已验证生产行为为准。监督者尚未覆盖全部 user stories。 +## 2026-10-05 后端阶段审查 A03/A04/A05 + +对应 SL-5、SL-8、FR-S6、FR-S10。生产候选读取现在将批次项目和跨项目设置传到只读 Worker;最终候选读取返回后检查暂停/取消;故事归属和拆分在原写事务内拒绝已移除或合并的目标。存储、停止与重试规则见[技术设计](./technical-design.md#单次执行与取消)。没有 schema 变更,原始消息、历史结果及人工确认字段保留。 + +先加回归、后改实现的结果:8 个新用例中旧代码失败 7 个,取消用例原本通过。失败覆盖跨项目开/关两种生产候选、最终候选读取期间暂停、归属期间移除/合并目标、拆分前移除功能/子线索。旧暂停用例实际返回 `completed` 并发布结果;旧归属用例写入隐藏目标且把事件标为已处理。修复后暂停不发布结果或 checkpoint,继续仍复用原批次;失效故事批次回滚且事件可重试。生产身份测试补充真实项目事件关联,保留 UUID 别名、确认字段及失败后继续的断言。 + +验证命令与结果: + +- `npm test -- src/main/assistant/supervision-production.test.ts src/main/assistant/supervision-worker.test.ts src/main/assistant/supervision-stories.test.ts src/main/assistant/supervision-review.test.ts src/main/assistant/supervision-timeline.test.ts src/main/assistant/supervision-experiences.test.ts src/main/assistant/supervisor-service.test.ts src/main/readonly-query-reader.test.ts`:8 个文件、96 项通过。Worker 回归每轮使用两个项目、422 条来源,并检查候选隔离、实体数量、外键及暂停后的完整发布。 +- 本次修改的 Main 源码、测试和验证脚本定向 ESLint、`git diff --check` 通过。`npm run typecheck` 首轮发现本轮 fixture 缺默认设置字段,修正后曾被并行 `tests/message-expansion.electron.test.ts:39` 类型错误阻塞;该文件由其负责方修正后,Main/Agent/Web 三组最终全部通过。本轮未修改该文件。 +- 定向验收时 `npm run lint` 曾被并行 OpenCode 修改中的三项错误阻塞;负责方修正后,最终全仓 lint 与 typecheck 均通过。两份后端文档扫描阻断项为 0;人工复核保留取消与事务顺序的边界说明。完整集成结果见[阶段修复验收](../../review/stage-audit-fixes-2026-10-05.md#最终检查)。 +- `node --import jiti/register scripts/supervision-audit-validation.ts scale <临时父目录>`:真实文件 SQLite,10 个项目、1,000 个实体、10,000 个事件、10,000 条事件实体关联、10 条来源,库文件 10,964,992 字节。三轮交替查询中,每轮十次旧相关子查询合计 3,517/5,302/4,190 ms;新 Worker 查询合计 39.18/34.01/36.04 ms,包含传输。逐项目返回的 100 个候选 ID 及顺序与旧 SQL 完全一致;跨项目读取每轮 0.95~1.79 ms。120 个事件的单批归属事务为 6.63 ms。计数不变,`foreign_key_check` 为空,`integrity_check` 为 `ok`。 +- 查询测量是同一合成库内的 SQL 对照,非完整 App 基线;同时有其他本地验证任务运行。原相关查询在修复传参后首次实测暴露每十次 3.60~4.65 秒耗时,随后改为实体集合查询。没有新增索引或迁移,也不据此宣称整窗性能改善。 + +真实模型检查沿用 `scripts/supervision-production-live.ts` 的环境文件读取与 `ModelAgentRuntime` 无工具模式,由新增合成验证脚本执行。`node --import jiti/register scripts/supervision-audit-validation.ts live <临时父目录> .env.deepseek.local` 只发送一条 147 字符合成消息,真实生产工厂和文件 Worker 完成提取、保存及故事归属。外部请求准确为 **2 次**:提取 1 次、故事归属 1 次;无变化重跑新增请求 0 次。原文片段精确重建,外键与完整性检查通过,总耗时 3.84 秒。脚本上限为 3 次 HTTP 请求、每次模型阶段 45 秒、1,800 输出 token;凭据仅在进程内读取,终端与指标文件只输出计数和验证信息。本机继承的 TLS 环境设置未由本次修改。 + +报告位于系统临时 `opencode/supervision-audit-Ggn5uW/metrics.json`(规模检查)和 `opencode/supervision-audit-WNu08s/metrics.json`(模型检查)。只写隔离合成库,未读取或修改用户库,未运行完整 App/Electron 回归或整包构建;没有提交。修改位于桌面监督服务及其 SQLite Worker,GoodBuddy Agent 与远程 Runtime 控制路径未改变。 + ## 2026-10-04 螺旋工具栏与事件颜色修正 接续未提交的回顾布局调整。平铺与螺旋共用标题工具栏样式,项目层级菜单放在面包屑旁,画布内的视角入口和左下角圈数说明已移除。默认斜俯视,拖动及键盘操作保留;两种图谱均以共享 InlineHelp 的 i 图标打开图例。共用历史回顾栏、下钻返回、详情选择与中窄窗口详情面板保留。当前规则见 [UI 设计](./ui-design.md#故事线图谱)。 diff --git a/docs/features/conversation-supervision/technical-design.md b/docs/features/conversation-supervision/technical-design.md index 44bbe762..6dde9c4a 100644 --- a/docs/features/conversation-supervision/technical-design.md +++ b/docs/features/conversation-supervision/technical-design.md @@ -13,11 +13,13 @@ 本文回答如何在现有 GoodBuddy 桌面端中实现监督者。它不改变产品范围,也不把模拟 Demo 当作生产数据模型。 -FR-S11 的读取架构见[时态 Story Graph 内置 MCP 设计](./story-graph-mcp-design.md)。三个只读工具通过 `readStoryGraph` 投影现有 SQLite,Main 在发现、调用和返回前检查监督者启用、会话开关及 Runtime 分配。绑定携带会话 ID,按主键读取当前设置;原生客户端的复用键包含会话 ID 和开关值。本地 MCP、Model、Harness Main 代理和远程受管工具通道共用此入口;会话开关复用 `context_state_json`,无 schema 迁移。该文独立定义对象/版本合同、时间语义、配置映射及后续跨 scope 复用和记忆过渡。当前 memory 背景读取、精确 scope checkpoint 与候选身份保留;结果快照不支持 `as_of` 历史重建。 +FR-S11 的读取架构见[时态 Story Graph 内置 MCP 设计](./story-graph-mcp-design.md)。三个只读工具通过 `readStoryGraph` 投影现有 SQLite,Main 在发现、调用和返回前检查监督者启用、会话开关及 Runtime 分配。绑定携带会话 ID,按主键读取当前设置;原生客户端的复用键包含会话 ID 和开关值。本地 MCP、Model、Harness Main 代理和远程受管工具通道共用此入口;会话开关复用 `context_state_json`,无 schema 迁移。当前监督阶段使用共享时间线 checkpoint;候选按下文的批次项目与跨项目设置筛选。memory 继续作为背景读取,结果快照不支持 `as_of` 历史重建。 生产入口由 `supervision-production.ts` 组装服务、SQLite、共享池和无工具 Runtime。`supervision-review-store.ts` 保存冻结来源清单,按有界页和片段供 `SupervisorService` 派发;叶子及连续位置先提交,导航和完整结果之后发布。消息已有知识引用保留本地或外部 locator,已确认记忆通过独立背景输入提供。不会检索外部全库。详细存储、调度及当前限制统一见[分块调度第 0 节](./review-scheduling-design.md#0-生产接线与剩余边界)。 -模型实体 `id` 只在单次输出内有效。Main 从同一 scope 的故事线提供最多 100 个未撤销实体候选,生产提示只暴露 `candidateRef`(如 `known_1`)、名称和说明。模型显式选择候选后,Main 转换为严格 UUID 类型的内部 `persistedId`。服务校验候选集成员和重复映射,保存事务再次按实体主键校验故事线归属及撤销状态。没有有效候选引用时分配新 UUID,不按名称或裸模型 ID 合并。来源仍按每次结果分配 UUID,保留原始 `source_id` 和 locator。 +模型实体 `id` 只在单次输出内有效。生产服务将批次的 `projectId` 和 `crossProject` 经 `supervisionCandidates` 传到只读 Worker,再由 `listSupervisionCandidates` 查询共享时间线。跨项目关闭时,只提供该项目当前事件关联的未撤销实体;开启时允许其他项目的实体。候选仍最多 100 个,按更新时间和 ID 排序。查询先取得项目事件关联的实体集合,再筛选候选,避免对每个实体重复扫描事件。省略批次参数的旧读取入口保留精确 scope 查询。 + +生产提示只暴露 `candidateRef`(如 `known_1`)、名称和说明。模型显式选择候选后,Main 转换为严格 UUID 类型的内部 `persistedId`。服务校验候选集成员和重复映射,保存事务再次按实体主键校验存在性及撤销状态。没有有效候选引用时分配新 UUID,不按名称或裸模型 ID 合并。来源仍按每次结果分配 UUID,保留原始 `source_id` 和 locator。 新实体省略 `candidateRef`,不要求模型生成数据库 ID。未知非空 `candidateRef`、冲突身份和候选集之外的 UUID 均拒绝。旧输出中的 `persistedId` 仅在 UUID 合法且属于候选集时复用;空值、占位符和格式错误值不能证明已有身份,保留为独立新实体。局部 ID 或同名不会触发合并。 @@ -71,6 +73,10 @@ schema 47 的四张批次相关表使用外键随监督运行清理,未完成 `supervision:execution` 返回进程内 `{ active, runId?, stopping? }`,配置读取期间可有 `active: true` 而尚无 runId。`supervision:cancel({ runId })` 先在既有 `supervision_runs` 写入 `cancelled`、清除错误并保存取消时间,再向父 controller 传播 abort;重复取消保留原时间。该接口等待执行 Promise 结束,包含并行批次的 `allSettled` 和 Runtime `releaseConversation`,之后才释放准入。暂停只请求 abort,执行停止后保存 `paused`;取消优先于暂停。迟到模型输出不得保存叶子、导航或完整结果,成功批次保留,自动 checkpoint 不推进。发布是同步 SQLite 事务,已完成或无变化的运行拒绝取消,不撤销已发布结果;本次无 schema 迁移。 +发布前重读已保存批次所需候选后,再检查父 signal。此时收到暂停或取消,走原有停止分支;完整叶子和导航保留,结果及 checkpoint 尚未提交。该检查与同步保存之间没有新的异步等待。 + +故事归属和子线索拆分在既有写事务内,按主键检查模型选中的目标、父故事和跨项目功能仍为 `current`。模型等待期间若用户移除或合并目标,整批写入回滚,包含新建故事和已处理标记;重新整理使用当前候选。拆分同样检查原功能及目标子线索。已发布回顾、用户调整和此前成功批次保留,不增加版本表或恢复日志。 + Preload 暴露类型化 `execution`、`cancel`,取消输入使用 UUID schema 并校验可信 sender。活动 UI 用实时执行状态覆盖持久记录的停止显示;工作回顾只在发起前查询执行状态,Main 仍作最终准入判断。`resume` IPC 直到运行结束才返回,Renderer 发出请求后释放提交锁并独立处理结果,确保继续中的回顾仍可暂停或取消。 `App` 将既有通知回调经 `HeartbeatCenter` 传给 `SupervisorWorkspace`。回顾操作用 `supervisor-review` 去重键发送本地化 Toast,结果读取失败仍写入 `loadError` 并就地重试;实体、关系操作和来源读取失败也使用通知。工作回顾不再为状态条轮询执行状态,历史读取不发送操作通知。请求锁覆盖发起前检查和整次运行;页面刷新只使旧结果选择失效,不丢弃运行结束反馈。卸载后忽略迟到响应,检查阶段尚未提交的回顾不再发起,已提交的后台运行不取消。活动查询、原始错误持久化和 Main 生命周期保持原路径。 @@ -98,7 +104,7 @@ IPC `supervision:suggestions`、`supervision:suggestion-action`、`supervision:r 只有实际送入模型、通过结果校验且成功落库的完整片段推进位置。心跳报告及其进度、监督结果及其进度分别在各自 SQLite 事务提交。监督服务串行执行,后续自动请求在前次保存后重新收集。报告成功而监督失败时,下一次自动检查跳过已成功的报告输入,但重试监督输入;无变化不创建报告、来源或图谱事件。 -已有同 scope 故事线的 scope 表示和实体 ID 保留;比较项目集合时忽略排序。模型接收最近摘要和同 scope 最多 100 个未撤销实体的候选引用、名称、说明,显式选择候选才复用,身份转换按上文执行。不同范围不合并,已有重复实体不自动修复。知识引用继续以独立来源保存 locator;当前记忆和旧摘要不作为新事件。 +已有故事线的 scope 表示和实体 ID 保留;比较项目集合时忽略排序。生产候选按上文的项目归属与跨项目设置读取,显式选择候选才复用;已有重复实体不自动修复。监督 checkpoint 的共享时间线规则见[故事线模型](./storyline-model-design.md)。知识引用继续以独立来源保存 locator;当前记忆和旧摘要不作为新事件。 迁移在事务中扩展心跳计划及运行表的状态 CHECK,保留列、索引、ID、计划、报告与引用;消息版本列使用默认值,无需遍历或重写旧正文。进度初始为空,首次自动运行从配置窗口开始。报告保留期限覆盖 `no_change` 运行,但不删除来源进度;删除报告不触发重新消费。 @@ -326,7 +332,7 @@ IPC 返回图谱查询结果时使用分页和有界字段;详情中的来源 ## 当前剩余差距 - 实体与关系 revoke 保存状态;界面没有 undo 或恢复入口。 -- 跨 run identity 依赖同 scope 候选 UUID 的显式引用,不自动合并历史重复实体;模型漏选候选时会创建新身份。 +- 跨 run identity 依赖候选 UUID 的显式引用,候选范围受批次项目及跨项目设置控制;不自动合并历史重复实体,模型漏选候选时会创建新身份。 - 结果图谱使用真实事件实体连线,侧栏已按固定目标过滤并支持结果直达图谱;逐事件状态回放和 Experiment 尚未实现。 - 用户暂停已接到排队/在途 signal,并保留已保存批次;源版本变更后的局部重算、完整模型配置冻结及其他限制见分块调度第 0 节。 - 监督知识实体与知识库正文条目仍是不同对象,知识正文只能通过现有条目定位和预览接口处理。 diff --git a/docs/features/conversation-supervision/ui-design.md b/docs/features/conversation-supervision/ui-design.md index b040a80f..c9850139 100644 --- a/docs/features/conversation-supervision/ui-design.md +++ b/docs/features/conversation-supervision/ui-design.md @@ -96,6 +96,8 @@ Modal 按[共享表单 Modal 规则](../../../UI-DESIGN.md#615-表单-modal)复 选择历史结果后,图谱读取该结果的成员和内容,历史菜单显示其回顾区间和生成时间。切换时清空旧图谱和详情并显示加载状态,迟到请求不得替换新选择。确认、修订和移除反馈更新当前所选结果,后续自动回顾不改写旧结果。 +切换语言或手动回顾完成引起刷新时,同时解除被替代的来源读取、实体和关系操作的等待状态。新图谱读取完成后,历史选择、刷新和详情操作恢复可用;旧请求迟到时既不显示旧内容,也不能解除后来发起的请求的等待状态。 + 手动“回顾当前进展”新建回顾,活动页“继续”恢复原 run;运行中再次发起按上文显示提示,停止清理结束后才能开始新回顾。失败保留上次结果、本次范围和成功批次;已取消回顾不能继续。手动操作不推进自动位置,删除报告也不重置自动位置。 ## 会话侧栏 diff --git a/docs/features/remote-host/technical-design.md b/docs/features/remote-host/technical-design.md index d785c37b..742868c6 100644 --- a/docs/features/remote-host/technical-design.md +++ b/docs/features/remote-host/technical-design.md @@ -365,6 +365,12 @@ Runtime 包。2026-09-19 完整桌面验证覆盖联合包安装、CN 项目创 同次验证复现原生 ACP 冷启动超过 15 秒控制超时。`runtime/startPrompt` 的控制等待 现至少为 30 秒,其余控制请求沿用原时限;Runtime 外层仍保留启动超时和取消处理。 +2026-10-05 修复启动等待阻塞控制队列的问题:原生启动等待同时释放 backend 全局控制 +队列和协议接收分派,取消、watchdog 及其他 binding 的控制请求可继续处理。相同启动 +请求只执行一次,迟到返回的已取消 Session 单独关闭;不增加 Prompt 总时限或新协议 +阶段。实现规则见[共享进程与 Session 所有权](../assistant-workbar/runtime-process-reuse-technical-design.md#61-连接与所有权), +本次源码 Host 验证及调用计数见[统一执行进度](../unified-execution/progress.md#2026-10-05-runtime-生命周期审查修复)。 + ## ACP 与断线 - 管控面只负责有界 JSON-RPC:连接、查询、启动、取消、关闭和重连。数据面只负责 diff --git a/docs/features/telegram-channel/progress.md b/docs/features/telegram-channel/progress.md index 4c3cc263..0ad6563c 100644 --- a/docs/features/telegram-channel/progress.md +++ b/docs/features/telegram-channel/progress.md @@ -35,6 +35,26 @@ npm test 集成集合及 301 项复跑的完整命令未随计数提供,这里不推测其文件清单。真实本地代理 fixture 见 [`tests/telegram-proxy.electron.test.ts`](../../../tests/telegram-proxy.electron.test.ts);其本地代理结果不证明外部 Telegram Bot 可用。探针操作见 [API 联调](./api-testing.md),探针成功也不包含模型验收。 +## 2026-10-05 通道审计修复验证 + +A06 的图片结果回归使用现有 IPC executor 图片事件 fixture,接入生产 `ChannelService` 和内存 SQLite 发件箱。修复前回复状态为 `failed`;修复后状态为 `completed`,图片附件字节保持一致,桌面会话仍记录成果 ID。未调用付费图片模型。 + +A07 的 Renderer 回归覆盖推送状态为 `error`、`stopped`、`running`、`starting`、`disabled` 的无修改保存。前两种状态在修复前没有提交配置,修复后能够提交;其余状态保持不提交。扩展的 Electron 场景通过真实设置 UI、Preload、IPC、Manager 和驱动验证 409 后无修改保存恢复轮询,以及正常连接时再次保存不重启。Telegram API 在网络边界使用替身;本次运行有 2 次本地 HTTP 模型调用、0 次外部请求。 + +A08 的钉钉回归使用生产传输、服务和 SQLite 发件箱,HTTP 请求只发送到 loopback fixture。首次 HTTP 200 携带非零 `errcode`,修复前被当作成功;修复后记录一次失败并保留上下文,第二次成功响应完成投递,executor 只执行一次。另有空响应、缺少错误码、JSON null、无效 JSON 和超限响应回归。 + +本轮聚焦验证:6 个 channel / Renderer suite 共 106 项通过;IPC 图片、文件与 Telegram 场景 3 项通过;Electron 场景 1 项通过。修复前选择性回归为 9 项失败、3 项通过。命令如下: + +```powershell +npm test -- src/main/channels/dingtalk-channel-driver.test.ts src/renderer/src/ChannelSettingsSection.test.tsx src/main/channels/channel-manager.test.ts src/main/channels/channel-service.test.ts src/main/channels/telegram-channel-driver.test.ts src/main/channels/sqlite-channel-state.test.ts +npm test -- src/main/ipc.test.ts -t "returns a generated image only|creates a bounded result file|routes Telegram settings" +npm test -- tests/telegram-channel.electron.test.ts +``` + +这些结果验证本地生产连接关系,不替代真实平台收发或图片模型验收。本轮未改 Agent、Runtime 协议或执行策略,未做性能改善声明。 + +补充检查:钉钉错误码断言加入后,单独复跑 9 项通过;修改的 TS 文件 ESLint、Renderer typecheck 和 `git diff --check` 通过。早期全仓检查受到并行修改中的类型与 lint 错误阻塞,负责方修正后最终检查均通过;完整测试为 6,136 项通过、0 失败、87 跳过。汇总见[阶段修复验收](../../review/stage-audit-fixes-2026-10-05.md#最终检查)。 + ## Main 性能对比 基线为 `78afec77758f3c175f8e2b80e5be4327e13b4689` 的临时 detached worktree,当前为包含 Telegram 改动的工作区。按基线、当前交替执行 `npm run perf:main` 各 3 次。环境为 Windows x64、Intel Core Ultra X7 358H、Electron 44.5.1、Node 24.21.0;每轮使用隔离数据库,包含 1000 个会话、20008 条生成消息、24000 条活动记录,每个场景预热后测量 15 次,无外部模型调用。 @@ -62,7 +82,7 @@ worker 读取、活动启动、增量更新和同步首页查询在六轮中均 ## 待完成 - 在可完成操作系统信任确认的环境中复跑显式启用的代理测试,记录最终结果;保留单次成功和后续导入超时两项证据,不把默认跳过视为通过。 -- 跟踪全量测试中的 Runtime 清理与帮助弹层间歇性失败;两者均有原样复跑通过证据,未为此修改无关产品逻辑。 +- Runtime 清理用例的 GC 生命周期问题已定位并修正测试夹具,完整集成测试通过,见[GC 复查](../unified-execution/progress.md#sdk-cleanup-timeout-复查)。历史帮助弹层间歇性失败在本轮完整测试中未出现。 - 提供有效测试凭据后运行生产实时探针,记录连接及固定回复结果;本次缺少凭据的阻塞不以历史 Node 成功替代。 - 从真实设置页保存并启用专用 Bot,在手机发送授权请求,经过实际模型产生回复并核对桌面历史;再按 PRD 验证连续上下文、隔离、无害工具任务和恢复场景。当前没有真实 Bot 与模型完整链路的通过记录。 - Main 测量未覆盖迁移和完整 UI;后续验收需按[性能原则](../../architecture/performance-principles.md)区分实测路径与未测路径。 diff --git a/docs/features/telegram-channel/technical-design.md b/docs/features/telegram-channel/technical-design.md index 3a5e4fb1..2405376a 100644 --- a/docs/features/telegram-channel/technical-design.md +++ b/docs/features/telegram-channel/technical-design.md @@ -27,6 +27,8 @@ 结果先进入现有发件箱,发送失败由服务有限重试,不重新调用模型;失败计数达到 5 或遇到永久错误后停止补发。发送前校验目标会话中的 Bot ID,拒绝把旧 Bot 结果交给新 Bot。附件只附加桌面查看提示。 +生成图片后,executor 先把 `artifactIds` 写入桌面会话,再向 `ChannelService` 返回通道契约中的状态、正文、错误和附件。图片字节保留在附件中;桌面成果 ID 不进入严格校验的通道结果,避免成功任务被误报为“无效结果”。该转换位于本地和远程 Runtime 事件处理之后,不改变 Runtime 或 Agent 协议。 + 驱动按最多 4096 个 UTF-16 码元拆分纯文本,并避开代理对中间位置;不设置 `parse_mode` 或引用原消息。分段进度只在驱动内存保存,最多 128 条,停止时清空。单次发送的 429 最多等待重试 3 次,仍失败则交回发件箱处理。跨重启补发与不确定网络结果允许产生重复段落。 停用会中止轮询、发送等待、活动执行与排队工作,等待活动操作结束后报告停止。清除凭据时设置页同时提交 `enabled=false`;底层拒绝启用但无 Token 的配置。Token 替换先验证,失败不停止旧服务。项目与历史不因停用或清除凭据删除。 @@ -37,4 +39,6 @@ Manager 通过 `{ channel, status }` 增量 IPC 推送状态,忽略旧服务 Renderer 的 `channel-status-store.ts` 订阅事件,selector 只向状态区域提供对应通道数据。首次快照不会覆盖已收到的较新事件;状态更新不重新加载整份设置,不重置 Token 或白名单草稿。配置页复用共享页签、开关、项目设置字段和通知控件。 +保存时读取状态 store 的最新值。Telegram 已启用、配置可编辑且状态为 `error` 或 `stopped` 时,即使字段未变,也提交当前配置以重启连接;`running`、`starting` 状态下的无修改保存不重启轮询。环境变量配置仍为只读,测试连接不改变轮询状态。 + 源码入口:[`telegram-channel-driver.ts`](../../../src/main/channels/telegram-channel-driver.ts)、[`channel-manager.ts`](../../../src/main/channels/channel-manager.ts)、[`channel-service.ts`](../../../src/main/channels/channel-service.ts)、[`channel-settings-store.ts`](../../../src/main/channels/channel-settings-store.ts)、[`channel-status-store.ts`](../../../src/renderer/src/channel-status-store.ts)、[`ChannelSettingsSection.tsx`](../../../src/renderer/src/ChannelSettingsSection.tsx)。 diff --git a/docs/features/unified-execution/progress.md b/docs/features/unified-execution/progress.md index e5d5a208..733b52fa 100644 --- a/docs/features/unified-execution/progress.md +++ b/docs/features/unified-execution/progress.md @@ -1,5 +1,100 @@ # 进度与证据 +## 2026-10-05 Runtime 生命周期审查修复 + +A01(远程启动阻塞控制队列)和 A02(本机 OpenCode 提交失败后等待静默 SSE)已修复。 +远程启动等待释放 backend 控制队列及协议接收分派,取消、watchdog、关闭和其他 +binding 可以继续处理;Session owner 合并相同启动、拒绝冲突,并关闭取消后迟到的 +原生 Session。本机提交错误立即唤醒所属 SSE 消费,保留原错误和既有 Session 取消。 +两条实现均未增加整个 Prompt 的默认总时限;共享进程、终态提交和写帧顺序规则见 +[Runtime 进程复用设计](../assistant-workbar/runtime-process-reuse-technical-design.md)。 + +### 确定性复现 + +持久回归中,本机拒绝、错误响应、10 ms 提交超时三种情况在旧代码经过 100 ms +假时钟后仍未结算;远程共享进程中的第二个 Session 启动被挂起时,旧代码经过 +200 ms 后,取消/截止时间和同进程另一 binding 的查询均未结算。上述五项先运行旧 +实现,全部按这些断言失败。真实 Host 首轮还定位到协议 dispatcher 串行等待启动 +响应,新增帧级回归在修复前只分派 `start`,没有分派后续 `cancel` 和 `peer`。 + +修复后的九项新增回归通过,另覆盖启动 watchdog、默认关闭时限、相同启动重放及 +冲突。共享 peer 保持 running;迟到 Session 收到自身的 `session/close`,取消的 +启动不发送 Prompt。原始审查探针曾在 131 秒假时钟后观察到启动、取消、peer 查询 +仍等待,默认 10 秒 dispose 报超时且 stop 次数为 0,现有关闭与 watchdog 回归不再 +等待原生初始化响应。各假时钟值是确定性边界,不是系统性能测量。 + +### 当前源码实机验证 + +复用已有 lifecycle harness,用 esbuild 重建当前 Desktop driver、Agent daemon、 +CLI/helper;凭据从 GoodBuddy 已有加密配置的临时副本加载,SSH 校验已固定 Host +identity。共享 Linux x64 经 `192.168.0.23` 连接,只使用 `/root/tmp/gb-lifecycle-*` +专用目录和所属进程,未替换原 Host 安装或修改无关数据。 + +| 场景 | 实际结果 | 真实文本提供商调用 | +| --- | --- | ---: | +| 首轮 Host 启动挂起 | backend 修复后,协议 dispatcher 仍阻塞;测试退出后所属进程为 0 | 0 | +| OpenCode 启动取消及工具、detach、controller 恢复、测试 Agent 提升、重放 | 启动响应被挂起时控制查询 48 ms、取消 216 ms;工具与恢复完成;两个提供商响应均 HTTP 200 | 2 | +| Continue 同场景 | 控制查询 60 ms、取消 258 ms;工具与恢复完成;两个提供商响应均 HTTP 200 | 2 | +| Windows 当前源码 OpenCode 普通文本 | 真实原生二进制经有界 loopback 转发访问已配置模型,返回 `LOCAL_RUNTIME_OK`,0 工具调用;总耗时 4,727 ms | 1 | + +本轮合计 **5 次真实文本提供商调用、0 次图片生成调用**。两次本机脚本准备失败发生在 +SDK 动态加载阶段,均为 0 次模型调用,随后修正测试 bundle 并通过。所有成功场景均 +使用最终相关生产源码。Host 启动取消阶段各为 0 次模型调用,最终所属残留进程均为 0, +专用目录由 harness 清理;本机 Runtime 和凭据临时副本也已清理。 + +真实数据检查使用本轮原生工具和模型产生的事件:OpenCode 45 条写入、23 个 checkpoint, +Continue 16 条写入、8 个 checkpoint,经生产 `AssistantDatabase` 和 +`RemoteEventBatcher` 写入隔离 SQLite,再重放确认全部去重。未迁移、改写用户生产 +历史库,也未以构造事件代替本轮 Host transcript。 + +原始脚本和日志位于 `C:/Users/jiang/AppData/Local/Temp/opencode`: +`a01-runtime-host-build.cjs`、`a01-runtime-host-run.cjs`、`a01-runtime-host/`,以及 +`a02-local-model.ts`、`a02-local-model-run.cjs` 和 `a02-local-model-1791142126078.log`。 +Host 成功日志为 `opencode-1791141713375.log`、`continue-1791141785721.log`。 +延迟为各场景一次实测,不宣称吞吐、p95 或完整桌面性能提升。 + +### 检查命令与边界 + +```text +npx vitest run src/agent-daemon --testTimeout=30000 --hookTimeout=60000 +npx vitest run src/main/remote-agent src/main/agent/acp-remote-runtime.test.ts src/main/agent/runtime-controller.test.ts src/main/agent/local-runtime-registry.test.ts --testTimeout=30000 +npx vitest run src/main/agent/opencode-runtime.test.ts +npx vitest run src/main/agent/opencode-runtime-lifecycle.test.ts --testTimeout=30000 --hookTimeout=60000 +npm run typecheck +npm run lint +``` + +四组测试分别为 323 通过/33 跳过、393 通过、156 通过、4 通过,共 876 项通过; +typecheck、全仓 lint 和修改范围 `git diff --check` 通过。初次合并运行中,既有 +OpenCode `cleanup-timeout` 用例失败一次;该用例独立复跑及随后完整 OpenCode 文件 +复跑通过,未改动该用例或相关产品清理逻辑。较早 Main 类型检查的并发监督者 fixture +错误在最终全仓检查时已消失。 + +验证覆盖当前源码、真实 Windows OpenCode 二进制和共享 Linux x64 Host,不含签名包 +安装验收、完整 UI 点击、macOS/Linux arm64 或用户生产大库性能测试。首轮定向验证 +未运行全仓 `npm test`,后续完整检查见下节;远程更新仍须部署包含本次源码的 Agent, +未提交或发布。 + +### SDK cleanup-timeout 复查 + +后续全仓验证为 6,130 通过、1 失败、87 跳过,唯一失败是 OpenCode SDK +`cleanup-timeout` 用例。复查通过独立 Node 子进程的十轮显式 GC 和受控取消复现: +旧 fetch 替身只保存 `request.signal`,返回永不结算的 Promise,没有在途 Request +所有者或 abort 处理。Request 及 Undici 内部 AbortController 被回收后,外层超时 +signal 已中止,保存的 Request signal 仍未中止。真实 loopback fetch 在同样 GC +条件下保留 Request,取消后 SDK 返回错误,未观察到产品清理提前完成。 + +修正仅涉及测试:挂起的 fetch 替身在 abort 时以 `request.signal.reason` 拒绝, +闭包保持 Request 存活至取消。原有 `signals.every(signal => signal.aborted)`、调用 +顺序、错误脱敏及超时均保留。新增 GC 回归在旧替身上稳定失败,修正后通过;九项 +SDK 定向用例、Main 类型检查及该文件 ESLint 通过。本次跟进新增外部模型调用 0 次, +生产代码与远程 Agent 路径均未改动,无需重新执行 Host 模型验证。 + +修正后的完整 `npm test` 于 04:00:22 开始,852.63 秒完成,退出码 0:518 文件通过、 +15 文件跳过;6,136 项通过、0 失败、87 跳过。计数包含并发工作树中的其他修复。 +原始记录为 `C:/Users/jiang/AppData/Local/Temp/opencode/a02-cleanup-final-20261005.log` +及同名 `.json`;GC/真实 loopback 对照脚本为该目录的 `a02-cleanup-gc-probe.mjs`。 + ## 2026-10-04 调研与设计 调研阶段,用户确认彻底移除 Ask / Execute,包括界面、原生工具与 MCP 限制,并允许升级清理历史模式字段。该阶段先交付文档,随后用户确认删除安全设置并授权实施;实现结果见下文。 diff --git a/docs/features/wechat-channel/prd.md b/docs/features/wechat-channel/prd.md index 851efc32..e9e8f3fb 100644 --- a/docs/features/wechat-channel/prd.md +++ b/docs/features/wechat-channel/prd.md @@ -298,6 +298,8 @@ Agent Runtime。OpenCode/Continue 的通道项目只持久化 `provider`,旧 现有发送者白名单和群聊提及设置继续有效。 +钉钉回复以接口响应中的 `errcode=0` 确认投递,HTTP 200 本身不代表成功。业务错误或无效响应保留回复上下文和发件箱结果,由现有有限重试补发,不重新执行任务。响应读取上限为 64 KiB,包含没有 `Content-Length` 的分块响应;错误提示只保留数值错误码,不回显平台响应中的敏感字段。 + ## 8. 远程会话需求 ### 8.1 会话创建与复用 diff --git a/docs/review/stage-audit-fixes-2026-10-05.md b/docs/review/stage-audit-fixes-2026-10-05.md new file mode 100644 index 00000000..4a399907 --- /dev/null +++ b/docs/review/stage-audit-fixes-2026-10-05.md @@ -0,0 +1,77 @@ +# 阶段审计修复与验收 + +本轮十项审计发现均已修复,最终完整测试、类型检查和 lint 通过。修改基于 `126dbc57` 后的工作树,未提交或发布。数据验证使用隔离的合成 SQLite 和本轮真实 Runtime 产生的事件,没有改写 portable 原始用户数据。 + +## 修复清单 + +| 编号 | 问题 | 必须覆盖的失败场景 | 状态 | +| --- | --- | --- | --- | +| A01 | 远程 ACP 启动占用全局控制队列 | 延迟初始化时其他绑定查询、取消、看门狗和关闭仍可完成 | 已修复;回归与真实 Host 通过 | +| A02 | 本地 OpenCode 提交失败后等待静默 SSE | 拒绝、错误响应、提交超时均及时结束并保留错误,正常流不变 | 已修复;回归与真实本机模型通过 | +| A03 | 监督候选生产链路遗漏项目参数 | 真实生产装配与 Worker 路径尊重 projectId/crossProject | 已修复;生产 Worker、SQLite 与真实模型通过 | +| A04 | 最终候选查询期间暂停仍发布 | 暂停后不保存结果,正常完成仍保存 | 已修复;暂停/继续与取消回归通过 | +| A05 | 在途故事分配写入已移除故事 | 移除候选后不得产生隐藏关联或消耗待处理事件 | 已修复;移除/合并/拆分回归通过 | +| A06 | 图片生成结果被通道严格契约拒绝 | 含 artifactIds 的执行结果通过生产通道交付,桌面产物保留 | 已修复;生产服务与 SQLite 发件箱通过 | +| A07 | Telegram 原样保存不能恢复轮询 | 终止错误解除后原样保存重启,正常运行保存不重复启动 | 已修复;组件与 Electron 生产入口通过 | +| A08 | 钉钉 HTTP 200 业务错误被算作送达 | 非零 errcode 保留失败及重试上下文,成功回复正常交付 | 已修复;loopback HTTP 与 SQLite 重试通过 | +| A09 | 监督视图请求失效后永久忙碌 | 刷新期间旧请求结束不覆盖新状态,控件恢复可用 | 已修复;组件与 Electron 通过 | +| A10 | 虚拟消息行淘汰丢失展开状态 | 超过八行交互后滚出再滚回保持展开,DOM 仍有界 | 已修复;组件、Electron 与缓存保留测量通过 | + +## 验收要求 + +- 每项记录旧实现失败、新实现通过的测试证据及命令。 +- 运行时改动检查本地与远程路径,Agent 改动在共享 Linux x64 Host 验证当前源码。 +- 真实模型请求保持有界,记录实际请求数;模拟 Provider 和真实 Provider 分开报告。 +- 数据验证使用真实 SQLite 合成数据或 portable 数据隔离副本,记录规模及一致性检查,不记录用户正文或凭据。 +- 性能检查遵守 `docs/architecture/performance-principles.md`,测量与推断分开;不删除动画、虚拟化、取消或正常功能来取得通过。 +- 最后运行完整测试、类型检查与 lint,并同步所属功能文档。 + +## 验证记录 + +### 复现与修复 + +| 范围 | 修复前证据 | 修复及验证 | +| --- | --- | --- | +| A01/A02 | 静默 SSE 三种提交失败不结束;挂起启动阻塞取消和其他绑定;协议分派只收到 start | 启动等待释放 backend 和 socket 分派队列;相同启动合并、冲突拒绝;迟到 Session 单独关闭。本机提交失败取消所属订阅并保留原错误。九项新增回归通过 | +| A03/A04/A05 | 八个新增用例中七个失败,既有取消场景原本通过;关闭跨项目仍收到其他项目候选,暂停后仍发布,已移除故事仍收到新关联 | 贯通批次参数;发布前检查取消;原事务中检查目标当前状态。96 项监督/Worker 定向测试通过,无 schema 迁移 | +| A06/A07/A08 | 选择性回归九项失败、三项通过;图片结果失败、原样保存不调用重启、业务错误被记作送达 | 通道边界映射公开结果;异常轮询状态允许原样保存重启;有界解析钉钉业务响应。106 项通道测试、3 项 IPC 测试及 Telegram Electron 通过 | +| A09/A10 | 控件保持禁用;超过八行交互后的展开状态丢失,Electron 原生交互也复现 | 失效请求释放等待状态;展开选择由历史面板保存,虚拟行卸载后可恢复。112 项相关组件回归及真实 Electron 通过 | + +独立复审没有发现运行时修复引入的高置信度回归,但发现首次 A10 实现保存了未操作的默认展开值。跟进回归复现浏览 10,000 个作用域留下 10,000 条记录,现改为仅保存用户覆盖项并清理已删除来源;该修正包含在最终全仓测试中。 + +### 真实模型与 Host + +本轮准确合计 **7 次外部文本模型请求、0 次图片模型请求**。另有 Telegram Electron 中的 2 次 loopback 模型请求,不计为外部请求。没有向真实消息通道收件人发送测试消息。 + +| 路径 | 真实外部请求 | 结果 | +| --- | ---: | --- | +| Linux x64 OpenCode | 2 | 当前源码工具执行、断开、恢复、测试 Agent 提升和事件重放通过;启动挂起时查询 48 ms、取消 216 ms | +| Linux x64 Continue | 2 | 同类生产路径通过;启动挂起时查询 60 ms、取消 258 ms | +| Windows 本地 OpenCode | 1 | 真实二进制正常返回固定文本,0 工具调用,总耗时 4,727 ms | +| 监督生产服务与文件 Worker | 2 | 147 字符合成消息完成提取和故事归属,总耗时 3.84 秒;无变化重跑新增请求为 0 | + +Host 使用现有固定身份和加密凭据,测试资源位于 `/root/tmp/gb-lifecycle-*`。结束时所属进程为零,没有替换原 Host 安装。延迟是单次场景观察,不是 p95 或吞吐结论。完整命令、脚本及日志索引见[运行时验证](../features/unified-execution/progress.md#2026-10-05-runtime-生命周期审查修复)。 + +### 数据与性能 + +- SQLite 合成库包含 10 个项目、1,000 个实体、10,000 个事件和 10,000 条事件实体关联。候选 ID 与排序逐项一致,数据计数不变、外键检查为空、完整性为 `ok`。真实 Host 事件另外通过生产数据库与批处理器写入:OpenCode 45 条/23 个 checkpoint,Continue 16 条/8 个 checkpoint,重放去重通过。 +- 激活项目隔离后,测量发现原相关子查询较慢,改用实体集合查询。三轮各十次项目查询,旧 SQL 合计 3,517/5,302/4,190 ms;新 Worker 路径含传输为 39.18/34.01/36.04 ms。120 事件归属事务为 6.63 ms。此结果仅适用于该合成库 SQL 对照,见[监督后端证据](../features/conversation-supervision/progress.md#2026-10-05-后端阶段审查-a03a04a05)。 +- Windows Electron 使用 2,000/10,000 条合成消息,原版与首次修复各三轮交替运行;后续缓存修正又做三轮对照。最大采样挂载量均为 15 个消息行、926 个 DOM 元素,展开、键盘、焦点、选区和滚动回归通过。缓存修正后的导航中位数为 38.5~39.1 ms,含聚焦后的两帧等待,不能作为纯渲染耗时或整体预算结论。 +- 未操作导航后缓存条目为零;13 条消息交互后保留 38 条用户覆盖项,删除消息后归零。该测量统计缓存条目,没有测量堆字节。方法、每轮数据及原有滚动回归见[前端验证](../features/application-tool-navigation/progress.md#2026-10-05展开状态缓存复查)。 + +### 最终检查 + +| 命令 | 最终结果 | +| --- | --- | +| `npm test` | 518 文件通过、15 文件跳过;6,136 项通过、0 失败、87 跳过;852.63 秒 | +| `npm run typecheck` | Main、Agent、Renderer 全部通过 | +| `npm run lint` | 通过 | +| `git diff --check` | 通过 | + +首轮完整测试的唯一失败为既有 `cleanup-timeout`。独立 GC 探针确认 mock 返回永不结算 Promise,却未保留待完成的 Request;GC 后 Undici 取消转发失效。测试夹具现保持 Request 存活至取消,并按真实 fetch 行为拒绝。原断言、调用顺序和一秒清理预算均保留,新增强制 GC 回归先在旧 mock 上失败,再在修正后通过。生产清理代码未因此调整。详见[GC 复查](../features/unified-execution/progress.md#sdk-cleanup-timeout-复查)。 + +最终全仓原始日志为 `C:/Users/jiang/AppData/Local/Temp/opencode/a02-cleanup-final-20261005.log`,退出码及时间记录为同名 `.json`。通道网络替身、发件箱及图片元数据验证见[通道记录](../features/telegram-channel/progress.md#2026-10-05-通道审计修复验证)。 + +## 验证边界 + +本轮未做真实 Telegram/钉钉平台收发或付费图片生成,A06 验证的是生成完成后的生产交付契约。未做 macOS、Linux arm64、低端机节流、完整 App 性能预算或签名包安装验收,也未用用户生产大库证明性能。上述已测场景未观察到功能或性能退化,不能推广为所有设备和数据规模的保证。远程修复生效需要部署包含当前源码的 Agent;本轮没有提交、打包发布或升级用户安装。 diff --git a/scripts/supervision-audit-validation.ts b/scripts/supervision-audit-validation.ts new file mode 100644 index 00000000..23d81264 --- /dev/null +++ b/scripts/supervision-audit-validation.ts @@ -0,0 +1,143 @@ +// Synthetic SQLite / bounded live validation for A03-A05. Never opens a user database. +import assert from 'node:assert/strict' +import { randomUUID } from 'node:crypto' +import { mkdtemp, readFile, stat, writeFile } from 'node:fs/promises' +import { isAbsolute, join, relative, resolve } from 'node:path' +import { parseEnv } from 'node:util' +import { build } from 'esbuild' +import type { DatabaseSync } from 'node:sqlite' +import { AssistantDatabase } from '../src/main/assistant/assistant-database' +import { createProductionSupervisorService } from '../src/main/assistant/supervision-production' +import { SupervisionModelPool } from '../src/main/assistant/supervision-model-pool' +import { ModelAgentRuntime } from '../src/main/agent/model-runtime' +import type { SupervisionRunRequest } from '../src/shared/supervision-contracts' +import { supervisionReviewSettingsSchema } from '../src/shared/supervision-review-contracts' + +async function main() { + const [mode, parent, envPath] = process.argv.slice(2) + assert((mode === 'scale' || mode === 'live') && parent) + const rel = relative(process.cwd(), resolve(parent)) + assert(rel.startsWith('..') || isAbsolute(rel), 'Output must be outside repository') + const directory = await mkdtemp(join(resolve(parent), 'supervision-audit-')) + const path = join(directory, 'assistant.sqlite') + const worker = join(directory, 'readonly-query-worker.cjs') + await build({ entryPoints: [resolve('src/main/readonly-query-worker.ts')], outfile: worker, + bundle: true, platform: 'node', format: 'cjs', logLevel: 'silent' }) + const db = new AssistantDatabase(path) + db.initialize(directory) + db.enableReadonlyWorker(worker) + const sql = (db as unknown as { requireDatabase(): DatabaseSync }).requireDatabase() + const pool = new SupervisionModelPool() + const report: Record = { mode, directory, externalCalls: 0 } + let calls = 0 + const started = performance.now() + const request: SupervisionRunRequest = { trigger: 'manual', scope: { kind: 'global' }, + timeRange: { from: '2026-10-01T00:00:00.000Z', to: '2026-10-02T00:00:00.000Z' } } + const empty = { summary: 'Synthetic audit', changeDigest: '', openItems: [], events: [], entities: [], entityChanges: [], relations: [] } + try { + if (mode === 'scale') { + const projects = [db.listProjects()[0]!, ...Array.from({ length: 9 }, (_, i) => db.createProject({ name: `Synthetic ${i}`, description: '', rootPath: directory }))] + for (const project of projects) { + db.saveSupervisionResult({ request, evidence: [{ id: 's', sourceType: 'conversation', sourceId: randomUUID(), + title: 'Synthetic ownership', content: 'Synthetic source', occurredAt: request.timeRange.from, locator: { projectId: project.id } }], + output: { ...empty, + entities: Array.from({ length: 100 }, (_, i) => ({ id: `e${i}`, label: `${project.id}:${i}`, description: 'Synthetic knowledge', sourceReferenceIds: ['s'] })), + events: Array.from({ length: 1000 }, (_, i) => ({ title: `Event ${i}`, description: 'Synthetic decision', occurredAt: request.timeRange.from, + eventType: 'discussion' as const, entityIds: [`e${i % 100}`], sourceReferenceIds: ['s'] })) } }) + } + report.seedMs = performance.now() - started + const counts = () => ['supervision_entities', 'supervision_events', 'supervision_event_entities', 'supervision_sources'].map(table => + [table, Number(sql.prepare(`SELECT COUNT(*) AS n FROM ${table}`).get()!.n)]) + const before = counts() + await db.supervisionCandidates(request, { projectId: projects[0]!.id, crossProject: false }) + const legacy = sql.prepare(`SELECT e.id FROM supervision_entities e WHERE e.confirmation_state != 'revoked' AND EXISTS ( + SELECT 1 FROM supervision_event_entities ee JOIN supervision_events ev ON ev.id = ee.event_id + WHERE ee.entity_id = e.id AND ev.superseded_by IS NULL AND COALESCE(ev.project_id, '') = ?) + ORDER BY e.updated_at DESC, e.id LIMIT 100`) + const rounds: Array<{ legacyMs: number; isolatedMs: number; crossProjectMs: number }> = [] + for (let round = 0; round < 3; round++) { + const legacyStart = performance.now() + const expected = new Map(projects.map(project => [project.id, legacy.all(project.id).map(row => row.id)])) + const legacyMs = performance.now() - legacyStart + const start = performance.now() + for (const project of projects) { + const candidates = await db.supervisionCandidates(request, { projectId: project.id, crossProject: false }) + assert.equal(candidates.length, 100) + assert(candidates.every(item => item.label.startsWith(project.id + ':'))) + assert.deepEqual(candidates.map(item => item.id), expected.get(project.id)) + } + const isolatedMs = performance.now() - start + const crossStart = performance.now() + const candidates = await db.supervisionCandidates(request, { projectId: projects[0]!.id, crossProject: true }) + assert.equal(candidates.length, 100) + rounds.push({ legacyMs, isolatedMs, crossProjectMs: performance.now() - crossStart }) + } + assert.deepEqual(counts(), before) + report.rows = Object.fromEntries(before) + report.rounds = rounds + report.isolatedQueriesPerRound = projects.length + // Exercise the new destination check with the largest admitted assignment chunk. + sql.exec('INSERT INTO supervision_story_assigned SELECT id FROM supervision_events') + const project = projects[0]! + const events = sql.prepare('SELECT id, project_id, started_at, event_type, title, description FROM supervision_events WHERE project_id = ? LIMIT 120').all(project.id) + const stories = db.supervisionStories() + const applyStart = performance.now() + stories.apply(project.id, events as Parameters[1], [], { + stories: [{ key: 'new_1', level: 'feature', name: 'Synthetic feature', description: '' }], + assignments: events.map((_, i) => ({ event: `e_${i + 1}`, story: 'new_1' })), concluded: [], links: [] + }, { crossProject: false, threadEvents: 500 }) + report.assignment120Ms = performance.now() - applyStart + assert.equal(stories.list({ kind: 'projects', projectIds: [project.id] })[0]!.events.length, 120) + } else { + assert(envPath, 'Environment file required') + const env = parseEnv(await readFile(envPath, 'utf8')) + assert(env.DEEPSEEK_API_KEY && env.DEEPSEEK_BASE_URL && env.DEEPSEEK_MODEL) + const content = 'We decided to use SQLite transactions to save review facts and coverage together. This prevents partial publication. The implementation is pending.' + db.saveLocalConversations([{ header: { id: randomUUID(), projectId: db.listProjects()[0]!.id, title: 'Synthetic review decision', updatedAt: Date.parse(request.timeRange.from) }, + messages: [{ id: randomUUID(), role: 'user', state: 'complete', content, createdAt: Date.parse(request.timeRange.from) }] }]) + const runtime = new ModelAgentRuntime({ protocol: 'openai-chat-completions', authentication: 'api-key', + baseUrl: env.DEEPSEEK_BASE_URL, model: env.DEEPSEEK_MODEL, apiKey: env.DEEPSEEK_API_KEY, defaultWorkspace: directory, + maximumOutputTokens: 1800, requestBody: { thinking: { type: 'disabled' }, response_format: { type: 'json_object' } }, + toolProvider: { listTools: async () => [], getApproval: () => { throw new Error('Tools disabled') }, + callTool: async () => { throw new Error('Tools disabled') }, releaseConversation: async () => undefined, dispose: async () => undefined }, + fetcher: async (input, init) => { + assert(calls < 3, 'Three external request cap') + assert.equal(JSON.parse(String(init?.body)).tools?.length ?? 0, 0) + calls++ + return fetch(input, init) + } }) + const service = createProductionSupervisorService(db, async () => ({ supervisorOrganizeTimeoutSeconds: 45, + supervisionReview: supervisionReviewSettingsSchema.parse({ crossProject: false, experienceMinEvents: 200 }) }), async () => runtime, pool) + const result = await service.run(request) + assert.equal(result.status, 'completed') + assert.equal(result.coverage?.stories?.status, 'completed') + const evidence = db.supervisionReviewStore().batches(result.runId!).flatMap(batch => batch.evidence) + assert.equal(evidence.map(item => item.content).join(''), content) + const before = calls + assert.equal((await service.run(request)).status, 'no_change') + assert.equal(calls, before) + report.messages = 1 + report.characters = content.length + report.status = result.status + report.storyCalls = result.coverage?.stories?.calls + report.noChangeAdditionalCalls = calls - before + } + assert.deepEqual(sql.prepare('PRAGMA foreign_key_check').all(), []) + assert.equal(sql.prepare('PRAGMA integrity_check').get()!.integrity_check, 'ok') + report.integrity = 'ok' + report.passed = true + } catch (error) { + report.passed = false + report.errorType = error instanceof Error ? error.name : 'Error' + process.exitCode = 1 + } finally { + report.externalCalls = calls + report.elapsedMs = performance.now() - started + db.close() + pool.dispose() + report.databaseBytes = (await stat(path)).size + await writeFile(join(directory, 'metrics.json'), JSON.stringify(report, null, 2)) + console.log(JSON.stringify(report, null, 2)) + } +} +void main().catch(() => { console.error('Synthetic supervision validation preflight failed'); process.exitCode = 1 }) diff --git a/src/agent-daemon/agent-owned-acp-prompt.test.ts b/src/agent-daemon/agent-owned-acp-prompt.test.ts index db2cf55d..291bc34b 100644 --- a/src/agent-daemon/agent-owned-acp-prompt.test.ts +++ b/src/agent-daemon/agent-owned-acp-prompt.test.ts @@ -23,6 +23,33 @@ afterEach(() => { }) describe('AgentOwnedAcpPrompt', () => { + it('coalesces identical starts and rejects conflicting starts while initialization is pending', async () => { + const root = mkdtempSync(join(tmpdir(), 'goodbuddy-owned-start-')) + temporary.push(root) + const transcript = new SemanticPromptStore(join(root, 'prompts.sqlite')) + transcript.prepare({ bindingId: 'binding-1', operationId: 'operation-1', requestId: 'operation-1', + controllerId: 'controller-1', preparationDigest: `sha256:${'a'.repeat(64)}`, promptSequence: 0 }) + let initialize!: (value: { protocolVersion: number }) => void + let finish!: (value: PromptResponse) => void + const newSession = vi.fn(async () => ({ sessionId: 'session-1' })) + const prompt = vi.fn(() => new Promise(resolve => { finish = resolve })) + const owner = new AgentOwnedAcpPrompt({ bindingId: 'binding-1', controllerId: 'controller-1', + workspaceDirectory: '/workspace', process: new MemoryProcess(), transcript, completePrompt: async () => undefined, + createConnection: () => ({ initialize: () => new Promise(resolve => { initialize = resolve }), newSession, prompt }) as unknown as ClientSideConnection }) + const request = { bindingId: 'binding-1', operationId: 'operation-1', requestId: 'operation-1', prompt: [{ type: 'text' as const, text: 'test' }] } + try { + const first = owner.start(request) + const replay = owner.start(request) + await expect(owner.start({ ...request, prompt: [{ type: 'text', text: 'different' }] })).rejects.toMatchObject({ code: 'conflict' }) + initialize({ protocolVersion: 1 }) + expect(await first).toEqual(await replay) + expect(newSession).toHaveBeenCalledOnce() + expect(prompt).toHaveBeenCalledOnce() + finish({ stopReason: 'end_turn' }) + await vi.waitFor(() => expect(transcript.attach('binding-1', 'operation-1', 'controller-1').state).toBe('completed')) + } finally { owner.close(); transcript.close() } + }) + it('refreshes prompt tools on the retained session and authorizes execution', async () => { const root = mkdtempSync(join(tmpdir(), 'goodbuddy-owned-acp-modes-')) temporary.push(root) diff --git a/src/agent-daemon/agent-owned-acp-prompt.ts b/src/agent-daemon/agent-owned-acp-prompt.ts index 94d51360..5b55511b 100644 --- a/src/agent-daemon/agent-owned-acp-prompt.ts +++ b/src/agent-daemon/agent-owned-acp-prompt.ts @@ -18,6 +18,7 @@ import type { } from '../shared/remote-agent-contracts' import type { RuntimeAcpProcessOwner } from './runtime-acp-backend' import { AgentAcpConnection } from './agent-acp-connection' +import { raceWithAbort } from './async-utils' import { SemanticPromptStore, SemanticPromptStoreError @@ -63,6 +64,11 @@ export class AgentOwnedAcpPrompt { #promptSettled?: Promise #closed = false #initialized = false + #starting?: { + digest: string + controller: AbortController + promise: Promise + } #hasMcpServers = false readonly #questions = new Map { + if (this.#closed) throw new Error('ACP Session is closed') if ( request.bindingId !== this.#options.bindingId ) { @@ -95,6 +102,12 @@ export class AgentOwnedAcpPrompt { ) } const startDigest = digest(canonicalJson(request)) + if (this.#starting) { + if (this.#starting.digest !== startDigest) { + throw new SemanticPromptStoreError('Another ACP prompt is starting', 'conflict') + } + return this.#starting.promise + } const existing = this.#options.transcript.findStarted({ bindingId: request.bindingId, operationId: request.operationId, @@ -111,9 +124,39 @@ export class AgentOwnedAcpPrompt { ) } + const controller = new AbortController() + const promise = this.#start(request, startDigest, controller.signal) + this.#starting = { digest: startDigest, controller, promise } + try { + return await promise + } finally { + this.#starting = undefined + } + } + + async #start( + request: RemoteOwnedPromptStartRequest, + startDigest: string, + signal: AbortSignal + ): Promise { + const whileStarting = async (operation: Promise): Promise => { + const result = await raceWithAbort(this.#whileProcessAlive(operation), signal) + signal.throwIfAborted() + return result + } + const retainSession = async (operation: Promise): Promise => + whileStarting(operation.then(sessionId => { + if (signal.aborted) { + // A native session can be created after its owner was cancelled. + void this.#connection.closeSession({ sessionId }).catch(() => undefined) + } else { + this.#sessionId = sessionId + } + return sessionId + })) const mcpServers = this.#options.mcpServers?.() ?? [] if (!this.#initialized) { - const initialization = await this.#whileProcessAlive( + const initialization = await whileStarting( this.#transport.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: { @@ -134,38 +177,36 @@ export class AgentOwnedAcpPrompt { } if (request.acpSessionId !== undefined && this.#sessionId === undefined) { if (initialization.agentCapabilities?.loadSession === true) { - await this.#whileProcessAlive( + this.#sessionId = await retainSession( this.#connection.loadSession({ sessionId: request.acpSessionId, cwd: this.#options.workspaceDirectory, mcpServers - }) + }).then(() => request.acpSessionId!) ) } else if ( initialization.agentCapabilities?.sessionCapabilities?.resume != null ) { - await this.#whileProcessAlive( + this.#sessionId = await retainSession( this.#connection.resumeSession({ sessionId: request.acpSessionId, cwd: this.#options.workspaceDirectory, mcpServers - }) + }).then(() => request.acpSessionId!) ) } else { throw new Error('ACP Runtime cannot resume the requested session') } - this.#sessionId = request.acpSessionId } else if (this.#sessionId === undefined) { - const created = await this.#whileProcessAlive( + this.#sessionId = await retainSession( this.#connection.newSession({ cwd: this.#options.workspaceDirectory, mcpServers - }) + }).then(created => created.sessionId) ) - this.#sessionId = created.sessionId } if (this.#options.expectedModel !== undefined) { - const configured = await this.#whileProcessAlive( + const configured = await whileStarting( this.#connection.setSessionConfigOption({ sessionId: this.#sessionId!, configId: 'model', @@ -190,13 +231,14 @@ export class AgentOwnedAcpPrompt { ) { throw new Error('ACP session identity cannot change within a binding') } else if (mcpServers.length > 0 || this.#hasMcpServers) { - await this.#whileProcessAlive(this.#connection.resumeSession({ + await whileStarting(this.#connection.resumeSession({ sessionId: this.#sessionId!, cwd: this.#options.workspaceDirectory, mcpServers })) } this.#hasMcpServers = mcpServers.length > 0 - await this.#options.prepareSession?.(this.#sessionId!, request.operationId) + await whileStarting(Promise.resolve(this.#options.prepareSession?.(this.#sessionId!, request.operationId))) + signal.throwIfAborted() this.#active = { operationId: request.operationId } @@ -251,6 +293,7 @@ export class AgentOwnedAcpPrompt { } async cancel(): Promise { + this.#starting?.controller.abort(new Error('ACP prompt startup cancelled')) this.#acceptQuestions = false this.#questions.clear() if (this.#sessionId !== undefined && !this.#closed) { @@ -396,6 +439,7 @@ export class AgentOwnedAcpPrompt { return } this.#closed = true + this.#starting?.controller.abort(new Error('ACP Session is closed')) this.#acceptQuestions = false this.#questions.clear() this.#unregisterSession?.() diff --git a/src/agent-daemon/protocol-server.test.ts b/src/agent-daemon/protocol-server.test.ts index a8c3ed74..a7172ae7 100644 --- a/src/agent-daemon/protocol-server.test.ts +++ b/src/agent-daemon/protocol-server.test.ts @@ -44,6 +44,30 @@ afterEach(() => { }) describe('AgentProtocolServer connection bounds', () => { + it('dispatches cancellation and peer requests before a native startup response', async () => { + const gate = deferred() + const calls: string[] = [] + const harness = createHarness({ methods: { + 'runtime/startPrompt': async () => { calls.push('start'); await gate.promise; return { started: true } }, + 'runtime/escalateCancellation': async () => { calls.push('cancel'); return { stopped: true } }, + 'runtime/getAcpCursors': async () => { calls.push('peer'); return {} } + } }) + try { + harness.socket.receive(Buffer.concat([ + controlRequest(harness.controller, 'start', '1', 1, 'runtime/startPrompt'), + controlRequest(harness.controller, 'cancel', '1', 1, 'runtime/escalateCancellation'), + controlRequest(harness.controller, 'peer', '1', 1, 'runtime/getAcpCursors') + ].map(Buffer.from))) + await new Promise(resolve => setImmediate(resolve)) + expect(calls).toEqual(['start', 'cancel', 'peer']) + expect(harness.socket.writes).toHaveLength(4) + gate.resolve() + await waitFor(() => harness.socket.writes.length === 6) + expect(harness.socket.maximumConcurrentWrites).toBe(1) + expect(harness.socket.destroyCalled).toBe(false) + } finally { gate.resolve(); harness.close() } + }) + it('accepts a frame fragmented into single-byte socket chunks', async () => { const harness = createHarness() const frame = controlRequest( diff --git a/src/agent-daemon/protocol-server.ts b/src/agent-daemon/protocol-server.ts index 5ab97bf6..f8c534dc 100644 --- a/src/agent-daemon/protocol-server.ts +++ b/src/agent-daemon/protocol-server.ts @@ -866,11 +866,19 @@ class ProtocolConnection { if (!('id' in request)) { await this.#invokeNotification(request, channel) } else { - await this.#invokeRequest(request, channel) - await this.#sendChannelClose(channel) - this.#channels.close(channel.channelId) - this.#channelKinds.delete(channel.channelId) - return + const completion = this.#invokeRequest(request, channel).then(async () => { + if (this.#closed) return + await this.#sendChannelClose(channel) + this.#channels.close(channel.channelId) + this.#channelKinds.delete(channel.channelId) + }) + if (request.method === 'runtime/startPrompt') { + // Startup holds its admitted request channel (and its existing capacity + // slot), but must leave the socket free for cancellation and peer work. + void completion.catch(error => this.#close(protocolFailureCategory('dispatch', error))) + } else { + await completion + } } } @@ -923,6 +931,7 @@ class ProtocolConnection { controllerTakeoverProven: this.#takeoverProven, signal: this.#abortController.signal }) + if (this.#closed) return if (request.method === 'runtime/resumeAcpChannel') { this.#bindResumedAcpChannel(result) } @@ -941,6 +950,7 @@ class ProtocolConnection { result: result ?? null }) } catch (error) { + if (this.#closed) return const serviceCode = typedErrorCode(error) await this.#sendJson(channel, { jsonrpc: '2.0', diff --git a/src/agent-daemon/runtime-acp-backend.test.ts b/src/agent-daemon/runtime-acp-backend.test.ts index fb83fce2..35ac8fa5 100644 --- a/src/agent-daemon/runtime-acp-backend.test.ts +++ b/src/agent-daemon/runtime-acp-backend.test.ts @@ -518,6 +518,84 @@ function harness(input: { } describe('RuntimeAcpBackend', () => { + it.each(['watchdog', 'dispose'])('interrupts stalled native initialization through %s', async action => { + vi.useFakeTimers() + const fixture = harness({ agentOwned: true, shareOwnedProcesses: true }) + try { + await open(fixture) + await invoke(fixture, 'runtime/preparePrompt', fixture.preparation({ deadlineAt: UNBOUNDED_REMOTE_PROMPT_DEADLINE })) + let settled = false + const starting = invoke(fixture, 'runtime/startPrompt', { + bindingId: 'binding-1', operationId: 'request-1', requestId: 'request-1', prompt: [{ type: 'text', text: 'test' }] + }).catch(() => undefined).finally(() => { settled = true }) + await vi.advanceTimersByTimeAsync(1) + expect(fixture.process.writes).toHaveLength(1) + let disposalError: unknown + const disposal = action === 'dispose' ? fixture.backend.dispose().catch((error: unknown) => { disposalError = error }) : undefined + await vi.advanceTimersByTimeAsync(action === 'watchdog' ? 121000 : 10000) + const observed = { settled, stops: fixture.process.stops.length, disposalError } + const initialize = JSON.parse(Buffer.from(fixture.process.writes[0]!).toString()) + await fixture.process.emit('stdout', `${JSON.stringify({ jsonrpc: '2.0', id: initialize.id, error: { code: -32603, message: 'release fixture' } })}\n`) + await Promise.all([starting, disposal]) + await vi.advanceTimersByTimeAsync(100) + expect(observed).toEqual({ settled: true, stops: 1, disposalError: undefined }) + expect(fixture.process.writes.map(payload => JSON.parse(Buffer.from(payload).toString()).method)).toEqual(['initialize']) + } finally { + vi.useRealTimers() + await fixture.backend.dispose().catch(() => undefined) + } + }) + + it.each(['cancel', 'deadline'])('keeps a shared peer responsive during native startup and %s', async reason => { + const fixture = await ownedHarness(true) + let releaseStartup: (() => Promise) | undefined + try { + await fixture.start() + const process = fixture.processes[0]! + const originalWrite = process.onWrite! + process.onWrite = payload => { + const request = JSON.parse(Buffer.from(payload).toString()) + if (request.method === 'session/new') { + releaseStartup = () => process.emit('stdout', `${JSON.stringify({ jsonrpc: '2.0', id: request.id, result: { sessionId: 'late-session' } })}\n`) + } else originalWrite(payload) + } + const opened = await invoke(fixture, 'runtime/openAcpChannel', { ...fixture.openRequest, bindingId: 'starting' }) as { channelEpoch: string } + vi.useFakeTimers() + await invoke(fixture, 'runtime/preparePrompt', fixture.preparation({ bindingId: 'starting', channelEpoch: opened.channelEpoch, + operationId: 'starting-request', requestId: 'starting-request', + deadlineAt: reason === 'deadline' ? new Date(1100).toISOString() : UNBOUNDED_REMOTE_PROMPT_DEADLINE })) + let startSettled = false + const starting = invoke(fixture, 'runtime/startPrompt', { bindingId: 'starting', operationId: 'starting-request', requestId: 'starting-request', prompt: [{ type: 'text', text: 'test' }] }) + .catch(() => undefined).finally(() => { startSettled = true }) + await vi.advanceTimersByTimeAsync(1) + expect(releaseStartup).toBeDefined() + let peerSettled = false + const peer = invoke(fixture, 'runtime/getAcpCursors', { bindingId: 'binding-1' }).then(() => { peerSettled = true }) + let cancelled: Promise | undefined + if (reason === 'cancel') cancelled = invoke(fixture, 'runtime/escalateCancellation', { + bindingId: 'starting', operationId: 'starting-request', requestId: 'starting-request', sessionId: 'opening', reason: 'requested' + }) + await vi.advanceTimersByTimeAsync(200) + const observed = { peerSettled, startSettled, stops: process.stops.length } + // Release the fake native response even on the old implementation. + await releaseStartup!() + await vi.advanceTimersByTimeAsync(5000) + await Promise.all([starting, peer, cancelled]) + expect(observed).toEqual({ peerSettled: true, startSettled: true, stops: 0 }) + expect(fixture.transcript.attach('binding-1', 'request-1', fixture.context.controller.controllerId).state).toBe('running') + const writes = process.writes.map(payload => JSON.parse(Buffer.from(payload).toString())) + expect(writes.filter(request => request.method === 'session/prompt')).toHaveLength(1) + expect(writes).toContainEqual(expect.objectContaining({ method: 'session/close', params: { sessionId: 'late-session' } })) + await fixture.prompts[0]!() + await vi.advanceTimersByTimeAsync(1) + } finally { + await releaseStartup?.() + await fixture.prompts[0]?.() + vi.useRealTimers() + await fixture.cleanup() + } + }) + it.each([false, true])('commits completion before idle cleanup and preserves it on cleanup failure (shared=%s)', async shared => { for (const failure of ['stop', 'reconcile'] as const) { const fixture = await ownedHarness(shared) diff --git a/src/agent-daemon/runtime-acp-backend.ts b/src/agent-daemon/runtime-acp-backend.ts index 16ca0b45..2ff57ce0 100644 --- a/src/agent-daemon/runtime-acp-backend.ts +++ b/src/agent-daemon/runtime-acp-backend.ts @@ -440,7 +440,8 @@ export class RuntimeAcpBackend { 'runtime/preparePrompt': (params, context) => this.#enqueuePublicControl(() => this.#prepare(params, context)), 'runtime/startPrompt': (params, context) => - this.#enqueuePublicControl(() => this.#startOwnedPrompt(params, context)), + this.#enqueuePublicControl(() => this.#startOwnedPrompt(params, context)) + .then(({ started }) => started), 'runtime/attachPrompt': (params, context) => this.#enqueuePublicControl(() => this.#attachOwnedPrompt(params, context)), 'runtime/respondToQuestion': (params, context) => @@ -1441,7 +1442,7 @@ export class RuntimeAcpBackend { return completion } - async #startOwnedPrompt( + #startOwnedPrompt( params: unknown, context: ProtocolMethodContext ) { @@ -1464,7 +1465,7 @@ export class RuntimeAcpBackend { clearTimeout(binding.ownedPromptStartTimer) binding.ownedPromptStartTimer = undefined } - return existing + return { started: Promise.resolve(existing) } } if ( this.#options.semanticPrompts === undefined || @@ -1523,13 +1524,20 @@ export class RuntimeAcpBackend { binding.poisoned ? 'outcome-unknown' : status }) } - const result = await binding.ownedAcp.start(request) - prepared.ownedPromptStarted = true - if (binding.ownedPromptStartTimer !== undefined) { - clearTimeout(binding.ownedPromptStartTimer) - binding.ownedPromptStartTimer = undefined - } - return result + const owner = binding.ownedAcp + // Native initialization must not hold the global control queue: cancellation, + // watchdogs and other bindings still need to make progress while it waits. + const started = owner.start(request).then(result => { + if (binding.ownedAcp === owner && binding.activeOperationId === request.operationId) { + prepared.ownedPromptStarted = true + if (binding.ownedPromptStartTimer !== undefined) { + clearTimeout(binding.ownedPromptStartTimer) + binding.ownedPromptStartTimer = undefined + } + } + return result + }) + return { started } } #attachOwnedPrompt( diff --git a/src/main/agent/opencode-runtime.test.ts b/src/main/agent/opencode-runtime.test.ts index 8b236c20..c175a83f 100644 --- a/src/main/agent/opencode-runtime.test.ts +++ b/src/main/agent/opencode-runtime.test.ts @@ -1,4 +1,5 @@ import { EventEmitter } from "node:events"; +import { execFileSync } from "node:child_process"; import { createHash } from "node:crypto"; import { createServer } from "node:http"; import { @@ -263,6 +264,16 @@ function runClient(events: Record[]) { }; } +function stalledFetch(request: Request): Promise { + return new Promise((_resolve, reject) => { + // Like a live fetch, retain the Request until cancellation. Keeping only its + // signal lets Undici's weakly referenced controller disappear during GC. + const abort = (): void => reject(request.signal.reason); + if (request.signal.aborted) abort(); + else request.signal.addEventListener("abort", abort, { once: true }); + }); +} + function embeddedRuntime( client: ReturnType, overrides: Partial[0]> = {}, @@ -3323,6 +3334,35 @@ describe("OpenCodeRuntime embedded permission mediation", () => { } }); + it("retains a stalled SDK fetch until abort even under garbage collection", () => { + const output = execFileSync(process.execPath, ["--expose-gc", "--input-type=module", "-e", ` + import { createOpencodeClient } from '@opencode-ai/sdk/v2'; + import { setImmediate as turn } from 'node:timers/promises'; + const stalledFetch = ${stalledFetch.toString()}; + const source = new AbortController(); + let requestSignal, requestRef, result; + const sdk = createOpencodeClient({ baseUrl: 'http://127.0.0.1:1', throwOnError: false, + fetch: request => { + requestSignal = request.signal; + requestRef = new WeakRef(request); + return stalledFetch(request); + } + }); + const operation = sdk.mcp.disconnect({ name: 'gc-fixture' }, { signal: source.signal }); + operation.then(value => { result = value; }); + while (!requestSignal) await turn(); + for (let i = 0; i < 10; i++) { await turn(); gc(); await turn(); } + const requestRetained = Boolean(requestRef.deref()); + source.abort(new Error('controlled deadline')); + await turn(); + console.log(JSON.stringify({ requestRetained, sourceAborted: source.signal.aborted, + requestAborted: requestSignal.aborted, sdkSettledWithError: Boolean(result?.error) })); + `], { encoding: "utf8", timeout: 4_000 }); + expect(JSON.parse(output)).toEqual({ + requestRetained: true, sourceAborted: true, requestAborted: true, sdkSettledWithError: true, + }); + }); + it.each(["recovered", "exhausted", "cleanup-false", "cleanup-transport", "cleanup-timeout", "http-401", "http-503", "unknown"])( "handles installed SDK fetch results: %s", async (scenario) => { const setup = runClient([ @@ -3344,7 +3384,7 @@ describe("OpenCodeRuntime embedded permission mediation", () => { order.push("disconnect"); signals.push(request.signal); if (scenario === "cleanup-transport") throw failure; - if (scenario === "cleanup-timeout") return new Promise(() => {}); + if (scenario === "cleanup-timeout") return stalledFetch(request); return Response.json(scenario !== "cleanup-false"); } order.push("add"); @@ -4680,6 +4720,53 @@ describe("OpenCodeRuntime embedded permission mediation", () => { await runtime.dispose(); }); + it.each(["rejection", "error-result", "timeout"])("settles failed submission with silent SSE: %s", async (failure) => { + vi.useFakeTimers(); + const setup = runClient([]); + const controller = new AbortController(); + let readerClosed = false; + vi.mocked(setup.event.subscribe).mockImplementation(async (_input, options) => ({ + stream: (async function* () { + try { + yield* []; + await new Promise((resolve) => { + if (options?.signal?.aborted) resolve(); + else options?.signal?.addEventListener("abort", () => resolve(), { once: true }); + }); + } finally { readerClosed = true; } + })(), + }) as never); + vi.mocked(setup.session.promptAsync).mockImplementation(async () => { + if (failure === "timeout") return await new Promise(() => {}); + if (failure === "error-result") return { error: { data: { message: "submission rejected" } } } as never; + throw new Error("submission rejected"); + }); + const runtime = new OpenCodeRuntime(options({ embedded: false, baseUrl: "http://127.0.0.1:4096" }), { + createClient: () => setup.client, controlRequestTimeoutMs: 10, + }); + let settled = false; + let error: unknown; + const pending = (async () => { + for await (const event of runtime.run({ requestId: "silent", conversationId: "silent", prompt: "test" }, controller.signal)) { void event; } + })().catch((failure: unknown) => { error = failure; }).finally(() => { settled = true; }); + try { + await vi.advanceTimersByTimeAsync(100); + expect(settled).toBe(true); + expect(error).toBeInstanceOf(Error); + expect((error as Error).message).toBe(failure === "timeout" ? "OpenCode 提交请求超时" : "submission rejected"); + expect(setup.session.promptAsync).toHaveBeenCalledOnce(); + expect(setup.session.abort).toHaveBeenCalledOnce(); + expect(readerClosed).toBe(true); + expect(controller.signal.aborted).toBe(false); + } finally { + controller.abort(); + await vi.advanceTimersByTimeAsync(20); + await pending; + await runtime.dispose(); + vi.useRealTimers(); + } + }); + it("surfaces a rejected async prompt instead of reporting success", async () => { const { client, session } = runClient([ { diff --git a/src/main/agent/opencode-runtime.ts b/src/main/agent/opencode-runtime.ts index b3abc857..490adf79 100644 --- a/src/main/agent/opencode-runtime.ts +++ b/src/main/agent/opencode-runtime.ts @@ -2559,6 +2559,7 @@ export class OpenCodeRuntime implements AgentRuntime { const submittedChecklistCalls = new Set(); const pendingChecklistUpdates: RuntimeChecklist[] = []; let runFailed = false; + let submissionFailure: { error: unknown } | undefined; try { const promptText = promptWithUntrustedConversationHistory( request, @@ -2643,7 +2644,15 @@ export class OpenCodeRuntime implements AgentRuntime { ), signal, ); - prompt.catch(() => undefined); + void prompt.then((result) => { + if (result.error) { + throw new Error(opencodeErrorMessage(result.error, "OpenCode 提交请求失败")); + } + }).catch((error: unknown) => { + submissionFailure = { error }; + // Wake a silent SSE reader without cancelling any peer subscription. + subscriptionController.abort(error); + }); const repliedPermissionIds = new Set(); const ownedSessions = new Set([sessionId]); @@ -3174,7 +3183,7 @@ export class OpenCodeRuntime implements AgentRuntime { } } } - throw error; + throw submissionFailure?.error ?? error; } finally { signal.removeEventListener("abort", abortSession); // Keep the conversation locked until its session-wide abort settles. diff --git a/src/main/assistant/assistant-database.ts b/src/main/assistant/assistant-database.ts index 3e7460e6..9385463b 100644 --- a/src/main/assistant/assistant-database.ts +++ b/src/main/assistant/assistant-database.ts @@ -12378,11 +12378,11 @@ export class AssistantDatabase { return { summary: this.reviewSummary(request.scope, 'supervisor'), background: this.reviewBackground(request.scope) } } - async supervisionCandidates(request: SupervisionRunRequest): Promise> { + async supervisionCandidates(request: SupervisionRunRequest, batch?: { projectId: string; crossProject: boolean }): Promise> { const reader = this.readonlyQueryReader() - if (reader) return reader.call('reviewCandidates', [request]) + if (reader) return reader.call('reviewCandidates', [request, batch]) if (this.databasePath !== ':memory:') throw new Error('Supervision worker is not configured') - return this.listSupervisionCandidates(request) + return this.listSupervisionCandidates(request, batch) } supervisionSuggestions(): SupervisionSuggestionStore { diff --git a/src/main/assistant/supervision-production.test.ts b/src/main/assistant/supervision-production.test.ts index dfbe0262..61d0eadf 100644 --- a/src/main/assistant/supervision-production.test.ts +++ b/src/main/assistant/supervision-production.test.ts @@ -211,7 +211,17 @@ function fixture(content = 'Atlas and Beacon are separate projects.') { const service = (responseKiB = 1024) => createProductionSupervisorService(db, async () => ({ supervisorModelConcurrency: 1, supervisionReview: { pageSize: 10, batchCharacters: 1000, batchMessages: 10, executionSeconds: 300, responseKiB } }), async () => ({ runtimeId: 'model', capability: 'chat', run } as AgentRuntime), pool) - return { db, request, respond, service } + // Project ownership comes from timeline events, not the scope that first saved an entity. + const associateCandidates = () => { + const sql = (db as unknown as { requireDatabase(): DatabaseSync }).requireDatabase() + const id = randomUUID() + sql.prepare(`INSERT INTO supervision_events (id, story_line_id, result_id, occurred_at, title, description, event_type, project_id) + SELECT ?, story_lines.id, supervision_results.id, ?, 'Seed ownership', '', 'discussion', ? + FROM story_lines, supervision_results LIMIT 1`).run(id, request.timeRange.from, db.listProjects()[0]!.id) + sql.prepare('INSERT INTO supervision_event_entities (event_id, entity_id, change_type) SELECT ?, id, ? FROM supervision_entities').run(id, 'associated') + sql.prepare('INSERT INTO supervision_story_assigned VALUES (?)').run(id) + } + return { db, request, respond, service, associateCandidates } } it('records supervisor model usage under a hidden supervision task', async () => { @@ -237,6 +247,7 @@ it('production factory normalizes two absent identities, then reuses exact candi const first = await f.service().run(f.request) expect(first.output.entities).toHaveLength(2) for (const entity of first.output.entities) expect(entity).not.toHaveProperty('persistedId') + f.associateCandidates() const candidates = f.db.listSupervisionCandidates(f.request) expect(candidates).toHaveLength(2) expect(f.respond.mock.calls[0]![0]).toContain('KNOWN ENTITIES is empty. Every entity is new; omit candidateRef') @@ -256,6 +267,7 @@ it('retains candidate mappings across leaf batches, failed merge and resume with const f = fixture('Atlas and Beacon. '.repeat(100)) f.db.saveSupervisionResult({ request: f.request, evidence: [], output: { ...empty, entities: ['Atlas', 'Beacon'].map(label => ({ id: label, label, description: label, sourceReferenceIds: [] })) } }) + f.associateCandidates() const candidates = f.db.listSupervisionCandidates(f.request) const original = f.respond.getMockImplementation()! let failMerge = true @@ -346,6 +358,7 @@ it('reuses actual legacy text-key shapes through strict UUID aliases and preserv sql.prepare(`INSERT INTO supervision_entities (id, story_line_id, canonical_label, description, confirmation_state, updated_at, source_reference_ids_json) VALUES (?, ?, ?, 'Human description', 'confirmed', ?, '[]')`).run(id, String(story.id), id, new Date().toISOString()) } + f.associateCandidates() const candidates = f.db.listSupervisionCandidates(f.request) const legacy = candidates.filter(candidate => candidate.storageId) expect(legacy).toHaveLength(2) diff --git a/src/main/assistant/supervision-production.ts b/src/main/assistant/supervision-production.ts index a3d0285a..dd205a01 100644 --- a/src/main/assistant/supervision-production.ts +++ b/src/main/assistant/supervision-production.ts @@ -126,7 +126,7 @@ export function createProductionSupervisorService( background: request => database.reviewBackground(request.scope), start: (request, heartbeatRunId) => database.startSupervisionRun(request, heartbeatRunId), fail: (runId, error) => database.failSupervisionRun(runId, error), noChange: runId => database.noChangeSupervisionRun(runId), - candidates: request => database.supervisionCandidates(request), save: async result => database.saveSupervisionResult(result) + candidates: (request, batch) => database.supervisionCandidates(request, batch), save: async result => database.saveSupervisionResult(result) }, { database: () => database.supervisionReviewStore(), initialize: (runId, state, signal) => database.initializeSupervisionReview(runId, state, signal), resume: (runId, signal) => database.resumeSupervisionReview(runId, signal), diff --git a/src/main/assistant/supervision-stories.test.ts b/src/main/assistant/supervision-stories.test.ts index 0f6b7213..dc82e1d1 100644 --- a/src/main/assistant/supervision-stories.test.ts +++ b/src/main/assistant/supervision-stories.test.ts @@ -247,3 +247,37 @@ it('runs after publication inside the review and records failure without failing expect(f.stories.list({ kind: 'global' }).map(story => story.name)).toEqual(['Timeline']) await expect(service.run(request)).resolves.toMatchObject({ status: 'no_change', coverage: { stories: { status: 'completed', calls: 0 } } }) }) + +it.each(['remove', 'merge'] as const)('rejects an assignment whose selected story was changed by %s while awaiting the model', async action => { + const f = await fixture() + f.publish(f.a.id, ['First', 'Second']) + await assignStories(f.stories, answer({ stories: [{ key: 'new_1', level: 'feature', name: 'Alpha' }, { key: 'new_2', level: 'feature', name: 'Beta' }], + assignments: [{ event: 'e_1', story: 'new_1' }, { event: 'e_2', story: 'new_2' }] }), { kind: 'global' }, options) + const [alpha, beta] = f.stories.list({ kind: 'global' }) + f.publish(f.a.id, ['New evidence'], 22) + const model = async () => { + f.stories.act(action === 'remove' ? { action, storyId: alpha!.id } : { action, storyId: alpha!.id, intoId: beta!.id }) + return JSON.stringify({ stories: [{ key: 'new_1', level: 'feature', name: 'Must roll back' }], assignments: [{ event: 'e_1', story: 'story_1' }] }) + } + await expect(assignStories(f.stories, model, { kind: 'global' }, options)).rejects.toThrow('Story changed') + expect(f.stories.pending({ kind: 'global' })).toHaveLength(1) + expect(f.stories.list({ kind: 'global' }).map(story => story.name)).toEqual(['Beta']) + expect(f.sql.prepare("SELECT COUNT(*) AS n FROM supervision_event_stories es JOIN supervision_stories s ON s.id = es.story_id WHERE s.status != 'current'").get()!.n).toBe(0) + await assignStories(f.stories, answer({ assignments: [{ event: 'e_1', story: 'story_1' }] }), { kind: 'global' }, { ...options, threadEvents: 100 }) + expect(f.stories.pending({ kind: 'global' })).toEqual([]) + expect(f.sql.prepare('PRAGMA foreign_key_check').all()).toEqual([]) +}) + +it.each(['feature', 'thread'] as const)('rejects a split after its %s was removed without leaving new threads', async removed => { + const f = await fixture() + f.publish(f.a.id, ['One', 'Two', 'Three', 'Four']) + await assignStories(f.stories, answer({ stories: [{ key: 'new_1', level: 'feature', name: 'Feature' }, { key: 'new_2', level: 'thread', name: 'Thread', parent: 'new_1' }], + assignments: [1, 2, 3, 4].map(n => ({ event: `e_${n}`, story: n === 4 ? 'new_2' : 'new_1' })) }), { kind: 'global' }, { ...options, threadEvents: 4 }) + const [feature, thread] = f.stories.list({ kind: 'global' }) + const input = f.stories.splitInput(feature!.id, 8000) + f.stories.act({ action: 'remove', storyId: removed === 'feature' ? feature!.id : thread!.id }) + const before = f.stories.list({ kind: 'global' }) + expect(() => f.stories.split(input, { threads: [], moves: [{ event: 'e_1', thread: 'thread_1' }] }, 4)).toThrow('Story changed') + expect(f.stories.list({ kind: 'global' })).toEqual(before) + expect(f.sql.prepare('PRAGMA foreign_key_check').all()).toEqual([]) +}) diff --git a/src/main/assistant/supervision-stories.ts b/src/main/assistant/supervision-stories.ts index 89db537f..541e23bb 100644 --- a/src/main/assistant/supervision-stories.ts +++ b/src/main/assistant/supervision-stories.ts @@ -236,6 +236,7 @@ export class SupervisionStoryStore { VALUES (?, ?, ?, 'thread', ?, ?, ?, ?)`).run(id, String(input.feature.project_id), featureId, thread.name, thread.description, now, now) ids.set(thread.key, id); existing.set(name, id); created++ } + this.assertCurrent([featureId, ...output.moves.flatMap(item => ids.get(item.thread) ?? [])]) const move = this.db.prepare('UPDATE supervision_event_stories SET story_id = ? WHERE event_id = ? AND story_id = ? AND is_primary = 1 AND user_set = 0') for (const item of output.moves) { const id = ids.get(item.thread) @@ -252,6 +253,14 @@ export class SupervisionStoryStore { .map(row => `${row.parent}|${nameKey(String(row.name))}`)) } + /** Check model-selected destinations under the caller's write transaction. */ + private assertCurrent(ids: string[]): void { + const unique = [...new Set(ids)] + const count = this.db.prepare(`SELECT COUNT(*) AS n FROM supervision_stories + WHERE id IN (SELECT value FROM json_each(?)) AND status = 'current'`).get(JSON.stringify(unique))!.n + if (Number(count) !== unique.length) throw new Error('Story changed while organizing; retry with current stories') + } + /** Applies one validated model answer for one project chunk in a single transaction. */ apply(projectId: string, events: PendingEvent[], candidates: Row[], output: z.infer, options: Pick, others: Row[] = []): Omit { const eventRefs = new Map(events.map((event, index) => [`e_${index + 1}`, event])) @@ -336,6 +345,12 @@ export class SupervisionStoryStore { ids.set(story.key, id) created++ } + const selected = [ + ...output.assignments.flatMap(item => [item.story, ...(item.related ?? [])]), + ...output.stories.map(item => item.parent), ...output.concluded.map(item => item.story), + ...output.links.flatMap(item => [item.cross, item.feature]) + ] + this.assertCurrent(selected.flatMap(ref => ref ? ids.get(ref) ?? (otherRefs.has(ref) ? String(otherRefs.get(ref)!.id) : []) : [])) const link = this.db.prepare('INSERT OR IGNORE INTO supervision_event_stories (event_id, story_id, is_primary) VALUES (?, ?, ?)') const mark = this.db.prepare('INSERT OR IGNORE INTO supervision_story_assigned (event_id) VALUES (?)') let assigned = 0, unassigned = 0 diff --git a/src/main/assistant/supervision-timeline.ts b/src/main/assistant/supervision-timeline.ts index 15cad8eb..f47823bf 100644 --- a/src/main/assistant/supervision-timeline.ts +++ b/src/main/assistant/supervision-timeline.ts @@ -73,9 +73,9 @@ export function supersedeReextractedEvents(db: DatabaseSync, resultId: string): */ export function timelineCandidates(db: DatabaseSync, projectId: string, crossProject: boolean): Array<{ id: string; label: string; description: string }> { return db.prepare(`SELECT e.id, e.canonical_label AS label, e.description FROM supervision_entities e - WHERE e.confirmation_state != 'revoked' AND (? = 1 OR EXISTS ( - SELECT 1 FROM supervision_event_entities ee JOIN supervision_events ev ON ev.id = ee.event_id - WHERE ee.entity_id = e.id AND ev.superseded_by IS NULL AND COALESCE(ev.project_id, '') = ?)) + WHERE e.confirmation_state != 'revoked' AND (? = 1 OR e.id IN ( + SELECT ee.entity_id FROM supervision_events ev JOIN supervision_event_entities ee ON ee.event_id = ev.id + WHERE ev.superseded_by IS NULL AND COALESCE(ev.project_id, '') = ?)) ORDER BY e.updated_at DESC, e.id LIMIT 100`).all(crossProject ? 1 : 0, projectId) as Array<{ id: string; label: string; description: string }> } diff --git a/src/main/assistant/supervision-worker.test.ts b/src/main/assistant/supervision-worker.test.ts index 5847c5ac..9f6f6e48 100644 --- a/src/main/assistant/supervision-worker.test.ts +++ b/src/main/assistant/supervision-worker.test.ts @@ -157,6 +157,76 @@ it('publishes full worker-selected coverage atomically and skips it on the next expect(calls).toBe(before) }) +it.each([false, true])('production worker preserves batch candidate isolation with crossProject=%s', async crossProject => { + const f = await fixture() + const empty = { summary: 'Seed', changeDigest: '', openItems: [], events: [], entities: [], entityChanges: [], relations: [] } + const projects = f.db.listProjects() + for (const project of projects) f.db.saveSupervisionResult({ + request: { ...f.state.request, scope: project.id === f.project.id ? { kind: 'global' } : { kind: 'projects', projectIds: [project.id] } }, + evidence: [{ id: 's', sourceType: 'conversation', sourceId: randomUUID(), title: 'Seed', content: 'Seed', + occurredAt: f.state.request.timeRange.from, locator: { projectId: project.id } }], + output: { ...empty, entities: [{ id: 'entity', label: project.id, description: 'Owned knowledge', sourceReferenceIds: ['s'] }], + events: [{ title: 'Seed', description: '', occurredAt: f.state.request.timeRange.from, eventType: 'discussion', entityIds: ['entity'], sourceReferenceIds: ['s'] }] } + }) + f.sql.exec('INSERT INTO supervision_story_assigned SELECT id FROM supervision_events') + const pool = new SupervisionModelPool() + cleanup.push(async () => pool.dispose()) + const seen = new Set() + const runtime = { async *run(input: { prompt: string }) { + if (!input.prompt.includes('These inputs are navigation summaries')) { + const evidence = JSON.parse(input.prompt.split('BOUNDED EVIDENCE:\n\n')[1]!.split('\n\nReturn only JSON.')[0]!) as Array<{ locator: { projectId: string } }> + const projectId = evidence[0]!.locator.projectId + const candidates = JSON.parse(input.prompt.split('KNOWN ENTITIES:\n\n')[1]!.split('\n\nPREVIOUS SUMMARY')[0]!) as Array<{ label: string }> + expect(candidates.map(item => item.label).sort()).toEqual((crossProject ? projects.map(p => p.id) : [projectId]).sort()) + seen.add(projectId) + } + yield { type: 'text', delta: JSON.stringify(empty) } + yield { type: 'done' } + }, async dispose() {} } as unknown as AgentRuntime + const service = createProductionSupervisorService(f.db, async () => ({ supervisionReview: supervisionReviewSettingsSchema.parse({ crossProject }) }), async () => runtime, pool) + await expect(service.run(f.state.request)).resolves.toMatchObject({ status: 'completed', coverage: { sources: 422 } }) + expect([...seen].sort()).toEqual(projects.map(p => p.id).sort()) + expect(f.sql.prepare('SELECT COUNT(*) AS n FROM supervision_entities').get()!.n).toBe(2) + expect(f.sql.prepare('PRAGMA foreign_key_check').all()).toEqual([]) +}) + +it.each(['paused', 'cancelled'] as const)('honors %s during the final production candidate read before publishing', async action => { + const f = await fixture() + const pool = new SupervisionModelPool() + cleanup.push(async () => pool.dispose()) + const runtime = { async *run() { + yield { type: 'text', delta: JSON.stringify({ summary: 'Saved leaf', changeDigest: '', openItems: [], events: [], entities: [], entityChanges: [], relations: [] }) } + yield { type: 'done' } + }, async dispose() {} } as unknown as AgentRuntime + const service = createProductionSupervisorService(f.db, async () => ({}), async () => runtime, pool) + const candidates = f.db.supervisionCandidates.bind(f.db) + let stopped = false + let cancellation: Promise | undefined + vi.spyOn(f.db, 'supervisionCandidates').mockImplementation(async (...args) => { + const result = await candidates(...args) + const runId = service.execution().runId! + if (!stopped && f.db.supervisionReviewStore().progress(runId).phase === 'saving') { + stopped = true + if (action === 'paused') service.pause(runId) + else cancellation = service.cancel(runId) + } + return result + }) + const result = await service.run(f.state.request) + await cancellation + expect(stopped).toBe(true) + expect(result.status).toBe(action) + expect(f.db.listSupervisionResults()).toEqual([]) + expect(f.sql.prepare('SELECT COUNT(*) AS n FROM review_checkpoints').get()!.n).toBe(0) + const saved = f.db.supervisionReviewStore().batches(result.runId!, 1000) + expect(saved.length).toBeGreaterThan(0) + if (action === 'paused') { + await expect(service.resume(result.runId!)).resolves.toMatchObject({ status: 'completed' }) + expect(f.db.supervisionReviewStore().batches(result.runId!, 1000)).toEqual(saved) + expect(f.sql.prepare('SELECT COUNT(*) AS n FROM review_checkpoints').get()!.n).toBe(422) + } +}) + it.each(['crash', 'close'] as const)('settles writer %s before releasing pending work and can rebuild after reopening', async action => { const f = await fixture() const id = f.db.startSupervisionRun(f.state.request) diff --git a/src/main/assistant/supervisor-service.ts b/src/main/assistant/supervisor-service.ts index 37e0dc59..626f3c57 100644 --- a/src/main/assistant/supervisor-service.ts +++ b/src/main/assistant/supervisor-service.ts @@ -487,6 +487,7 @@ export class SupervisorService { for (const batch of saved) savedProjects.add(batch.projectId) } for (const projectId of savedProjects) await candidatesFor(projectId) + controller.signal.throwIfAborted() const result: StoredSupervisionResult = { runId, request, candidates: [...offered.values()], evidence: progress.batches === 1 ? first!.evidence : [], output: root!.output, status: 'completed', coverage: { ...db.progress(runId), complete: true } } diff --git a/src/main/channels/dingtalk-channel-driver.test.ts b/src/main/channels/dingtalk-channel-driver.test.ts index b9a4c748..6a3d4243 100644 --- a/src/main/channels/dingtalk-channel-driver.test.ts +++ b/src/main/channels/dingtalk-channel-driver.test.ts @@ -1,4 +1,8 @@ import { describe, expect, it, vi } from 'vitest' +import { createServer } from 'node:http' +import { AssistantDatabase } from '../assistant/assistant-database' +import { ChannelService } from './channel-service' +import { SqliteChannelOutbox } from './sqlite-channel-state' import { DingTalkChannelDriver, createOfficialDingTalkTransportFactory @@ -46,6 +50,65 @@ function envelope( } describe('DingTalkChannelDriver', () => { + it('retries an HTTP 200 business failure through the production transport and SQLite outbox', async () => { + const requests: string[] = [] + const server = createServer(async (request, response) => { + let body = '' + for await (const chunk of request) body += String(chunk) + requests.push(body) + response.setHeader('content-type', 'application/json') + response.end(JSON.stringify(requests.length === 1 + ? { errcode: 130101, errmsg: 'synthetic rate limit' } + : { errcode: 0, errmsg: 'ok' })) + }) + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)) + const address = server.address() as { port: number } + const database = new AssistantDatabase(':memory:') + database.initialize('C:\\Workspace') + const outbox = new SqliteChannelOutbox(database) + let receive!: (message: { headers: { messageId: string }; data: string }) => void + const driver = new DingTalkChannelDriver({ + clientId: 'client-id', clientSecret: 'synthetic-secret', allowedSenderIds: ['user-1'], + transportFactory: createOfficialDingTalkTransportFactory({ + clientFactory: async () => ({ + registerCallbackListener: (_topic, listener) => { receive = listener }, + socketCallBackResponse: () => undefined, + connect: async () => undefined, disconnect: () => undefined + }), + fetchImpl: (_input, init) => fetch(`http://127.0.0.1:${address.port}/reply`, init) + }) + }) + const executor = vi.fn(async () => ({ status: 'completed', output: 'synthetic reply' })) + const failure = vi.fn() + const service = new ChannelService(driver, executor, { + allowedSenderIds: ['user-1'], allowGroupMessages: true, outbox, onDeliveryFailure: failure + }) + try { + await service.start() + receive(envelope()) + await vi.waitFor(() => expect(failure).toHaveBeenCalledOnce()) + expect(failure.mock.calls[0]?.[0]).toMatchObject({ message: '钉钉回复请求失败 (130101)' }) + expect(outbox.listUndelivered()[0]).toMatchObject({ state: 'failed', attempts: 1 }) + await vi.waitFor(() => expect(requests).toHaveLength(2), { timeout: 3000 }) + await vi.waitFor(() => expect(outbox.listUndelivered()).toEqual([])) + expect(requests[1]).toBe(requests[0]) + expect(executor).toHaveBeenCalledOnce() + } finally { + await service.stop() + database.close() + await new Promise((resolve, reject) => server.close(error => error ? reject(error) : resolve())) + } + }) + + it.each(['', '{}', 'null', 'not JSON', JSON.stringify({ errcode: 0, padding: 'x'.repeat(65_536) })])( + 'rejects invalid or oversized successful HTTP responses (%#)', async (body) => { + const transport = await createOfficialDingTalkTransportFactory({ + fetchImpl: async () => new Response(body) + }).create({ clientId: 'synthetic', clientSecret: 'synthetic' }) + await expect(transport.replyText(SESSION_WEBHOOK, 'reply')).rejects.toThrow() + } + ) + it('adapts group text and consumes only the issued reply context', async () => { const transport = new FakeTransport() const factory: DingTalkTransportFactory = { @@ -133,7 +196,7 @@ describe('DingTalkChannelDriver', () => { connect: vi.fn(async () => undefined), disconnect: vi.fn() } - const fetchImpl = vi.fn(async () => new Response(null, { status: 200 })) + const fetchImpl = vi.fn(async () => Response.json({ errcode: 0, errmsg: 'ok' })) const factory = createOfficialDingTalkTransportFactory({ clientFactory: async (credentials) => { expect(credentials).toEqual({ diff --git a/src/main/channels/dingtalk-channel-driver.ts b/src/main/channels/dingtalk-channel-driver.ts index d7691ece..d5c75b3f 100644 --- a/src/main/channels/dingtalk-channel-driver.ts +++ b/src/main/channels/dingtalk-channel-driver.ts @@ -3,6 +3,7 @@ import type { ChannelResultMessage } from '../../shared/channel-contracts' import type { ChannelDriver, ChannelInboundHandler } from './channel-driver' +import { readBoundedResponseText } from '../../shared/node/bounded-response' import { DingTalkDriver, type DingTalkStreamEnvelope, @@ -133,6 +134,7 @@ class OfficialDingTalkTransport implements DingTalkStreamTransport { const timeout = setTimeout(() => { controller.abort(new Error('钉钉回复超时')) }, REPLY_TIMEOUT_MS) + let result: unknown try { const response = await this.fetchImpl(sessionWebhook, { method: 'POST', @@ -144,22 +146,26 @@ class OfficialDingTalkTransport implements DingTalkStreamTransport { redirect: 'error', signal: controller.signal }) - const responseLength = Number( - response.headers.get('content-length') ?? '0' - ) - if ( - !response.ok || - !Number.isFinite(responseLength) || - responseLength > MAXIMUM_RESPONSE_BYTES - ) { + if (!response.ok) { + await response.body?.cancel() throw new Error('钉钉回复请求失败') } - await response.body?.cancel() + result = JSON.parse(await readBoundedResponseText(response, { + maxBytes: MAXIMUM_RESPONSE_BYTES, + tooLargeMessage: '钉钉回复响应过大', + truncatedMessage: '钉钉回复响应不完整' + })) } catch { throw new Error('钉钉回复请求失败') } finally { clearTimeout(timeout) } + if (typeof result !== 'object' || result === null || !('errcode' in result) || result.errcode !== 0) { + const code = typeof result === 'object' && result !== null && 'errcode' in result && + typeof result.errcode === 'number' && Number.isSafeInteger(result.errcode) + ? ` (${result.errcode})` : '' + throw new Error(`钉钉回复请求失败${code}`) + } } } @@ -245,12 +251,8 @@ export class DingTalkChannelDriver implements ChannelDriver { throw new Error('钉钉回复上下文无效或已过期') } - try { - signal.throwIfAborted() - await this.driver.reply(record.context, resultText(message)) - } catch { - throw new Error('钉钉消息回复失败') - } + signal.throwIfAborted() + await this.driver.reply(record.context, resultText(message)) if (!message.attachments?.length) { this.replyContexts.delete(message.eventId) } diff --git a/src/main/ipc.test.ts b/src/main/ipc.test.ts index 9dda74f3..f3db9f9d 100644 --- a/src/main/ipc.test.ts +++ b/src/main/ipc.test.ts @@ -36,6 +36,9 @@ import type { import { defaultKnowledgeOntologySettings } from '../shared/knowledge-ontology' import { AssistantDatabase } from './assistant/assistant-database' import { ChannelSettingsStore } from './channels/channel-settings-store' +import { ChannelService } from './channels/channel-service' +import { SqliteChannelOutbox } from './channels/sqlite-channel-state' +import type { ChannelDriver, ChannelInboundHandler } from './channels/channel-driver' import { SubagentService, createSubagentRuntime } from './assistant/subagent-service' import { ConversationAttachmentStorage } from './conversation-attachment-storage' import { DocumentResultStorage } from './document-result-storage' @@ -12583,8 +12586,20 @@ describe('registerIpcHandlers agent terminal state', () => { throw new Error('Expected channel executor') } - await expect( - executor( + const outboxDatabase = new AssistantDatabase(':memory:') + outboxDatabase.initialize('C:\\Workspace') + const outbox = new SqliteChannelOutbox(outboxDatabase) + let inbound!: ChannelInboundHandler + const send = vi.fn(async () => undefined) + const service = new ChannelService({ + channel: 'wecom', + start: (handler) => { inbound = handler }, + send, + stop: () => undefined + }, executor, { allowedSenderIds: ['user-1'], outbox }) + try { + await service.start() + await inbound( { channel: 'wecom', eventId: 'event-generated-image', @@ -12594,34 +12609,38 @@ describe('registerIpcHandlers agent terminal state', () => { text: '生成结果图', mentioned: false }, - new AbortController().signal + () => undefined ) - ).resolves.toMatchObject({ - status: 'completed', - attachments: [ - { - name: '结果图.png', - mimeType: 'image/png', - size: image.byteLength, - kind: 'image', - dataBase64: image.toString('base64') - } - ], - artifactIds: [ - '00000000-0000-4000-8000-000000000499' - ] - }) - expect( - harness.assistantDatabase.appendRemoteConversationMessage - ).toHaveBeenLastCalledWith( - expect.objectContaining({ - role: 'assistant', - artifactIds: [ - '00000000-0000-4000-8000-000000000499' + await vi.waitFor(() => expect(send).toHaveBeenCalledOnce()) + expect(send.mock.calls[0]?.[0]).toMatchObject({ + status: 'completed', + attachments: [ + { + name: '结果图.png', + mimeType: 'image/png', + size: image.byteLength, + kind: 'image', + dataBase64: image.toString('base64') + } ] }) - ) - await harness.dispose() + expect(send.mock.calls[0]?.[0]).not.toHaveProperty('artifactIds') + expect(outbox.listUndelivered()).toEqual([]) + expect( + harness.assistantDatabase.appendRemoteConversationMessage + ).toHaveBeenLastCalledWith( + expect.objectContaining({ + role: 'assistant', + artifactIds: [ + '00000000-0000-4000-8000-000000000499' + ] + }) + ) + } finally { + await service.stop() + outboxDatabase.close() + await harness.dispose() + } }) it('creates a bounded result file only when the remote user explicitly requests one', async () => { diff --git a/src/main/ipc.ts b/src/main/ipc.ts index 249f502f..edfe42df 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -3056,7 +3056,8 @@ export function registerIpcHandlers( status: result.status === 'completed' ? 'completed' : 'failed' }) - return result + const { status, output, error, attachments } = result + return { status, output, error, attachments } } finally { releaseRemoteContexts() } diff --git a/src/main/readonly-query-worker.ts b/src/main/readonly-query-worker.ts index 7e536bba..2ca01b28 100644 --- a/src/main/readonly-query-worker.ts +++ b/src/main/readonly-query-worker.ts @@ -78,7 +78,8 @@ if (kind === 'checkpoint') { const scope = (request as ReviewState['request']).scope return database.readSnapshot(() => ({ summary: database.reviewSummary(scope, 'supervisor'), background: database.reviewBackground(scope) })) }, - reviewCandidates: ([request]) => database.listSupervisionCandidates(request as ReviewState['request']) + reviewCandidates: ([request, batch]) => database.listSupervisionCandidates(request as ReviewState['request'], + batch as Parameters[1]) } } diff --git a/src/renderer/src/ChannelSettingsSection.test.tsx b/src/renderer/src/ChannelSettingsSection.test.tsx index 617fb665..42c3ed28 100644 --- a/src/renderer/src/ChannelSettingsSection.test.tsx +++ b/src/renderer/src/ChannelSettingsSection.test.tsx @@ -178,6 +178,39 @@ afterEach(async () => { }) describe('ChannelSettingsSection', () => { + it.each(['error', 'stopped', 'running', 'starting', 'disabled'] as const)( + 'only reapplies unchanged enabled Telegram settings when pushed state is %s and needs recovery', + async (state) => { + const configured: ChannelSettingsSnapshot = { + ...snapshot, + telegram: { ...snapshot.telegram, enabled: true, secretConfigured: true, source: 'encrypted', status: { state: 'running' } } + } + let receive!: (change: ChannelRuntimeStatusChange) => void + const apply = vi.fn(async () => configured) + const onNotify = vi.fn() + Object.defineProperty(window, 'goodbuddy', { + configurable: true, + value: { + channels: { ...bindingApi(), getSnapshot: async () => configured, apply, + onStatusChanged: (listener: typeof receive) => { receive = listener; return () => undefined } }, + settings: settingsApi(), projects: { update: vi.fn(async () => projects[0]) } + } as unknown as DesktopApi + }) + renderChannelSettings({ initialChannel: 'telegram', onNotify }) + await screen.findByLabelText('TelegramBot Token') + act(() => receive({ channel: 'telegram', status: { state } })) + fireEvent.click(screen.getByRole('button', { name: '保存通道设置' })) + await waitFor(() => expect(onNotify).toHaveBeenCalled()) + if (state === 'error' || state === 'stopped') { + expect(apply).toHaveBeenCalledWith({ telegram: { + enabled: true, secret: { action: 'keep' }, allowedSenderIds: [], allowGroupMessages: false + } }) + } else { + expect(apply).not.toHaveBeenCalled() + } + } + ) + it('keeps pushed channel status newer than loading and updates reconnect errors without resetting drafts', async () => { let receive!: (change: ChannelRuntimeStatusChange) => void const unsubscribe = vi.fn() diff --git a/src/renderer/src/ChannelSettingsSection.tsx b/src/renderer/src/ChannelSettingsSection.tsx index 3be35430..184cd8f4 100644 --- a/src/renderer/src/ChannelSettingsSection.tsx +++ b/src/renderer/src/ChannelSettingsSection.tsx @@ -1002,6 +1002,9 @@ export function ChannelSettingsSection({ ) return } + const telegramState = (statusStore.getState().telegram ?? snapshot.telegram.status).state + const restartTelegram = drafts.telegram.enabled && + (telegramState === 'error' || telegramState === 'stopped') const input: ChannelSettingsApply = { ...(weixinEnabled === snapshot.weixin.enabled ? {} @@ -1015,7 +1018,7 @@ export function ChannelSettingsSection({ ? { dingtalk: inputFor('dingtalk', drafts.dingtalk) } : {}), ...(!snapshot.telegram.readOnly && - channelDraftChanged('telegram', drafts.telegram, snapshot) + (channelDraftChanged('telegram', drafts.telegram, snapshot) || restartTelegram) ? { telegram: inputFor('telegram', drafts.telegram) } : {}) } diff --git a/src/renderer/src/ChatHistoryPane.test.tsx b/src/renderer/src/ChatHistoryPane.test.tsx index 032fbac9..d5de394a 100644 --- a/src/renderer/src/ChatHistoryPane.test.tsx +++ b/src/renderer/src/ChatHistoryPane.test.tsx @@ -182,6 +182,91 @@ afterEach(() => { }) describe('ChatHistoryPane windowing', () => { + it('restores disclosures after eviction by more than eight row interactions with bounded DOM', async () => { + const messages = makeMessages(2_000).map(message => ({ ...message, role: 'assistant' as const, reasoning: 'Reasoning' })) + const tool = { callId: 'read', name: 'read', summary: 'Read file', state: 'completed' as const, input: '{"path":"file"}', output: 'File contents' } + Object.assign(messages[0]!, { tools: [tool], subagents: [{ childTaskId: 'child', expertId: 'expert', expertName: 'Expert', + routingMode: 'native', state: 'completed', progress: [{ id: 'child-tool', type: 'tool', tool }] }] }) + const { container, chat } = setup(messages) + measure() + const visit = async (index: number) => { + act(() => { chat.scrollTop = index * 100; fireEvent.scroll(chat) }) + await frame() + measure() + } + const disclosure = (index: number) => container.querySelector(`[data-message-id="m${index}"] details`)! + await visit(0) + const original = disclosure(0) + act(() => { original.open = true; fireEvent(original, new Event('toggle')) }) + const toggle = (selector: string, open: boolean) => { + const element = container.querySelector(`[data-message-id="m0"] ${selector}`)! + act(() => { element.open = open; fireEvent(element, new Event('toggle')) }) + } + toggle('.subagent-status-card', true) + toggle('.subagent-status-card .tool-execution', true) + toggle('.subagent-status-card .tool-execution__input', true) + // Collapsing a subagent unmounts its children even before row eviction. + toggle('.subagent-status-card', false) + toggle('.subagent-status-card', true) + expect(container.querySelector('.subagent-status-card .tool-execution__input')!.open).toBe(true) + for (let index = 1; index <= 12; index++) { + await visit(index * 100) + const details = disclosure(index * 100) + act(() => { details.open = true; fireEvent(details, new Event('toggle')) }) + expect(renderedIds(container).length).toBeLessThanOrEqual(maxRenderedMessageCount + 8) + } + expect(original.isConnected).toBe(false) + await visit(0) + expect(disclosure(0)).not.toBe(original) + expect(disclosure(0).open).toBe(true) + expect(container.querySelector('[data-message-id="m0"] .subagent-status-card')!.open).toBe(true) + expect(container.querySelector('[data-message-id="m0"] .subagent-status-card .tool-execution__input')!.open).toBe(true) + // The parent and child deliberately share a tool call ID, but not state. + expect([...container.querySelectorAll('[data-message-id="m0"] .tool-execution')] + .find(element => !element.closest('.subagent-status-card'))!.open).toBe(false) + act(() => { disclosure(0).open = false; fireEvent(disclosure(0), new Event('toggle')) }) + await visit(1500) + await visit(0) + expect(disclosure(0).open).toBe(false) + }) + + it.each(['legacy', 'blocks'] as const)('preserves %s streaming reasoning defaults and manual choices through eviction and completion', async representation => { + const messages = makeMessages(2_000).map(message => ({ ...message, role: 'assistant' as const, reasoning: 'Reasoning' })) + messages[0] = { ...messages[0]!, state: 'streaming' } + if (representation === 'blocks') Object.assign(messages[0]!, { blocks: [{ id: 'reason', type: 'reasoning', content: 'Reasoning' }] }) + const { container, chat, props, rerender } = setup(messages) + measure() + const visit = async (index: number) => { + act(() => { chat.scrollTop = index * 100; fireEvent.scroll(chat) }) + await frame(); measure() + } + const details = () => container.querySelector('[data-message-id="m0"] .message-reasoning')! + await visit(0) + expect(details().open).toBe(true) + act(() => { details().open = false; fireEvent(details(), new Event('toggle')) }) + for (let index = 1; index <= 10; index++) { + await visit(index * 100) + const other = container.querySelector(`[data-message-id="m${index * 100}"] details`)! + act(() => { other.open = true; fireEvent(other, new Event('toggle')) }) + } + expect(details()).toBeNull() + await visit(0) + expect(details().open).toBe(false) + act(() => { details().open = true; fireEvent(details(), new Event('toggle')) }) + for (let index = 1; index <= 10; index++) { + await visit(index * 100) + const other = container.querySelector(`[data-message-id="m${index * 100}"] details`)! + act(() => { other.open = true; fireEvent(other, new Event('toggle')) }) + } + expect(details()).toBeNull() + // Completion keeps the existing auto-close semantics rather than restoring + // the streaming expansion on the next mount. + const completed = messages.map((message, index) => index === 0 ? { ...message, state: 'complete' as const } : message) + rerender() + await visit(0) + expect(details().open).toBe(false) + }) + it('keeps the DOM bounded for a 2,000-message conversation at any scroll position', async () => { const messages = makeMessages(2_000) const { container, chat } = setup(messages) diff --git a/src/renderer/src/ChatHistoryPane.tsx b/src/renderer/src/ChatHistoryPane.tsx index 81a49080..60b284c5 100644 --- a/src/renderer/src/ChatHistoryPane.tsx +++ b/src/renderer/src/ChatHistoryPane.tsx @@ -18,6 +18,7 @@ import type { import type { ImageOperation } from "../../shared/image-generation-contracts"; import { ChatTimeline, type ImageViewerItem, type Message } from "./ChatTimeline"; import { isUnusedConversation, type Conversation } from "./chat-conversation"; +import { MessageExpansionProvider } from "./message-expansion"; import { buildSegments, maxRenderedMessageCount, @@ -246,8 +247,8 @@ export const ChatHistoryPane = memo(function ChatHistoryPane({ // Rows kept mounted outside the range: the focused row, the first message // after the final "load earlier" step. const [keptMessageIds, setKeptMessageIds] = useState([]); - // Rows the user changed (toggled a
, typed into a question card): - // their local state would be lost if they unmounted. Bounded, oldest out. + // Keep recent interactions warm (copy feedback, previews, typed answers). + // Disclosure choices also survive eviction in MessageExpansionProvider. const [interactedMessageIds, setInteractedMessageIds] = useState([]); // Rows holding the start and end of a non-empty text selection. const [selectionMessageIds, setSelectionMessageIds] = useState([]); @@ -544,9 +545,8 @@ export const ChatHistoryPane = memo(function ChatHistoryPane({ return () => cancelAnimationFrame(frame); }, [active, captureAnchor, navigationTarget, view, visibleMessageCount]); - // Rows the user interacts with keep their local state (open details, typed - // question answers, "copied" feedback, Mermaid toggles) as they did when - // every loaded row stayed mounted. + // Keep a bounded set of recently interacted rows mounted. Disclosure state + // lives outside the rows, while pending questions are independently pinned. useEffect(() => { const list = listRef.current; if (!active || !list) return; @@ -1065,7 +1065,7 @@ export const ChatHistoryPane = memo(function ChatHistoryPane({ }; return ( -
)} -
+
); }); diff --git a/src/renderer/src/ChatTimeline.tsx b/src/renderer/src/ChatTimeline.tsx index 6f2f7694..22531caa 100644 --- a/src/renderer/src/ChatTimeline.tsx +++ b/src/renderer/src/ChatTimeline.tsx @@ -1,5 +1,6 @@ import { AttachmentResultButton } from './AttachmentResultButton' import { AttachmentActions, AttachmentStatus } from './AttachmentActions' +import { MessageExpansionScope, useMessageExpansion } from './message-expansion' import { Bot, Check, @@ -167,14 +168,17 @@ function formatAttachmentSize(size: number): string { } function MessageReasoning({ + expansionId = 'reasoning', content, streaming }: { + expansionId?: string content: string streaming: boolean }): React.JSX.Element { const { t } = useTranslation('app') const contentRef = useRef(null) + const [expanded, setExpanded] = useMessageExpansion(expansionId, streaming) useEffect(() => { if (!streaming || !contentRef.current) { @@ -187,7 +191,7 @@ function MessageReasoning({ }, [content, streaming]) return ( -
+
setExpanded(event.currentTarget.open)}> {streaming ? t('chat.reasoning.streaming') @@ -239,12 +243,15 @@ const ToolDetail = memo(function ToolDetail({ label, content, formatJson = false ) }) -const ToolExecutionRow = memo(function ToolExecutionRow({ onCopy, ...tool }: Pick< +const ToolExecutionRow = memo(function ToolExecutionRow({ onCopy, expansionId, ...tool }: Pick< ToolActivity, 'name' | 'summary' | 'state' | 'input' | 'output' | 'error' > & { + expansionId: string onCopy: CopyContent }): React.JSX.Element { const { t } = useTranslation('app') + const [expanded, setExpanded] = useMessageExpansion(`tool:${expansionId}`) + const [inputExpanded, setInputExpanded] = useMessageExpansion(`tool-input:${expansionId}`) const summary = tool.summary.trim() // Only suppress known boilerplate; recovery hints and custom summaries remain visible. const summaryIdentity = summary.replace( @@ -258,6 +265,7 @@ const ToolExecutionRow = memo(function ToolExecutionRow({ onCopy, ...tool }: Pic return (
  • setExpanded(event.currentTarget.open)} className={`tool-execution tool-execution--${tool.state}`} > @@ -290,7 +298,7 @@ const ToolExecutionRow = memo(function ToolExecutionRow({ onCopy, ...tool }: Pic : tool.state === 'completed' ? 'chat.tools.emptyOutput' : 'chat.tools.noDetails')}

    )} {tool.input && ( -
    +
    setInputExpanded(event.currentTarget.open)}> {t('chat.tools.input')}
    @@ -322,6 +330,7 @@ function ToolExecutionList({ {tools.map((tool) => ( setExpanded(event.currentTarget.open)} > @@ -450,6 +460,7 @@ const SubagentStatusCard = memo(function SubagentStatusCard({ : item.block.type === 'reasoning' ? ( @@ -513,13 +524,12 @@ function SubagentStatusList({ className="subagent-status-list" > {subagents.map((subagent) => ( - question.childTaskId === subagent.childTaskId) ? questionFormId : undefined} onCopy={onCopy} - /> + /> ))} ) @@ -581,6 +591,7 @@ function ChatMessageRowView({ }: ChatMessageRowProps): React.JSX.Element { const { t } = useTranslation('app') const [copied, setCopied] = useState(false) + const [citationsExpanded, setCitationsExpanded] = useMessageExpansion('citations') const copyResetTimer = useRef | undefined>(undefined) useEffect(() => () => clearTimeout(copyResetTimer.current), []) @@ -867,6 +878,7 @@ function ChatMessageRowView({ /> ) : item.block.type === 'reasoning' ? ( 0 && ( -
    +
    setCitationsExpanded(event.currentTarget.open)}> {t('chat.citations.view', { count: message.sourceReferences.length @@ -1272,7 +1284,7 @@ function ChatMessageRowView({ export const ChatMessageRow = memo(function ChatMessageRow( props: ChatMessageRowProps ): React.JSX.Element { - return + return }) type ChatMessageWindowRowProps = ChatMessageRowProps & { diff --git a/src/renderer/src/SupervisorWorkspace.test.tsx b/src/renderer/src/SupervisorWorkspace.test.tsx index 05c96097..a817c14e 100644 --- a/src/renderer/src/SupervisorWorkspace.test.tsx +++ b/src/renderer/src/SupervisorWorkspace.test.tsx @@ -15,6 +15,66 @@ const selectHistory = (id: string) => { } describe('SupervisorWorkspace', () => { + it.each(['locale', 'review'] as const)('releases pending source state on %s refresh without letting its late response clear a newer request', async trigger => { + let finishReview!: () => void + const run = vi.fn(() => new Promise(resolve => { finishReview = resolve })) + let finishOld!: (value: unknown) => void + let finishNew!: (value: unknown) => void + const source = vi.fn() + .mockImplementationOnce(() => new Promise(resolve => { finishOld = resolve })) + .mockImplementationOnce(() => new Promise(resolve => { finishNew = resolve })) + const graph = vi.fn(async () => ({ storyLine: null, + events: [{ id: 'event', title: 'Event', description: '', occurred_at: result.createdAt }], + entities: [], relations: [], eventEntities: [], sources: [{ id: 'source', title: 'Source', occurred_at: result.createdAt }], + eventSources: [{ event_id: 'event', source_id: 'source' }] })) + window.goodbuddy = { supervision: { overview: async () => [result], graph, source, run } } as never + render() + await screen.findByRole('button', { name: /Source/ }) + if (trigger === 'review') { + fireEvent.click(screen.getByRole('button', { name: '新回顾' })) + fireEvent.click(screen.getByRole('button', { name: '回顾' })) + await waitFor(() => expect(run).toHaveBeenCalledOnce()) + } + fireEvent.click(await screen.findByRole('button', { name: /Source/ })) + await waitFor(() => expect(source).toHaveBeenCalledTimes(1)) + await act(async () => { if (trigger === 'locale') await changeUiLocale('en-US'); else finishReview() }) + const refreshLabel = trigger === 'locale' ? 'Refresh' : '刷新' + await waitFor(() => { + expect(graph).toHaveBeenCalledTimes(2) + expect(screen.getByRole('button', { name: /Source/ })).toBeEnabled() + expect(screen.getByRole('button', { name: refreshLabel })).toBeEnabled() + }) + fireEvent.click(screen.getByRole('button', { name: /Source/ })) + await act(async () => finishOld({ title: 'Source', content: 'Obsolete source', occurredAt: result.createdAt })) + expect(screen.queryByText('Obsolete source')).not.toBeInTheDocument() + expect(screen.getByRole('button', { name: /Source/ })).toBeDisabled() + await act(async () => finishNew({ title: 'Source', content: 'Current source', occurredAt: result.createdAt })) + expect(screen.getByText('Current source')).toBeVisible() + expect(screen.getByRole('button', { name: refreshLabel })).toBeEnabled() + }) + + it.each(['entity', 'relation'] as const)('releases a pending %s action when locale refresh invalidates it', async kind => { + let finish!: () => void + const action = vi.fn(() => new Promise(resolve => { finish = resolve })) + const graph = vi.fn(async () => ({ storyLine: null, + events: [{ id: 'event', title: 'Event', description: '', occurred_at: result.createdAt }], + entities: [{ id: 'entity', canonical_label: 'Entity', description: '', confirmation_state: 'automatic' }], + relations: [{ id: 'relation', from_entity_id: 'entity', to_entity_id: 'entity', relation_type: 'related', reason: '', confirmation_state: 'automatic' }], + sources: [], eventEntities: [], eventSources: [] })) + window.goodbuddy = { supervision: { overview: async () => [result], graph, entityAction: action, relationAction: action } } as never + render() + await waitFor(() => expect(screen.getByRole('button', { name: '刷新' })).toBeEnabled()) + fireEvent.change(screen.getByRole('combobox', { name: '图谱选择' }), { target: { value: kind } }) + fireEvent.click(within(screen.getByRole('tabpanel')).getByRole('button', { name: /Entity/ })) + fireEvent.click(screen.getByRole('button', { name: i18nResources['zh-CN'].heartbeat.supervisor.confirm })) + await waitFor(() => expect(action).toHaveBeenCalledOnce()) + await act(async () => { await changeUiLocale('en-US') }) + await waitFor(() => expect(screen.getByRole('button', { name: 'Refresh' })).toBeEnabled()) + await act(async () => finish()) + expect(graph).toHaveBeenCalledTimes(2) + expect(screen.getByRole('button', { name: 'Refresh' })).toBeEnabled() + }) + it.each(['zh-CN', 'en-US'] as const)('submits custom days and keeps reanalysis confirmation consistent in %s', async locale => { await changeUiLocale(locale) const copy = i18nResources[locale].heartbeat.supervisor diff --git a/src/renderer/src/SupervisorWorkspace.tsx b/src/renderer/src/SupervisorWorkspace.tsx index 2a215ec0..aeecfc2b 100644 --- a/src/renderer/src/SupervisorWorkspace.tsx +++ b/src/renderer/src/SupervisorWorkspace.tsx @@ -158,6 +158,7 @@ export function SupervisorWorkspace({ const refresh = useCallback(async (requestedId = selectedResult.current) => { if (!api) return const generation = ++loadGeneration.current + setPending(undefined) selectedResult.current = requestedId setLoading(true) setGraph(emptyGraph) diff --git a/src/renderer/src/message-expansion.test.tsx b/src/renderer/src/message-expansion.test.tsx new file mode 100644 index 00000000..e9587088 --- /dev/null +++ b/src/renderer/src/message-expansion.test.tsx @@ -0,0 +1,110 @@ +import { act, cleanup, fireEvent, render } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { Message } from './ChatTimeline' +import { MessageExpansionProvider, MessageExpansionScope, useMessageExpansion } from './message-expansion' + +afterEach(() => { cleanup(); vi.restoreAllMocks() }) + +function Disclosure({ id, automaticOpen = false }: { id: string; automaticOpen?: boolean }) { + const [open, setOpen] = useMessageExpansion(id, automaticOpen) + return