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