fix: resume reviews after conversation deletion and update release docs

This commit is contained in:
mesalogo
2026-10-05 14:22:16 +08:00
parent b90ff50f2a
commit 2be57492e6
23 changed files with 570 additions and 146 deletions
@@ -12,6 +12,8 @@
监督回顾按来源版本及正文位置增量处理,手动回顾与心跳共享进度,无变化时跳过模型;“重新整理”才重读所选区间。旧的进度、失败重试和删除边界见[自动增量与手动重看](./logic-design.md#自动增量与手动重看)。
未发布回顾在会话删除后省略相关来源并继续,具体覆盖和历史保留规则见[会话删除](./logic-design.md#未发布回顾遇到会话删除)。
| 文档 | 权威职责 |
| --- | --- |
| [会话监督 PRD](./prd.md) | 监督目标、证据、介入方式和产品验收 |
@@ -16,7 +16,15 @@
报告、监督叶子与导航整理共用监督专用 FIFO 并发池,普通聊天不入池;监督服务外层 run 仍串行。报告在排队前冻结超时,获槽后领取,实际模型请求前校验并刷新租约;报告提交或无变化后先释放槽位,再执行监督。监督超时在 run 创建时保存,节点获槽并解析 Runtime 后计时。阶段超时、租约、传输时限、后台检查与活动轮询各自独立,取值及生效规则见[当前设置合同](./review-scheduling-design.md#当前设置合同)。
完整回顾的生产接线已增加分页来源清单、逐批输出/offset 事务和导航节点。用户主动暂停后显示 `paused`,模型或保存失败显示 `failed`;两者均保留此前成功批次,可继续同一 run。回顾不按累计运行时长自动暂停。存在未读来源、覆盖空洞或未成功导航时不能标 `completed`。手动位置与自动 checkpoint 分开,自动位置只在最终发布事务推进。来源变更目前要求重新回顾,模型配置跨重启固定等差异见[生产接线与剩余边界](./review-scheduling-design.md#0-生产接线与剩余边界)。真实模型验证覆盖 27 条消息的完整会话,较大样本失败单独记录。
完整回顾的生产接线已增加分页来源清单、逐批输出/offset 事务和导航节点。用户主动暂停后显示 `paused`,模型或保存失败显示 `failed`;两者均保留此前成功批次,可继续同一 run。回顾不按累计运行时长自动暂停。存在未读来源、覆盖空洞或未成功导航时不能标 `completed`。手动位置与自动 checkpoint 分开,自动位置只在最终发布事务推进。现存来源修改仍要求重新回顾;会话删除按下节规则省略。其他差异见[生产接线与剩余边界](./review-scheduling-design.md#0-生产接线与剩余边界)。真实模型验证覆盖 27 条消息的完整会话,较大样本失败单独记录。
### 未发布回顾遇到会话删除
对应 FR-S9、US-S35。会话物理删除后,尚未发布的回顾省略其冻结来源,继续处理其他会话;省略包含消息、消息携带的知识引用及已记录会话归属的任务。归档不等同于删除,现存来源修改、单独消息移除或其他不可用原因仍明确失败。初始化前已删除的会话不进入清单。
含删除来源的叶子整批丢弃,其父级导航失效。混合批次中的存活来源从本次初始 offset 重做,包含这些来源的其他批次也失效,避免覆盖空洞或重复事实;与之无关的批次及导航保留。不从保存片段恢复或重读已删除会话。
活动及运行详情显示省略来源数;完成仅表示保留来源已处理并发布,不代表原冻结集合全部覆盖。所有来源均被省略时记录 `no_change`,不生成空报告、不推进这些来源的 checkpoint。删除前已发布的结果、事实和历史片段保留规则不变。已发出的模型请求可能仍返回,但其受影响输出不得发布。
```mermaid
flowchart TB
@@ -379,7 +379,7 @@ type SupervisorRecord = {
5. 普通会话监督失败不影响主回答;自动化配置的强制监督失败进入明确关注状态。
6. 评论和通知不包含完整私人消息,证据点击后才在原对象中查看。
7. Renderer 传入的 Evidence ID 必须由 Main 重新验证归属。
8. 删除会话时按产品数据保留策略删除或匿名化监督记录。
8. 删除会话时省略未发布回顾中的该会话来源,继续其他来源,并显示省略范围;已发布历史结果保留。具体规则见[未发布回顾遇到会话删除](./logic-design.md#未发布回顾遇到会话删除)。
9. 用户关闭监督后停止新分析,但保留历史,除非用户明确删除。
## 19. 性能与预算
@@ -44,6 +44,18 @@
剩余预算问题:故事读取有一次 19.74 ms 的 Main 间隔,超过 16 ms;大图谱仍要等待数百毫秒的 Worker 查询;首次 Worker 启动也会增加小库概览等待。Main 最终发布仍有约 271~303 ms 的同步阻塞,并未达到性能原则。业务写入迁移、独立存储服务、IPC 实际序列化与并发写锁等待不在本次已验证范围,后续方向见[桌面存储职责](../../architecture/desktop-storage-direction.md)。本轮不迁移全部写入,也不放宽预算。外部模型调用为 0;没有读取用户正文、修改用户库、读取环境文件、生产构建、打包或启动已打包应用,没有提交。
## 2026-10-05 删除会话后继续未发布回顾
对应 FR-S9、US-S35。删除会话现在使受影响的未发布批次及依赖导航失效,其他来源继续;混合批次重算、省略覆盖和历史保留规则见[逻辑设计](./logic-design.md#未发布回顾遇到会话删除)。活动和运行详情已增加中英文省略数量提示。原先因已删除会话而标记需要重建的运行,可重新继续;现存来源的修改或归档仍失败。
验证使用隔离文件 SQLite、真实初始化/恢复 Worker 和可控模型返回,未接触用户数据库:
- `npx vitest run src/main/assistant/supervision-review.test.ts src/main/assistant/supervision-worker.test.ts src/main/assistant/supervision-production.test.ts src/renderer/src/SupervisorActivity.test.tsx`:4 文件、95 项通过。新增场景包括恢复前删除整会话及知识引用/任务、Worker 复制清单时删除、两路处理及导航期间删除、发布前另一连接删除、混合批次存活来源重做、父导航递归失效、历史结果及来源逐字段不变、全部来源删除无空报告和中英文省略提示。
- Node/Web TypeScript 检查、11 个改动 TS/TSX 文件的定向 ESLint、`git diff --check` 通过。差异检查仅有仓库既有 LF/CRLF 提示。
- `npx vitest run src/main/assistant/assistant-database.test.ts -t explicitly.deletes.only.local`:既有本地会话删除与消息级联回归通过,其余 124 项按筛选跳过。
外部模型调用为 0;未运行全量测试、Electron 界面实测或大库删除延迟测量,未提交或推送。回顾仍在桌面直连模型路径执行,不改变远程 Agent。已发出的请求可在删除后返回,结果会被丢弃;不承诺撤回已发送给模型的输入。旧清单若未保存任务会话归属且任务早已被外部删除,不能推断其归属,仍按来源不可用失败。
## 2026-10-05 监督模型临时失败重试
对应 FR-S6。`runSupervisionModel` 已接入一次可取消的临时失败重试,覆盖监督者各模型阶段;错误分类、清理及每次尝试的超时规则见[技术设计](./technical-design.md#模型阶段超时与调度)。直连文本请求保留 HTTP status,并交出失败流中已报告的用量。OpenCode/Continue、直连传输重试和叶子/导航输出重试未改。监督者仍在桌面 Main 直连模型,不经过远程 Agent 或模型桥。
@@ -61,7 +61,7 @@ schema 47 增加运行配置、来源清单、成功批次、导航节点四张
### 未完成与实测限制
- 真实验证覆盖一个 27 条消息的完整会话;较早的 129 条消息实验在第二次输出 JSON 无效后停止。两实验合计 5 次付费请求,没有重复付费或回退。范围及缓存复核说明见[进度](./progress.md#2026-09-24-完整会话生产验证与收尾)。
- 来源被修改或删除时保留旧批次并明确失败,标记需要新建回顾,活动页停止提供无效的继续按钮;下一次自动检查可创建新 run,不会永久重复该失败。尚未实现仅使受影响叶子和父节点失效后重算。清单记录冻结版本,不保存全库正文快照。
- 现存来源修改仍明确失败并要求新建回顾;会话物理删除时省略该会话来源,丢弃受影响的未发布叶子及依赖导航,保留其他进度。混合批次重算及覆盖规则见[会话删除](./logic-design.md#未发布回顾遇到会话删除)。清单记录冻结版本,不保存全库正文快照。
- 分块配置、超时、接纳并发及算法版本已保存。监督者模型连接在每次执行开始时解析,同次回顾复用;继续或重试读取当前选择,规则见[监督者模型选择](./logic-design.md#监督者模型选择)。候选背景仍在执行时读取;未实现跨重启模型/提示词版本固定,也未实现共享池排空后切换冻结并发的目标规则。
- 元数据初始化已移到 Worker,并通过 2.43 GB 隔离副本验证;[测量记录](./progress.md#2026-10-04-回顾初始化阻塞修复)包括取消与来源一致性。最终发布仍在 Main 同步提交,未完成同等规模的全局发布延迟验收。批次详情已分页,既有历史图谱查询仍返回整份结果;大图谱的响应大小尚未改造。
- 没有 token/费用预算、提供商 RPM/TPM、Retry-After 冷却、执行硬期限、独立清理期限或逐层可配置超时。默认正文预算不保证任意模型上下文容量;超长输出、非法 JSON 和引用错误会失败,保留已成功批次。
@@ -286,7 +286,7 @@ run(request):
| 执行软预算到达 | 停止读页、接纳和派发,在飞节点在节点及硬期限内结束并保存成功批次 | 未完成时显示预算暂停,可继续;若已满足全部完成条件则可正常发布 |
| 批次或发布事务失败 | 该事务回滚;此前成功批次不回滚 | 从最后持久化位置恢复;发布失败不重算已有合法叶子 |
| 达到采集预算,已接纳任务全部成功 | 保存有限覆盖批次及位置,仍有未读内容 | 显示“未完成,可继续”,不以 `completed` 加提示代替真实状态 |
| 有来源读取失败或运行中被删除 | 初版按本轮失败处理,不将读取失败当作没有新证据 | 显示来源不可用的数量和范围,后续重新查询 |
| 有来源读取失败或运行中被删除 | 会话物理删除按[省略规则](./logic-design.md#未发布回顾遇到会话删除)继续;其他读取失败仍报错 | 省略数独立于保留来源的覆盖统计 |
| 自动无新来源 | 保存 `no_change`,无新结果或图谱事件 | 保留旧结果,模型调用数为零 |
成功批次持久化是用户已授权的续跑要求。最小记录包含 run 冻结范围、目录/页位置、来源 revision 与连续 offset、已校验叶子输出及引用、必要的汇总依赖和成功输出。输出与对应位置同事务提交,以 run 和来源区间等稳定身份防止重复保存;不持久化每次进度动画或完整提示词。崩溃前未提交的在飞批次允许重算,已提交成功批次必须复用。表结构及保留/清理实现仍待设计落地,不能声称现有 schema 46 已支持此能力。
@@ -55,6 +55,8 @@ FR-S8:本地知识库提供双向定位:资料详情可进入相关故事线
FR-S9:来源事实、自动归纳、待核对关联和用户确认内容分别标注。移除关联不删除原文,纠正操作可撤销。来源失效时显示不可用状态;回放历史时使用当时已有的归纳与依据,无法恢复旧版原文时明确提示,不能以当前版本冒充历史内容。
未发布回顾遇到用户删除会话时,跳过该会话来源并继续其他会话,明确显示省略数;无剩余来源时结束且不生成空报告。已发布历史的保留规则不变。具体失效与覆盖语义见[会话删除规则](./logic-design.md#未发布回顾遇到会话删除),验收见 US-S35。
## 落地范围
FR-S11(D1 已实现,D2/D3 待实现):监督者以时态 Story Graph 为主要产出,统一用户侧工作记忆概念并保留已确认偏好与已有数据。Agent 在继续工作、查询此前决定及其修订时,按需使用只读的 `story_graph_search`、`story_graph_get_context`、`story_graph_read_source`,读取范围、时间、演变、覆盖及可引用来源;工具不调用模型或触发回顾,监督者关闭时不暴露且拒绝调用。本地和远程 Runtime 共用读取语义。后续目标为同一来源版本提取一次并跨 scope 复用叶子事实;当前精确 scope 独立处理及 memory 读取继续保留。`as_of` 仅在历史重建能力具备后另行交付。合同与过渡阶段由[专项设计](./story-graph-mcp-design.md)定义,验收见 US-S28 至 US-S31;本轮没有数据迁移。
@@ -83,6 +83,10 @@ schema 62 在既有迁移事务中新增 `supervision_sources(result_id, occurre
schema 47 的四张批次相关表使用外键随监督运行清理,未完成运行不新增自动过期删除规则。启动时原有 running 记录会标 failed,但已保存批次和配置保留,活动页仍可继续。发布逐页读取叶子并复用现有结果/来源/图谱写入,在同一事务更新完成状态和自动 checkpoint。
会话删除复用 `SupervisionReviewStore.omitDeletedConversations`,只处理 `running/paused/failed` 的暂存记录。删除入口在事务内补齐旧任务清单的会话归属;新清单由 Worker 在初始化时记录任务所属的现存会话。初始化完成、恢复、分块读取、模型调用前及返回落库、导航合并和发布前均检查删除。初始化中的清单仍可丢弃重建,省略计数在完整清单提交后计算。
失效计算只取批次的来源键,不把保存正文作为删除会话的替代输入;依赖导航沿 `children_json` 递归删除。省略数写入既有 `state_json.omittedSources`,透出到活动进度及结果 `coverage_json`,不增加 schema。发布事务先核对省略数与待发布结果一致,再保存事实和 checkpoint;若删除使摘要过时,服务返回现有提取/合并循环。已发布历史不参与失效,行为定义见[逻辑规则](./logic-design.md#未发布回顾遇到会话删除)。
当前设置及冻结范围见[当前设置合同](./review-scheduling-design.md#当前设置合同)。未实现的高级参数继续保留在目标清单,不显示为可保存控件;没有兼容原型数据库的读取器。
### 单次执行与取消
@@ -46,6 +46,8 @@
“已保留事实与来源”展开后才查询既有批次接口,每页 10 批,按本页持久记录组成项目、会话、批次层级。项目使用可用名称,会话优先使用保存来源的标题;只有已保存批次标记“已保存”,不创建未落库的待执行、在途或失败叶子,也不把本页数量当作项目总量。批次摘要收起时最多两行,展开后保留完整摘要、事件、实体、变化、关系及原文片段。来源显示消息 ID、码点范围及版本;长原文局部滚动。原生 details/summary 保留键盘展开和可见焦点,轮询不主动展开用户收起的会话。加载、空批次和读取失败分开显示,失败可重试;换页失败不显示上一页批次,切换 run 后旧响应失效。暂停状态为“未完成,可继续”,手动回顾主动暂停后在工作回顾页提示去活动继续,不显示完成通知。
删除会话导致省略来源时,阶段进度下方和运行详情均显示省略数量,并说明覆盖统计只含保留来源。中文为“会话已删除,已跳过 N 个来源”;英文为“N sources skipped because their conversations were deleted”。全部省略时活动显示无新变化,不提供空结果跳转;已发布历史仍可查看。规则见[会话删除](./logic-design.md#未发布回顾遇到会话删除)。
活动页运行中同时提供“暂停回顾”和“取消回顾”,继续后的运行也可操作。停止期间禁用两项操作,并在运行标题和当前阶段显示“正在暂停”或“正在取消”;即使持久记录已经为已取消,只要执行尚未停止就继续显示“正在取消”。重新进入活动页通过实时执行状态恢复该提示。停止后显示“已取消”,保留事实详情,不显示继续按钮或失败恢复提示;任一回顾执行或停止清理期间,其他记录的继续按钮禁用。
## 智能心跳设置
@@ -650,6 +650,19 @@
| 已选连接被删除或变成图片模型 | 打开设置并尝试执行 | 显示连接不可用,执行报错,用户可改选;不自动回退 |
| 模型选择保存失败 | 留在设置页 | 保留选择草稿及重试入口 |
### US-S35 删除会话后继续未发布回顾
对应 FR-S9,规则见[会话删除](./logic-design.md#未发布回顾遇到会话删除)。
| Given | When | Then |
| --- | --- | --- |
| 回顾已保存批次但尚未发布 | 删除其中一个会话后继续 | 跳过该会话的消息、知识引用和已记录归属的任务,保留无关批次 |
| 来源正在初始化、提取、合并或等待发布 | 用户删除会话 | 丢弃受影响输出及依赖摘要,继续其余来源,不从片段恢复会话 |
| 一个旧批次包含已删除及仍存在的会话来源 | 继续回顾 | 重做受影响的存活来源,无关来源不重做 |
| 所有待回顾来源所属会话均已删除 | 继续或结束当前请求 | 显示省略数及无新变化,不生成空报告 |
| 删除前已有发布结果 | 删除会话 | 既有历史结果和历史片段不变;未发布结果按省略后的覆盖发布 |
| 会话仍存在,来源正文被修改或会话被归档 | 校验来源 | 明确失败,不把修改或归档记为删除省略 |
## 验收优先级与依赖
首个可用版本应先打通以下路径:
@@ -1,20 +1,20 @@
# GoodBuddy 更新:故事图谱、Obsidian接入、Runtime原生终端
> **编辑标注说明(对外发布前删除):** 本稿汇总桌面版 0.13.8 至 0.15.9,截至 `9527402` 的已提交变化,更新于 2026-10-03。所述功能与修复均已开发完成并提交;原故事线概念图仅作示意,待换为生产界面截图。“待补图”表示缺少可发布素材,“待替换”表示发布信息未核验,“待核对”表示发布前需要确认的行为。本文是宣传草稿,尚未对外发布。
> **编辑标注说明(对外发布前删除):** 本稿汇总桌面版 0.13.8 至 0.15.11,以及其后截至 `e50ca6d1` 的已提交变化,更新于 2026-10-05。所述功能与修复均已开发完成并提交,验证及发布归属另行标注;原故事线概念图仅作示意,待换为生产界面截图。“待补图”表示缺少可发布素材,“待替换”表示发布信息未核验,“待核对”表示发布前需要确认的行为。本文是宣传草稿,尚未对外发布。
GoodBuddy 这一轮更新围绕工作记录的整理与使用展开:监督者把分散的事件归入持续发展的故事,从中整理带有来源和适用条件的经验,供人和助手在后续工作中查阅。笔记连接支持保存聊天正文、返回原会话,以及在聊天中读写本机 Obsidian 笔记。本地项目还可以从输入区打开 Continue、OpenCode 原生终端或 DS Web 客户端,沿用当前项目与模型配置。
本文接续上一份画布笔记与文档处理更新稿,覆盖 0.13.8 起的实际增量。这次还加入了智能心跳的增量回顾与建议、笔记标签、会话图片保存,以及按会话、项目和全局分别选择 Runtime 与模型的方式,并继续改善长会话的浏览与输入。远程使用建议配套 Desktop 0.15.9 和 Agent 0.15.3,请先升级桌面端,再更新 Host 环境。
本文接续上一份画布笔记与文档处理更新稿,覆盖 0.13.8 起的实际增量。这次还加入了智能心跳、笔记标签、会话图片保存、Telegram 私聊和工作区文件导入。项目与会话集中到同一个菜单,提问和执行任务也无需再切换工作模式。远程统一执行与文件导入建议配套 Desktop 0.15.11 和 Agent 0.15.4,请先升级桌面端,再更新 Host 环境;最新远程启动修复还需包含后续提交的 Agent。
> **【待替换 R01|版本与体验入口】** 最新已核查桌面 tag `v0.15.9` 与 Agent tag `agent-v0.15.3` 均指向 `9527402`。本轮检查 `b02ce31..9527402`,共 45 个提交;图片保存、笔记取消及默认开关调整已包含在桌面 0.15.8 中。对外发布前核验公开安装包、发布日期、下载地址和完整更新日志链接,并在此补入。桌面 0.13.14 的发布失败后由 0.13.15 承接。
> **【待替换 R01|版本与体验入口】** 最新已核查桌面 tag `v0.15.11` 与 Agent tag `agent-v0.15.4` 均指向 `78afec77`。本轮检查 `9527402..e50ca6d1`,共 60 个提交;Telegram 私聊、历史耗时缺失提示、自定义回顾日期及最新运行修复位于这些 tag 之后,发布版本待确认。对外发布前核验公开安装包、发布日期、下载地址和完整更新日志链接,并在此补入。桌面 0.13.14 的发布失败后由 0.13.15 承接。
**升级前,请先关闭应用并备份完整用户数据。** 数据库和相邻的 `notes/` 目录需要成套保留;如有 WAL 文件,也应一并备份。当前助理数据库格式为 56,包含共享时间线、故事归属、经验及建议等更新;升级后旧客户端不能直接打开该数据库,回退需要升级前的备份。
**升级前,请先关闭应用并备份完整用户数据。** 数据库、设置和相邻的 `notes/` 目录需要成套保留;如有 WAL 文件,也应一并备份。当前核查提交的助理数据库格式为 61,0.15.11 标签对应格式为 60;升级后旧客户端不能直接打开新版数据库,回退需要升级前的备份。大库首次转换可能需要等待。高频本地保存调整了磁盘同步策略,断电时可能丢失最近尚未同步的写入。
## 新增功能
### 监督者:从工作记录中整理进展,查看每条结论的依据
项目讨论持续一段时间后,可以打开“监督者”的“工作回顾”,选择全局或一个项目,以及最近 1 天、7 天或 30 天的范围。回顾会整理工作总结、本次变化和未解决事项,并按故事列出这段时间的推进、新出现与结束的事项,以及形成或用上的经验。点击故事或经验,可以进入图谱查看依据;历史结果保留各自的范围和生成时间,方便回看。
项目讨论持续一段时间后,可以打开“监督者”的“工作回顾”,选择全局或一个项目,再选择最近 1 天、7 天、30 天、自定义最近天数或起止日期。指定日期按本地时区包含结束日全天,结束日为今天时截至发起回顾的时刻。回顾会整理工作总结、本次变化和未解决事项,并按故事列出这段时间的推进、新出现与结束的事项,以及形成或用上的经验。点击故事或经验,可以进入图谱查看依据;历史结果保留各自的范围和生成时间,方便回看。
监督者默认关闭,在应用中心启用后即可手动回顾,无需先建立自动计划。当前分析来源包括范围内的用户与助手消息、任务标题与当前状态,以及消息中已有的知识引用片段。它不会自动遍历全部笔记、整个知识库或附件原件。
@@ -34,7 +34,9 @@ GoodBuddy 这一轮更新围绕工作记录的整理与使用展开:监督者
一项功能往往经过多次讨论、试验和修订。回顾后,相关事件可以归入同一个故事,按项目、功能和子线索逐层浏览;后续有新进展时继续积累,不必每次从零查找。用户可以调整名称、合并重复故事、移动归错的事件,也能撤销调整;移除故事会保留其中的事件。
时间螺旋把这些故事放到连续时间上,便于查看不同工作如何交错推进。同一件事可以隔一段时间再继续,中间的停顿保留下来。螺旋高度对应时间,半径随消息活动量变化,不代表实际工时、效率或成果价值。
时间螺旋把这些故事放到连续时间上,便于查看不同工作如何交错推进。同一件事可以隔一段时间再继续,中间的停顿保留下来。螺旋高度对应时间,半径按所选回顾范围内的消息密度变化,不代表实际工时、效率或成果价值;消息按小时汇总,无法还原小时内的精细分布。
实际事件区间和事件点使用主故事的颜色,空白区间与未归属事件保持中性色。历史回顾中尚未归入故事的事件也会保留,可以在“未归属”分组查看;故事分组采用当前关系,不把它当作当时分类的完整还原。
视图支持斜俯视、侧视、俯视和自由视角,可以逐层进入项目、功能与子线索,选中故事或事件查看详情。当前层可显示部分经验及其跨故事应用连线,完整经验列表仍在“经验”页签中查阅。设备无法使用 3D 时,可以切回平铺视图。
@@ -58,7 +60,9 @@ GoodBuddy 这一轮更新围绕工作记录的整理与使用展开:监督者
工具说明也会引导助手在继续旧工作、追问决定原因或遇到可能已有约定的任务时主动查阅,优先找故事与经验。是否调用仍由当前模型判断;普通闲聊和独立的通用问题不要求查询,也不会在每次对话中自动塞入整张图谱。
在应用中心启用监督者,并在 MCP 设置中保持 Story Graph 开启、分配给所用 Runtime 后即可使用。支持直连模型、DeepSeek Harness,以及本地和远程 OpenCode、Continue;Ask 与 Execute 均可只读查询。本地 Continue、OpenCode 原生终端也可读取。
在应用中心启用监督者,并在 MCP 设置中保持 Story Graph 开启、分配给所用 Runtime 后即可使用。支持直连模型、DeepSeek Harness,以及本地和远程 OpenCode、Continue;本地 Continue、OpenCode 原生终端也可读取。
输入区的选项中可以为当前会话单独开关故事图谱访问。默认允许查询,但仍受监督者总开关和工具配置约束;开启不会自动运行回顾或注入上下文,关闭也不会删除图谱,或将这个会话排除在后续回顾之外。
查询默认使用当前项目,也可明确指定全局或项目集合。事件可从共享时间线中按项目查阅,回顾结果仍保留各自的范围。工具只读取已有记录,不会为一次查询重新运行回顾或修改图谱;助手根据结果生成回答仍使用当前模型。保存的来源片段可以分页读取,尚不支持任意时刻的完整历史状态重建。
@@ -80,7 +84,7 @@ GoodBuddy 这一轮更新围绕工作记录的整理与使用展开:监督者
带入的结论按所选来源关联的事件、实体与关系整理,方便围绕当前事项继续追问。监督界面已移除知识实体写回控件。
后续讨论的最终内容最多 12,000 字符。固定任务没有关联会话时,不能直接发送继续讨论;后续对话沿用目标会话的模型和工作模式。
后续讨论的最终内容最多 12,000 字符。固定任务没有关联会话时,不能直接发送继续讨论;后续对话沿用目标会话的 Runtime、模型与能力配置。
### 聊天正文存入魔法笔记,并保留来源
@@ -102,7 +106,7 @@ GoodBuddy 这一轮更新围绕工作记录的整理与使用展开:监督者
给笔记加上“项目资料”“待整理”等标签后,可以按标签查找,也可以同时选择多个标签,筛出同时带有这些标签的笔记。每篇最多添加 10 个标签;标签支持改名、同名合并和删除,删除标签会保留笔记正文。
宽窗口打开笔记时,左侧保留列表,右侧阅读和编辑正文,两栏宽度可以拖动调整。切换笔记时保留当前工作区内的筛选与阅读位置,有未保存内容时会提示。窄窗口改为列表、详情切换;记录索引默认收起,“新建笔记”固定在页头。
宽窗口打开笔记时,左侧保留列表,右侧阅读和编辑正文,两栏宽度可以拖动调整。切换笔记时保留当前工作区内的筛选与阅读位置,有未保存内容时会提示。窄窗口可从正文工具栏打开笔记列表抽屉,选完笔记后继续阅读;记录索引默认收起,“新建笔记”固定在页头。
正文使用当前栏的可用宽度,宽窗口下阅读画布和长内容有更多空间。新建文字或画布记录都可以取消,有草稿时先确认是否放弃;取消编辑、切换记录类型或返回聊天后,焦点回到对应的按钮或输入位置。
@@ -114,7 +118,7 @@ GoodBuddy 这一轮更新围绕工作记录的整理与使用展开:监督者
接入后,可以请助手查找笔记、读取内容,或将讨论结果写回仓库;也支持追加、局部修改、目录浏览,以及标签和属性操作。笔记仍保存在原仓库,不会自动复制成 GoodBuddy 知识库。
目前支持直连模型及 GoodBuddy 管理的本机 OpenCode、Continue 聊天,默认关闭;DeepSeek Harness 和远程执行暂不支持。Ask 只读,Execute 可写入;多仓库调用需要明确目标仓库。接入本机文件不等于离线推理,读出的内容会随当前会话交给所选模型处理。
目前支持直连模型及 GoodBuddy 管理的本机 OpenCode、Continue 聊天,默认关闭;DeepSeek Harness 和远程执行暂不支持。启用并分配后,可按请求读写已配置范围内的笔记;多仓库调用需要明确目标仓库。接入本机文件不等于离线推理,读出的内容会随当前会话交给所选模型处理。
> **【待补图 07|建议|Obsidian 接入与读写】** 已有候选 `obsidian.png` 展示仓库范围和 Runtime 分配,尚需确认构建来源;正文主图优先补公开笔记的搜索、写入与回读结果。计划文件:`assets/07-obsidian-notes.png`。
>
@@ -126,7 +130,7 @@ GoodBuddy 这一轮更新围绕工作记录的整理与使用展开:监督者
原生客户端使用独立会话,不会自动接续或同步 GoodBuddy 中的聊天历史。DS 网页关闭后,本地服务继续运行,可以回到 GoodBuddy 重新打开或停止;退出应用会清理所属服务。
当前入口支持本地项目,远程原生客户端尚未接通。Continue 与 DS Web 需要可用的标准 Node 环境,首次准备环境可能需要下载。Continue、OpenCode 原生终端在 Ask 下可使用已分配的内置只读工具,包括故事图谱查询;自定义 MCP 仍仅在 Execute 下接入。
当前入口支持本地项目,远程原生客户端尚未接通。Continue 与 DS Web 需要可用的标准 Node 环境,首次准备环境可能需要下载。Continue、OpenCode 原生终端可使用已启用并分配的内置工具及自定义 MCP;故事图谱工具保持只读。
> **【待补图 08|建议|原生终端与 DS Web】** 已有 `continue.png`、`opencode.png`、`deepseekweb.png` 候选;发布前确认构建来源,并处理本机路径、浏览器书签及与演示无关的会话。Continue 图含模型能力警告,建议换用合适模型重拍;不能据启动画面宣称完整对话验收通过。计划文件:`assets/08-native-clients.png`。
>
@@ -158,9 +162,25 @@ GoodBuddy 这一轮更新围绕工作记录的整理与使用展开:监督者
### 让助手把会话图片保存到指定文件
在 Execute 模式下,可以让助手把当前会话中已经上传或生成的图片保存到指定绝对路径,缺少的父目录会自动创建。远程项目保存到对应 Host,方便后续在项目文件中使用;已有同名文件默认保留,明确允许覆盖后才替换。
可以让助手把当前会话中已经上传或生成的图片保存到指定绝对路径,缺少的父目录会自动创建。远程项目保存到对应 Host,方便后续在项目文件中使用;已有同名文件默认保留,明确允许覆盖后才替换。
保存支持 PNG、JPEG 和原格式 WebP,可按扩展名转换为 PNG 或 JPEG,不提供转为 WebP。工具使用当前会话已有图片,不负责下载任意图片网址;Ask 模式不提供写入操作。远程保存自 Agent 0.15.2 起提供,本轮建议使用上述最新配套版本。
保存支持 PNG、JPEG 和原格式 WebP,可按扩展名转换为 PNG 或 JPEG,不提供转为 WebP。工具使用当前会话已有图片,不负责下载任意图片网址。成果页已移除,历史图片和成果记录仍保留;保存图片无需先进入成果页,既有成果也不会自动复制到项目目录。远程保存自 Agent 0.15.2 起提供,本轮建议使用上述最新配套版本。
### 通过 Telegram 私聊使用通道项目
配置自己创建的 Telegram Bot、数字用户 ID 白名单和通道项目后,可以在 Telegram 私聊中发送文字问题或任务。GoodBuddy 沿用该项目的 Runtime、模型和工作目录处理请求,返回最终文字回复,并在桌面保留会话记录。
首次配置可以先留空发送者 ID,启用并保存通道。连接后私聊 Bot,发送 `/start` 和 `/whoami` 获取自己的数字用户 ID,填回白名单并再次保存后即可发送 AI 请求;仅测试连接不会开始接收消息。
当前支持单个 Bot 的普通私聊,桌面应用需要保持运行并能连接 Telegram。连接使用系统代理,支持断线恢复和停用取消;排除连接故障后,也可原样保存配置重新连接。目前不处理群聊、媒体输入或附件发送,图片等附件需回到桌面查看。
> **【待核对 C02|Telegram 实际联调】** 功能已提交,真实 Bot 与模型的完整收发验收仍待完成。对外发布前用专用演示 Bot 核对文字请求、回复、断线恢复与停用行为,不将本轮静态核查写成实际联调通过。
### 将本机文件导入本地或远程工作区
工作区文件面板的“新建文件”旁新增导入入口。选定目标目录后,可以批量选择本机文件,复制到本地项目或上传到远程 Host,随后在文件列表中查看。远程导入需要配套 Agent 0.15.4 或包含该功能的更新版本。
导入遇到同名文件时保留原文件,单个文件失败后继续处理其余文件。列表可以按名称、修改时间或可用的创建时间排序;排序针对当前目录已读取的条目,每目录最多 500 条。
### 桌面通知可以单独关闭
@@ -174,6 +194,18 @@ GoodBuddy 这一轮更新围绕工作记录的整理与使用展开:监督者
## 功能改进
### 请求直接使用当前 Runtime 与能力配置
聊天、任务和通道请求不再需要切换 Ask/Execute,也移除了原有的通用工具审批策略。选好 Runtime、模型与已启用能力后,即可提出请求;只需解释时可以直接说明,文件和工具操作使用执行账户的实际权限。
能力开关、工具分配、资源范围和取消操作继续生效,各 Runtime 的支持范围也仍有区别。监督回顾、摘要等专用文本分析任务保持无工具。已有会话内容保留,旧工作模式设置不再决定后续请求的行为。
### 项目与会话集中到一个菜单
左上角的双行按钮统一显示项目与当前会话,点击后浏览本地、远程和通道项目。可以分类搜索,悬停预览项目会话,也可以直接跨项目进入正在处理的工作;切回项目时恢复上次打开的会话。
菜单同时提供最近 10 个会话,以及待处理和运行中的工作;按已完成状态筛选时可以继续查找历史会话。项目设置、归档恢复和离开未保存草稿前的提示仍可使用。新建本地项目必须选择目录,远程目录选择器支持进入指向目录的符号链接。
### 会话搜索按需展开,快捷键提示集中在输入框
侧栏的搜索入口与“新建对话”放在同一行,点击后展开并自动聚焦,可查找会话标题或消息正文。切换会话保留关键词;点击关闭按钮,或在搜索控件行内按 Escape,可清空并收起搜索。展开和收起时,会话列表位置保持不变。
@@ -194,6 +226,8 @@ GoodBuddy 这一轮更新围绕工作记录的整理与使用展开:监督者
故事线图谱侧栏可以按故事、经验、事件、实体和关系查看,列表与详情各自滚动。切换页签保留已有选中对象,在画布选中对象时,侧栏会切到对应类别。平铺与时间螺旋都使用中间画布的可用空间,视角等控件收进紧凑操作区,减少窗口较矮时内容被挤出屏幕的问题。
工作回顾与图谱共用选中的历史结果和范围说明,从总结切到图谱时继续查看同一份回顾。新回顾的输入区域可以收起,历史选择器和视角工具排列更紧凑;详情布局也适配较窄窗口。
### 手动回顾与智能心跳共用增量进度
原有的每日、每周计划现在集中到“自动监督”。新建或编辑计划时,可以选择全局或多个项目,设置时间、时区、回顾窗口和历史保留期限;暂停的计划在编辑保存后仍保持暂停。
@@ -202,6 +236,8 @@ GoodBuddy 这一轮更新围绕工作记录的整理与使用展开:监督者
启用监督者不会自动创建计划。定时工作需要桌面应用保持运行,关闭监督者会停止发起新的回顾,并保留计划和历史;从导航取消常驻只改变入口显示。尚无活动时,自动监督页会隐藏空的统计、建议和历史区域,先展示计划入口。
监督者设置中可以单独选择一条文本模型连接,用于回顾、故事和经验整理,以及建议生成;未指定时跟随默认文本模型。选定连接失效或缺少 API Key 时明确提示,不会自动换用其他模型。
监督分析会将入选内容交给配置的模型服务,可能产生费用。它以只读、无工具的方式分析记录,长时间范围可能分成多批;心跳不再额外生成一份独立报告。并发数和超时设置不等于总费用上限。
### 长回顾可以看进度、保留批次并继续处理
@@ -232,11 +268,13 @@ GoodBuddy 这一轮更新围绕工作记录的整理与使用展开:监督者
运行记录中的 Token 用量先显示项目或会话总计,展开后再看 Runtime 和模型明细,按模型分组的视图继续保留。“系统任务”页签单列监督回顾、笔记、知识库等后台用量,并补齐回顾、图片重新生成、知识图谱提取和远程嵌入等调用的用量记录;过去未记录的用量无法补算。页面打开时加载,可见期间每 15 秒刷新,也可点击页头手动刷新。这里汇总的是应用记录,并非服务商计费账单。
会话和任务耗时按实际运行状态累计,包含模型与工具执行,等待用户回答时暂停,运行期间持续更新。并行请求的时间会相加,因此它不等于项目经历的日历时长。旧历史不补算,缺少计时数据且累计为零时显示“不可用”,与真实的零时长区分。
### 长对话输入、切换与流式回复减少重复处理
任务统计转到后台查询,隐藏的任务面板保留操作状态并减少不必要的更新。较长任务列表也减少了屏幕外卡片的绘制工作。
任务耗时按运行状态累计,避免反复扫描历史消息和事件;隐藏的任务面板保留操作状态并减少不必要的更新。较长任务列表也减少了屏幕外卡片的绘制工作。
流式活动更新时,只保存变化的记录,并减少连续保存过程中反复读取整份历史的次数。改动针对工具活动较多、运行记录较长的对话,保留原有内容与顺序;实际响应改善取决于历史规模和设备,不以测试中的数据库指标代替整机提速幅度。
运行记录按需分页加载,筛选时读取对应范围,保存时只传递变化的记录;页面计数与汇总仍覆盖已保存历史。离开页面后释放多余已加载页,重新查看时可以继续加载,保留原有内容与顺序。实际响应改善取决于历史规模和设备,不以测试中的数据库指标代替整机提速幅度。
聊天输入和流式回复减少了无关活动面板的刷新;置顶或取消置顶会话时,也不再附带扫描附件引用。笔记启动检查复用已读取的正文信息,减少重复读取,原有内容恢复与清理流程保留。
@@ -246,6 +284,8 @@ GoodBuddy 这一轮更新围绕工作记录的整理与使用展开:监督者
知识检索、故事图谱查询、会话搜索和会话列表等常用读取移到后台线程,减少查询期间对桌面主线程的占用。远程运行的连续事件按批保存,减少逐条写入的开销。改善幅度取决于设备、数据规模和内容复杂度,不作统一提速比例承诺。
文档解析、分块与摘要哈希计算也转到后台线程;笔记网格、工作区文件树和知识库文档表主要绘制可见区域,终端连续输出合并刷新。启动时按需加载部分组件,并在后台准备工具环境,需要这些工具时再等待准备完成。
### 设置页减少来回查找
长设置表单的分类标题与相关页签固定在顶部,请求 Header、Body 等低频选项收进高级设置。默认工作目录移到“平台功能 → 通用设置”,Skills 和 MCP 页面移除重复标题,保留导入与添加操作。
@@ -254,7 +294,7 @@ GoodBuddy 这一轮更新围绕工作记录的整理与使用展开:监督者
外观设置新增透明磨砂效果,当前默认开启;标题栏和相关卡片使用半透明背景,可以看到其后的应用内容。可以关闭恢复实色显示,已有的外观选择继续保留。
内置浏览器 MCP 也改为默认启用,仍可在设置中关闭;之前明确保存的关闭状态会保留,不会修改系统默认浏览器。在 Execute 模式中,请助手通过配置工具应用本次请求的变更计划时,取消了额外一次原生确认;Ask 仍保持只读。
内置浏览器 MCP 也改为默认启用,仍可在设置中关闭;之前明确保存的关闭状态会保留,不会修改系统默认浏览器。请助手通过配置工具应用本次请求的变更计划时,取消了额外一次原生确认;仍需先生成有效计划,再应用对应变更。
HTTP OCR 高级设置可以直接选择是否保留页眉、页脚、页码等内容,并恢复默认过滤方式。版面与生成参数的分组和间距也重新整理;具体选项效果仍取决于所连接的 OCR 服务。
@@ -286,17 +326,29 @@ HTTP OCR 高级设置可以直接选择是否保留页眉、页脚、页码等
最后一个活动任务结束后,远程空闲 Runtime 会被释放,下一轮对话再恢复历史。仍有任务或待答问题的共享进程会保留;更新后被替换的旧 Agent 会等待已有工作结束再退出,当前 Agent 保持常驻。
Host 更新还会清理已确认停用的旧 Agent 程序和归属明确的孤儿进程,保留会话历史、活动安装和状态不明的安装。这些完成状态与进程清理修复自 Agent 0.13.4 起提供;本轮建议配套 Agent 0.15.3,仅升级桌面不会直接替换 Host 上已经运行的旧进程。
Host 更新还会清理已确认停用的旧 Agent 程序和归属明确的孤儿进程,保留会话历史、活动安装和状态不明的安装。这些完成状态与进程清理修复自 Agent 0.13.4 起提供;本轮基础配套为 Agent 0.15.4,仅升级桌面不会直接替换 Host 上已经运行的旧进程。
最新修复使远程 Runtime 在等待启动时仍可取消,并避免阻塞其他会话;本地 OpenCode 提交失败后也能及时结束等待并显示原因。远程部分需要包含 `bbf24c28` 改动的 Agent,不属于已核查的 Agent 0.15.4 标签。
### 通道交付与统计结果
- 修复生成图片成功后,通道因结果格式校验而误报失败的问题。Telegram 仍只发送文字提示,附件需到桌面查看。
- 钉钉返回业务错误时不再被当作送达成功,可以进入已有重试流程。
- 修正 OpenCode 缓存命中率偏高、完全命中时显示为空的问题;已有用量重新查询后使用完整输入量计算,不表示实际缓存命中率提高。
- 活动汇总覆盖已加载的会话集合,不再只统计前 5,000 个会话。
- PPTX 解析警告显示实际使用的 OCR 服务及失败的幻灯片、图片位置,便于排查;这项修复不改变外部 OCR 服务本身的识别结果。
### 启动、搜索与回顾结果
- 修复正常聊天或保存笔记后,重启反复显示历史迁移的问题。普通数据库结构升级也不再因正常空闲页额外整理整库;确需转换的旧数据仍会处理,启动页区分实际操作。
- 修复本机直连模型文件搜索参数受限和大结果截断的问题。模型可使用 ripgrep 原生参数,保留原生结果与退出码,并分页续读长输出。Ask 保持工作区只读,Execute 使用当前账号权限;这项变更不属于远程 Agent 或其他 Runtime 的新增工具。
- 修复本机直连模型文件搜索参数受限和大结果截断的问题。模型可使用 ripgrep 原生参数,保留原生结果与退出码,并分页续读长输出;工具使用当前执行账户权限。这项变更不属于远程 Agent 或其他 Runtime 的新增工具。
- 修复早期监督回顾只取部分会话、消息和正文的问题,改为对入选来源分页、分批处理。事实与来源独立保存,只有必要阶段完成并成功保存结果后才标记完成。
- 修复监督回顾中的旧实体引用、摘要合并校验及长摘要报错。遇到无效 JSON、结构或引用时,会尝试缩小当前批次后重试,剩余正文留待后续处理;成功保存的批次保留。网络错误、超时、取消和超出响应容量不走这套重试。
- 修复监督回顾中的旧实体引用、摘要合并校验及长摘要报错。遇到无效 JSON、结构或引用时,会尝试缩小当前批次后重试,剩余正文留待后续处理;成功保存的批次保留。
- 监督模型遇到可识别的临时网络中断、限流或部分服务端错误时,清理失败请求后自动再试一次,等待期间可以取消。认证、配置、费用额度、超时、取消及容量错误不会触发这次重试;失败尝试已报告的用量仍会记录,重试可能增加模型费用。
- 故事或经验整理失败时,已保存的回顾结果继续可用,可以单独重试整理。故事归类减少把各功能的提交与测试统归到“发布”的情况;经验应用记录也增加时间顺序与共同实体核对,归纳内容仍需结合来源判断。
- 时间螺旋退出时释放图形资源;3D 初始化失败或图形上下文丢失时,在视图内提示并提供重试,也可切回平铺,避免整个监督页面无法使用。
- 修复历史回顾入口跳到其他结果、切换后旧响应覆盖当前选择的问题。侧栏、活动记录和图谱按对应结果定位,人工确认与修订的内容保留。
- 修复关闭跨项目关联后候选范围未正确限定、暂停发生在最终查询期间仍继续发布的问题。故事整理期间已移除的故事不会再收到新关联,刷新后的控件也不会因旧请求失效而一直禁用。
### 日常界面操作
@@ -306,6 +358,7 @@ Host 更新还会清理已确认停用的旧 Agent 程序和归属明确的孤
- 展开 Runtime 检查清单不再挤动聊天内容;文件列表和差异页的操作栏在滚动时保持可见。
- 笔记保存后退出编辑;从快速笔记跳到指定记录时,等待正文显示后再定位,减少落在错误位置的情况。
- 切回已查看的会话时,立即显示缓存的累计回复时长,再后台刷新;缓存限本次应用运行期间。
- 长对话滚出屏幕后再返回,推理、工具输入输出、引用和子代理详情保留展开选择;该状态限历史面板存续期间,不跨应用重启保存。跨屏选择、复制与滚动定位也作了修复,跳到底部恢复平滑滚动。
- Mermaid 大图放大后,顶部和左侧等边缘仍可通过滚动、拖动到达,窄窗口中的控件可以换行。
- Git 分支选择器悬浮在工作区上方,不再把文件列表和提交历史向下挤动;项目选择菜单可以浮出侧栏,长名称与路径有更多显示空间。
- 文件差异页将查看文件、刷新和复制路径集中到路径旁,窄面板中可以换行;只有未暂存改动时,省去重复的分区标题。浏览器“前往”按钮改为紧凑图标,保留悬停提示。
@@ -324,6 +377,7 @@ Host 更新还会清理已确认停用的旧 Agent 程序和归属明确的孤
- MCP 编辑器和图片预览可以在父弹窗上正常操作;项目弹窗的 Escape 只处理当前弹窗,帮助层覆盖浏览器时不再被原生页面遮挡。
- HTML 全屏预览恢复键盘进入与 Escape 返回,继续保持原有沙箱限制。
- 窄工作栏的添加按钮不再被滚动箭头遮挡,模型连接协议文字保持可读;DS 入口移除常驻说明,重新打开和停止服务操作继续保留。
- 多页签工作栏的滚动箭头不再遮挡关闭按钮。DS 网页和服务操作名称更明确,停止确认与取消按钮同行显示,减少输入区跳动。
### 回顾运行期间的控制
@@ -1,5 +1,40 @@
# 0.13.8 起综合宣传稿核查记录
## 2026-10-05 截至 e50ca6d1 的增量核查
本轮最终范围 `9527402..e50ca6d1`,共 60 个提交;全文范围为 `v0.13.7..e50ca6d1`。初次固定至 `bbf24c28` 的 58 个提交,收尾补查新产生的两项提交。本地桌面 `v0.15.11` 与 Agent `agent-v0.15.4` 同指向 `78afec77`,上一桌面 `v0.15.10` 指向 `ff61da28`。Telegram、自定义回顾日期、历史耗时缺失提示及最后一组运行修复在 0.15.11 标签之后,均已提交,不将其混入该标签,也不使用开发状态标签。以下取代旧节的当前状态描述,旧节仅作历史依据。
沿用用户确认的标题与综合稿结构,继续按新增、改进、修复组织。Telegram 与文件导入各设短节,统一执行和项目菜单放在改进段前部;监督、图谱、性能与统计合入原有章节,避免重复介绍。打包、构建、测试及纯架构维护不进入宣传正文。
| 提交 | 入文内容 | 核查依据与边界 |
| --- | --- | --- |
| `b4df493b`、`e50ca6d1` | Telegram 首次配置顺序、监督临时失败重试 | 已提交中英文通道文案,先空白 ID 启用保存,再 /whoami 获取自己的 ID,测试连接不启动收信。`supervision-production.ts` 的包装层最多额外一次临时失败重试,429、部分 5xx、明确网络/流中断;排除认证、配置、额度、超时、取消、容量错误。等待可取消,失败用量保留,每次尝试独立超时;不扩大为所有 Runtime 全局重试或严格最多两次 HTTP 请求。替换旧稿网络错误不重试的表述。历史 177 项通过/1 跳过为假 fetch 和 SQLite 验证,非本轮模型实测 |
| `cf598121`、`bbf24c28` | Telegram 私聊、原样保存重连 | 已提交 `telegram-channel-driver.ts`、`ChannelSettingsSection.tsx` 与 Telegram PRD/progress。自建单 Bot、数字用户 ID 白名单、通道项目、普通私聊文字输入与最终文字回复,系统代理及恢复;桌面常驻。没有群聊、媒体输入或附件发送,真实 Bot+模型完整联调仍待完成,正文标注 C02 |
| `56ebd40b` | 统一执行,删除旧 Ask/Execute 限制 | `model-runtime.ts`、Runtime 接线与 `unified-execution/logic-design.md`。按所选 Runtime、能力及账户权限执行;专用文本分析无工具,各后端支持范围保留。同步修正图谱、Obsidian、原生终端、图片保存、配置工具、继续讨论及文件搜索旧段落 |
| `e89db96b`、`658e7f10`、`56ebd40b` | 项目与会话统一菜单 | `WorkspaceMenu.tsx`、`workspace-menu-selectors.ts`。打开默认全部项目,分类搜索、悬停预览、最近 10 个会话与活动、归档恢复、返回上次会话;已完成筛选不受最近 10 条限制。不沿用旧 UI 文档中默认仅预览当前项目的描述 |
| `56ebd40b`、`49fd0d30`、`ee186f9f` | 文件导入、时间排序、项目目录 | `workspace-import.ts`、`WorkspaceFilesPanel.tsx`、项目契约及 SSH 目录浏览。本机文件导入本地/远端,同名保留,单项失败继续;远程要求 Agent 0.15.4,每目录排序限已读最多 500 条。新本地项目必选目录,远程支持目录符号链接。成果页退出但记录保留,不自动将历史成果复制到目录 |
| `48d7526b`、`2c2b6c31` | 监督专用文本模型与会话图谱开关 | `HeartbeatCenter.tsx`、`supervision-production.ts`、`Composer.tsx`、IPC 和原生客户端。监督模型为全局设置,未指定跟随默认,失效或缺密钥报错。会话开关缺省允许查询,仍受总开关与分配约束;关闭不排除会话被回顾、不删除记录,开启不自动注入上下文 |
| `126dbc57` | 自定义最近天数与起止日期 | `SupervisorWorkspace.tsx` 日期计算和提交路径。最近天数按每 24 小时计算;指定日期按本地日界,包含结束日,今天截至提交时刻。普通回顾仍增量,需要重新分析时选择重新整理 |
| `577210a8`、`b9186a61`、`ea3d607f` | 共享历史选择、保留未归属事件、螺旋配色与密度 | `story-graph-3d-model.ts`、`StoryGraph3D.tsx`、数据库选定回顾查询和监督界面。所选历史事件与当前故事关系分开;主故事颜色仅覆盖实际事件区间,空档中性;半径按小时消息量和圈时长归一,不代表工时、生产力或小时内分布 |
| `4d18794e`、`d0877d92`、`0cd5da33`、`75e50fc1` | 运行耗时、缺失提示、OpenCode 命中率及会话汇总修复 | `assistant-database.ts`、`use-execution-stats.ts`、`token-usage.ts`。模型与工具执行累计,等用户暂停,并行相加;旧历史不补算,缺失零与完整零区分。OpenCode 分母含 input/cacheRead/cacheWrite,重新查询校正,不宣传缓存性能提高;去除前 5,000 会话统计限制 |
| `10c51628`、`f4458afe`、`b2eb86a0`、`68cc0792`、`010aafb6`、`41be9f80`、`2e3d2e77`、`651475d1`、`492f1d20` | 文档后台解析、终端合批、列表窗口化、启动与会话刷新 | 文档 worker、终端服务、窗口化列表、Composer、KeepAliveRoute、任务 store。描述减少重复处理的具体场景,不给通用倍数,不宣称所有数据库操作已异步化;任务/成果 store 抽取不单独计新功能 |
| `6aab78b9`、`352da66d`、`bf9850a6`、`0dd4d1d6` | 活动记录增量保存与分页、数据库处理 | 活动 store/sync/repository、只读 worker、数据库迁移。Renderer 不再持有整份历史,后台汇总计数,退出裁剪页而不删历史。高频本地 NORMAL 写入的断电持久性边界已加入升级说明,关键生命周期/远程确认等仍保留 FULL |
| `805acc3f`、`a620c226`、`bbf24c28` | 长列表阅读状态、笔记抽屉、消息展开保留 | `message-expansion.tsx`、聊天/笔记窗口化与终端路径。展开状态只在历史面板存续期保留;跨屏选择复制、滚动锚点、跳到底部和窄屏笔记入口改善。不承诺跨重启恢复展开状态 |
| `bbf24c28` | Runtime、监督、通道故障修复 | `agent-owned-acp-prompt.ts`、`protocol-server.ts`、`opencode-runtime.ts`;远程启动可取消且不阻塞其他会话,本地提交失败及时结束。`supervision-production.ts` 修复旧记录提及的 batch 未转发,发布前暂停与故事目的地校验;旧请求释放后控件可用。通道图片结果校验、钉钉业务错误进入重试。Telegram 仍不发送图片。远程修复需包含此提交的 Agent |
| `629e8254` | PPTX OCR 错误来源与位置 | 解析路径及 OCR 错误诊断;实际服务、幻灯片与图片位置。外部服务 HTTP 500 本身未因此修复 |
| `55f554d1`、`2adee136`、`78beade2`、`29a134b8`、`74efbcdc`、`0e9c9d49`、`b28ddd4b` | 控件、焦点、DS 操作及设置布局 | 合入界面简述;工作栏箭头不遮关闭按钮,停止确认同行。间距、代码抽取、缓存预算不逐项包装成新能力,b28ddd4b 中上一稿文档不重复计新功能 |
| `ae4c6d24` | 旧 Runtime 缓存清理,仅内部记录 | 属于磁盘维护,按用户偏好不写入功能宣传,也不当作通用提速结论 |
| `9d03217f`、`10065da8`、`942fe320`、`39fc15ef`、`59d36c22`、`91edcf5e`、`e205e909` | 架构、性能报告和官网同步,仅内部依据 | 性能报告涵盖部分区间外工作,受控测量不能整体归因本轮;官网改文案不列桌面新功能 |
| `739f2670`、`6b634c5d`、`5d0ca44b`、`273a7fa7`、`362fe504`、`ff61da28`、`245e296a`、`29af4090`、`4e72aa04`、`78afec77` | lint、测试与发布维护 | 用于版本与历史验证依据,不进入宣传正文 |
核查终点数据库 schema 61,Runtime 设置 22,应用设置 12,通道设置 3;0.15.11 标签数据库为 60。升级提醒保留完整用户数据、设置、数据库/WAL 和 notes 成套备份,说明大库转换等待与旧客户端回退限制,不承诺固定空间回收比例。版本号文件仍为 0.15.11/0.15.4,不据此推定后续提交已发布。
开始时工作区已有 Telegram 技术设计和两份 i18n 修改,核查期间又出现其他源码修改;均保留,不作为本次已提交依据。故事图谱检索得到自动归纳的宣传稿经验,提示区分现有能力与预览;实际内容以本次 Git 和生产源码核对为准。
本轮只做静态事实核查与文档验证,产品测试、构建、真实模型请求和新截图均为 0。提交中的 6136 测试通过、7 次模型调用及 Linux Host 验证属于既有记录,不写成本轮执行;Telegram 完整真实联调另有缺口。现有原型图保持示意标注,生产截图和公开下载信息仍待核验。未新增图片链接,未提交、推送或对外发布。
独立复核修正了旧稿“任务统计转到后台查询”的过时机制说明,改为当前的按运行状态累计。deai-writing 扫描阻断项 0、复核项 18;保留必要的版本、升级、素材与功能边界说明,以及模型理解能力的限定。文档差异检查通过,仅有既有 LF/CRLF 行尾提示。
## 2026-10-03 截至 9527402 的增量核查
本轮固定范围 `b02ce31..9527402`,共 45 个提交;全文范围为 `v0.13.7..9527402`。桌面 `v0.15.9` 与 Agent `agent-v0.15.3` 同指向 `9527402`。沿用用户确认的标题和综合稿结构,直接更新已授权草稿;未在线核验公开安装包和下载信息。以下记录取代旧节中的当前状态描述,旧节保留为历史依据。
+1 -1
View File
@@ -386,4 +386,4 @@ Agent 专题使用对应的 `agent-v…` tag。
| 日期 | 目标 tag | 标题 | 状态 | 正文 | 发布链接 |
| --- | --- | --- | --- | --- | --- |
| 2026-09-19 | 未确定(unreleased) | GoodBuddy 更新预览:画布笔记、文档处理与应用管理 | 草稿,截图待补 | [综合更新稿](./2026-09-19-unreleased/article.md) | 未发布 |
| 2026-09-26(10-03 更新) | v0.15.9(9527402);汇总 v0.13.8 起的变化 | GoodBuddy 更新:故事图谱、Obsidian接入、Runtime原生终端 | 草稿,已补故事归类、时间螺旋、经验与心跳建议、助手按需检索、图片保存和长会话性能;原概念图待换为生产截图,公开发布信息待核验 | [综合更新稿](./2026-09-26-unreleased/article.md) | 未发布 |
| 2026-09-26(10-05 更新) | v0.15.11(78afec77)及之后截至 e50ca6d1;汇总 v0.13.8 起的变化 | GoodBuddy 更新:故事图谱、Obsidian接入、Runtime原生终端 | 草稿,已补 Telegram 私聊、统一执行、项目会话菜单、文件导入、自定义回顾日期、监督模型与会话图谱开关、临时失败重试、3D 和性能改进;Telegram 实际联调、生产配图及公开发布信息待核验 | [综合更新稿](./2026-09-26-unreleased/article.md) | 未发布 |
+12
View File
@@ -3806,6 +3806,11 @@ export class AssistantDatabase {
)`
)
.run(conversationId)
// Older pending manifests grouped tasks without their conversation owner.
database.prepare(`UPDATE supervision_review_sources SET conversation_id = ?
WHERE source IN (SELECT 'task:' || id FROM tasks WHERE conversation_id = ?)
AND run_id IN (SELECT id FROM supervision_runs WHERE status IN ('running', 'paused', 'failed'))`)
.run(conversationId, conversationId)
database
.prepare('DELETE FROM tasks WHERE conversation_id = ?')
.run(conversationId)
@@ -3814,6 +3819,12 @@ export class AssistantDatabase {
'DELETE FROM conversations WHERE id = ? AND channel IS NULL'
)
.run(conversationId).changes === 1
if (deleted) {
const reviews = this.supervisionReviewStore()
for (const row of database.prepare('SELECT DISTINCT run_id FROM supervision_review_sources WHERE conversation_id = ?').all(conversationId)) {
reviews.omitDeletedConversations(String(row.run_id))
}
}
database.exec('COMMIT')
if (deleted) this.executionTiming?.changed()
return deleted
@@ -9117,6 +9128,7 @@ export class AssistantDatabase {
const timestamp = new Date().toISOString()
database.exec('BEGIN IMMEDIATE')
try {
if (result.coverage && result.runId) this.supervisionReviewStore().assertComplete(result.runId, result.coverage.omittedSources ?? 0)
let story = database.prepare(
'SELECT id FROM story_lines WHERE scope_json = ? LIMIT 1'
).get(JSON.stringify(result.request.scope)) as { id: string } | undefined
+88 -7
View File
@@ -9,7 +9,9 @@ import type { SupervisionReviewBatch, SupervisionReviewProgress, SupervisionRevi
export const TIMELINE_CHECKPOINT_SCOPE = 'timeline'
export type ReviewConfiguration = SupervisionReviewSettings & { timeoutSeconds: number; concurrency: number; version: 1 }
export type ReviewState = { request: SupervisionRunRequest; config: ReviewConfiguration; initializing?: boolean; restartRequired?: boolean; phase?: SupervisionReviewProgress['phase']; stories?: SupervisionReviewProgress['stories'] }
export type ReviewState = { request: SupervisionRunRequest; config: ReviewConfiguration; initializing?: boolean; restartRequired?: boolean; omittedSources?: number; phase?: SupervisionReviewProgress['phase']; stories?: SupervisionReviewProgress['stories'] }
export class ReviewSourcesOmitted extends Error {}
// Only the manifest is materialized. Source bodies are read in bounded substrings.
export const supervisionReviewMigration = `
@@ -98,7 +100,8 @@ export class SupervisionReviewStore {
this.db.exec('DROP TABLE IF EXISTS temp.review_manifest; BEGIN')
try {
this.db.prepare(`CREATE TEMP TABLE review_manifest AS
SELECT s.source, review_revision(s.context) AS revision, s.project_id, s.conversation_id, s.sequence,
SELECT s.source, review_revision(s.context) AS revision, s.project_id,
CASE WHEN s.type = 'task' THEN COALESCE((SELECT t.conversation_id FROM tasks t JOIN conversations c ON c.id = t.conversation_id WHERE t.id = s.record_id), '') ELSE s.conversation_id END AS conversation_id, s.sequence,
length(s.body) AS length, CASE WHEN c.revision = review_revision(s.context) THEN c.processed_offset ELSE 0 END AS initial_offset
FROM supervision_review_current s LEFT JOIN review_checkpoints c
ON ? = 1 AND c.stage = 'supervisor' AND c.scope = '${TIMELINE_CHECKPOINT_SCOPE}' AND c.source = s.source
@@ -130,6 +133,7 @@ export class SupervisionReviewStore {
}
signal?.throwIfAborted()
this.db.prepare("UPDATE supervision_review_runs SET state_json = json_remove(state_json, '$.initializing') WHERE run_id = ?").run(runId)
this.omitDeletedConversations(runId)
} finally { this.db.exec('DROP TABLE IF EXISTS temp.review_manifest') }
}
@@ -144,13 +148,17 @@ export class SupervisionReviewStore {
unfinished(request: SupervisionRunRequest): string | undefined {
return this.db.prepare(`SELECT s.id FROM supervision_runs s JOIN supervision_review_runs r ON r.run_id = s.id
WHERE s.trigger = 'heartbeat' AND s.scope_json = ? AND s.status IN ('failed', 'running')
AND COALESCE(json_extract(r.state_json, '$.restartRequired'), 0) = 0
AND (COALESCE(json_extract(r.state_json, '$.restartRequired'), 0) = 0 OR EXISTS (
SELECT 1 FROM supervision_review_sources source WHERE source.run_id = s.id AND source.conversation_id != ''
AND NOT EXISTS (SELECT 1 FROM conversations c WHERE c.id = source.conversation_id)
AND instr(s.error, '(' || source.source || ');') > 0))
ORDER BY s.created_at LIMIT 1`).get(JSON.stringify(request.scope))?.id as string | undefined
}
resume(runId: string, signal?: AbortSignal): void {
const status = this.db.prepare('SELECT status FROM supervision_runs WHERE id = ?').get(runId)?.status
if (status === 'cancelled') throw new Error('SUPERVISION_REVIEW_CANCELLED: 此回顾已取消,不能继续。请开始新的回顾。')
this.omitDeletedConversations(runId)
if (this.load(runId).restartRequired) throw new Error('Review source changed or was removed; start a new review')
const changed = this.db.prepare(`UPDATE supervision_runs SET status = 'running', error = NULL, completed_at = NULL
WHERE id = ? AND status IN ('paused', 'failed', 'running')`).run(runId)
@@ -159,7 +167,7 @@ export class SupervisionReviewStore {
this.initializeSources(runId, this.load(runId), signal)
return
}
// A changed/deleted source cannot be silently counted as covered. Saved facts remain accessible.
// Existing sources must still match their frozen versions.
let after = ''
for (;;) {
const rows = this.db.prepare('SELECT source, revision FROM supervision_review_sources WHERE run_id = ? AND source > ? ORDER BY source LIMIT 200').all(runId, after)
@@ -167,6 +175,7 @@ export class SupervisionReviewStore {
for (const row of rows) {
signal?.throwIfAborted()
if (this.currentSource(String(row.source), 0, 0)?.current_revision !== row.revision) {
if (this.omitDeletedConversations(runId)) return this.resume(runId, signal)
this.sourceChanged(runId, String(row.source))
}
after = String(row.source)
@@ -209,6 +218,7 @@ export class SupervisionReviewStore {
}
chunk(runId: string, projectId: string, conversationId: string, config: ReviewConfiguration): SupervisionEvidence[] {
this.omitDeletedConversations(runId)
const { request } = this.load(runId)
const evidence: SupervisionEvidence[] = []
let remaining = config.batchCharacters
@@ -226,7 +236,10 @@ export class SupervisionReviewStore {
if (!rows.length) break
for (const metadata of rows) {
const current = this.currentSource(metadata.source, metadata.processed_offset, Math.min(remaining, 8000))
if (!current || current.current_revision !== metadata.revision) this.sourceChanged(runId, metadata.source)
if (!current || current.current_revision !== metadata.revision) {
if (this.omitDeletedConversations(runId)) return this.chunk(runId, projectId, conversationId, config)
this.sourceChanged(runId, metadata.source)
}
const row = { ...metadata, ...current }
const locator = JSON.parse(row.locator) as Record<string, unknown>
const messageId = String(locator.messageId ?? row.source)
@@ -278,10 +291,13 @@ export class SupervisionReviewStore {
save(runId: string, projectId: string, conversationId: string, evidence: SupervisionEvidence[], output: SupervisionSummaryOutput): void {
this.db.exec('BEGIN IMMEDIATE')
try {
this.omitDeletedConversations(runId)
if (!this.hasSources(runId, evidence)) { this.db.exec('COMMIT'); return }
const advance = this.db.prepare(`UPDATE supervision_review_sources SET processed_offset = ?
WHERE run_id = ? AND source = ? AND revision = ? AND processed_offset = ?`)
for (const item of evidence) {
const locator = item.locator!
if (this.currentSource(String(locator.source), 0, 0)?.current_revision !== locator.revision) this.sourceChanged(runId, String(locator.source))
if (advance.run(Number(locator.end), runId, String(locator.source), String(locator.revision), Number(locator.start)).changes !== 1) {
throw new Error('Review coverage has a gap or duplicate batch')
}
@@ -293,6 +309,11 @@ export class SupervisionReviewStore {
} catch (error) { this.db.exec('ROLLBACK'); throw error }
}
hasSources(runId: string, evidence: SupervisionEvidence[]): boolean {
const source = this.db.prepare('SELECT 1 FROM supervision_review_sources WHERE run_id = ? AND source = ?')
return evidence.every(item => source.get(runId, String(item.locator!.source)))
}
batches(runId: string, limit = 10, offset = 0): SupervisionReviewBatch[] {
return this.db.prepare(`SELECT * FROM supervision_review_batches WHERE run_id = ? ORDER BY project_id, conversation_id, rowid LIMIT ? OFFSET ?`)
.all(runId, limit, offset).map(row => ({ id: String(row.id), projectId: String(row.project_id), conversationId: String(row.conversation_id),
@@ -305,16 +326,76 @@ export class SupervisionReviewStore {
const batches = this.db.prepare(`SELECT COUNT(*) AS batches, COALESCE(SUM(characters), 0) AS characters
FROM supervision_review_batches WHERE run_id = ?`).get(runId)!
const state = this.load(runId)
const error = String(this.db.prepare('SELECT error FROM supervision_runs WHERE id = ?').get(runId)?.error ?? '')
const failedSource = /Review source changed or was removed \((.+)\);/.exec(error)?.[1]
const deletedFailure = failedSource && this.db.prepare(`SELECT 1 FROM supervision_review_sources s
WHERE s.run_id = ? AND s.source = ? AND s.conversation_id != ''
AND NOT EXISTS (SELECT 1 FROM conversations c WHERE c.id = s.conversation_id)`).get(runId, failedSource)
const navigation = this.db.prepare('SELECT COUNT(*) AS count FROM supervision_review_navigation WHERE run_id = ?').get(runId)!
return { runId, phase: state.phase, stories: state.stories, navigationNodes: Number(navigation.count), settings: state.config, restartRequired: state.restartRequired, batches: Number(batches.batches), characters: Number(batches.characters), sources: Number(sources.sources),
return { runId, omittedSources: state.omittedSources, phase: state.phase, stories: state.stories, navigationNodes: Number(navigation.count), settings: state.config, restartRequired: deletedFailure ? false : state.restartRequired, batches: Number(batches.batches), characters: Number(batches.characters), sources: Number(sources.sources),
remainingSources: Number(sources.remaining), complete: this.db.prepare("SELECT id FROM supervision_runs WHERE id = ? AND status IN ('completed', 'no_change')").get(runId) !== undefined }
}
assertComplete(runId: string): void {
assertComplete(runId: string, expectedOmissions?: number): void {
if (this.omitDeletedConversations(runId) || (expectedOmissions !== undefined && (this.load(runId).omittedSources ?? 0) !== expectedOmissions)) {
throw new ReviewSourcesOmitted('Deleted conversation sources omitted; rebuild review navigation')
}
if (this.load(runId).initializing) throw new Error('Cannot publish an incomplete source manifest')
if (this.progress(runId).remainingSources) throw new Error('Cannot publish incomplete coverage')
}
/** Only unpublished staging is disposable. No source bodies are read or restored here. */
omitDeletedConversations(runId: string): boolean {
if (!this.db.prepare("SELECT id FROM supervision_runs WHERE id = ? AND status IN ('running', 'paused', 'failed')").get(runId)) return false
if (this.load(runId).initializing) return false
const deleted = this.db.prepare(`SELECT s.source FROM supervision_review_sources s
WHERE s.run_id = ? AND s.conversation_id != ''
AND NOT EXISTS (SELECT 1 FROM conversations c WHERE c.id = s.conversation_id)`).all(runId)
if (!deleted.length) return false
this.db.exec('SAVEPOINT omit_review_sources')
try {
const removed = new Set(deleted.map(row => String(row.source)))
const affected = new Set(removed)
const invalid = new Set<string>()
// A mixed leaf cannot be filtered safely: its prose may combine every input.
// Reset its surviving sources and discard their other leaves to avoid duplicate coverage.
let changed = true
while (changed) {
changed = false
for (let offset = 0;; offset += 10) {
const batches = this.db.prepare(`SELECT b.id, json_group_array(json_extract(item.value, '$.locator.source')) AS sources
FROM (SELECT id, evidence_json FROM supervision_review_batches WHERE run_id = ? ORDER BY rowid LIMIT 10 OFFSET ?) b,
json_each(b.evidence_json) item GROUP BY b.id`).all(runId, offset)
if (!batches.length) break
for (const batch of batches) {
const sources = JSON.parse(String(batch.sources)) as string[]
if (invalid.has(String(batch.id)) || !sources.some(source => affected.has(source))) continue
invalid.add(String(batch.id))
for (const source of sources) affected.add(source)
changed = true
}
}
}
const invalidIds = JSON.stringify([...invalid])
this.db.prepare(`WITH RECURSIVE invalid(id) AS (
SELECT value FROM json_each(?) UNION
SELECT n.id FROM supervision_review_navigation n, json_each(n.children_json) child
JOIN invalid i ON i.id = child.value WHERE n.run_id = ?
) DELETE FROM supervision_review_navigation WHERE run_id = ? AND id IN (SELECT id FROM invalid)`)
.run(invalidIds, runId, runId)
this.db.prepare('DELETE FROM supervision_review_batches WHERE run_id = ? AND id IN (SELECT value FROM json_each(?))').run(runId, invalidIds)
for (const source of affected) this.db.prepare('UPDATE supervision_review_sources SET processed_offset = initial_offset WHERE run_id = ? AND source = ?').run(runId, source)
for (const source of removed) this.db.prepare('DELETE FROM supervision_review_sources WHERE run_id = ? AND source = ?').run(runId, source)
const error = String(this.db.prepare('SELECT error FROM supervision_runs WHERE id = ?').get(runId)?.error ?? '')
const failedSource = /Review source changed or was removed \((.+)\);/.exec(error)?.[1]
if (failedSource && removed.has(failedSource)) this.db.prepare("UPDATE supervision_review_runs SET state_json = json_remove(state_json, '$.restartRequired') WHERE run_id = ?").run(runId)
this.db.prepare(`UPDATE supervision_review_runs SET state_json = json_set(state_json,
'$.omittedSources', COALESCE(json_extract(state_json, '$.omittedSources'), 0) + ?, '$.phase', 'extracting') WHERE run_id = ?`).run(removed.size, runId)
this.db.exec('RELEASE omit_review_sources')
return true
} catch (error) { this.db.exec('ROLLBACK TO omit_review_sources; RELEASE omit_review_sources'); throw error }
}
commitCheckpoints(runId: string, request: SupervisionRunRequest): void {
this.assertComplete(runId)
if (!this.db.isTransaction) throw new Error('Review publication must be transactional')
@@ -433,3 +433,128 @@ it('does not let a heartbeat silently resume a review the user paused', async ()
expect(next.runId).not.toBe(pausedId)
expect(f.db.listSupervisionActivity().find(row => row.id === pausedId)?.status).toBe('paused')
})
it('omits a deleted conversation, its knowledge and tasks before resume without changing published history', async () => {
const f = await fixture([['Delete me'], ['Keep me']])
const deleted = f.conversations[0]!
f.sql.prepare('UPDATE messages SET metadata_json = ? WHERE id = ?').run(JSON.stringify({ sourceReferences: [
{ documentId: 'doc', documentName: 'Reference', snippet: 'Deleted knowledge', chunkId: 'chunk' }
] }), deleted.messages[0]!.id)
const taskId = randomUUID()
f.db.createTask({ id: taskId, conversationId: deleted.id, projectId: deleted.projectId,
title: 'Deleted task', instructions: 'Test', origin: 'user', visible: true })
f.db.updateTaskStatus(taskId, 'completed')
f.sql.prepare('UPDATE tasks SET created_at = ?, completed_at = ? WHERE id = ?').run(new Date(time).toISOString(), new Date(time).toISOString(), taskId)
await f.service().run(request)
const history = f.sql.prepare('SELECT * FROM supervision_results').all()
const historySources = f.sql.prepare('SELECT * FROM supervision_sources ORDER BY id').all()
const historyBatches = f.sql.prepare('SELECT * FROM supervision_review_batches ORDER BY id').all()
f.sql.exec("CREATE TRIGGER fail_publication BEFORE INSERT ON supervision_results BEGIN SELECT RAISE(ABORT, 'Publication failed'); END")
await expect(f.service().run(request)).rejects.toThrow('Publication failed')
const runId = String(f.sql.prepare("SELECT id FROM supervision_runs WHERE status = 'failed'").get()!.id)
const store = f.db.supervisionReviewStore()
const kept = store.batches(runId).filter(batch => batch.conversationId !== deleted.id)
expect(f.db.deleteLocalConversation(deleted.id)).toBe(true)
expect(store.progress(runId)).toMatchObject({ omittedSources: 3, sources: 1, batches: kept.length, restartRequired: undefined })
expect(store.batches(runId)).toEqual(kept)
f.sql.exec('DROP TRIGGER fail_publication')
const calls = f.summarize.mock.calls.length
f.db.close(); f.db.initialize(f.directory)
const result = await f.service().resume(runId)
expect(result).toMatchObject({ status: 'completed', coverage: { omittedSources: 3, sources: 1, complete: true } })
expect(f.summarize).toHaveBeenCalledTimes(calls)
expect(f.sql.prepare('SELECT * FROM supervision_results WHERE id = ?').get(String(history[0]!.id))).toEqual(history[0])
expect(f.sql.prepare('SELECT * FROM supervision_sources WHERE result_id = ? ORDER BY id').all(String(history[0]!.id))).toEqual(historySources)
expect(f.sql.prepare('SELECT * FROM supervision_review_batches WHERE run_id = ? ORDER BY id').all(String(history[0]!.run_id))).toEqual(historyBatches)
expect(() => f.db.getConversation(deleted.id)).toThrow('对话不存在')
expect(f.db.listSupervisionActivity().find(row => row.id === runId)?.reviewProgress?.omittedSources).toBe(3)
})
it.each(['leaf', 'navigation', 'publication', 'publication from another connection'] as const)('continues after deletion during %s without publishing deleted input', async phase => {
const f = await fixture([['Delete me'], ['Keep me']], { concurrency: 2 })
const base = f.summarize.getMockImplementation()!
f.summarize.mockImplementation(async input => ({ ...await base(input), summary: input.evidence.map(item => item.content).join(', ') }))
let deletedId: string | undefined
const remove = () => {
deletedId = f.conversations[0]!.id
if (phase === 'publication from another connection') f.sql.prepare('DELETE FROM conversations WHERE id = ?').run(deletedId)
else f.db.deleteLocalConversation(deletedId)
}
if (phase.startsWith('publication')) {
const save = f.db.saveSupervisionResult.bind(f.db)
vi.spyOn(f.db, 'saveSupervisionResult').mockImplementationOnce(result => { remove(); save(result) })
} else f.summarize.mockImplementation(async input => {
if (!deletedId && (phase === 'leaf' ? input.evidence.some(item => item.locator?.conversationId === f.conversations[0]!.id) : input.evidence[0]?.sourceType === 'note')) remove()
return { ...await base(input), summary: input.evidence.map(item => item.content).join(', ') }
})
const result = await f.service().run({ ...request, reanalyze: undefined })
expect(result).toMatchObject({ status: 'completed', coverage: { omittedSources: 1, sources: 1, batches: 1, complete: true } })
expect(result.output.summary).toBe('Keep me')
expect(f.db.listSupervisionResults()[0]!.summary).toBe('Keep me')
const sources = f.sql.prepare('SELECT locator_json FROM supervision_sources').all()
expect(sources).toHaveLength(1)
expect(JSON.parse(String(sources[0]!.locator_json)).conversationId).toBe(f.conversations[1]!.id)
expect(f.sql.prepare('SELECT source FROM review_checkpoints').all()).toEqual([{ source: `message:${f.conversations[1]!.messages[0]!.id}` }])
expect(f.sql.prepare('SELECT COUNT(*) AS n FROM supervision_review_navigation').get()!.n).toBe(0)
})
it.each(['before resume', 'in flight'] as const)('finishes with an explicit omission and no empty report when every source is deleted %s', async when => {
const f = await fixture([['Only source']], { concurrency: 1 })
const service = f.service()
let runId: string
if (when === 'before resume') {
f.summarize.mockRejectedValueOnce(new Error('Provider unavailable'))
await expect(service.run(request)).rejects.toThrow('Provider unavailable')
runId = f.db.listSupervisionActivity()[0]!.id
f.db.deleteLocalConversation(f.conversations[0]!.id)
} else {
f.summarize.mockImplementationOnce(async () => { f.db.deleteLocalConversation(f.conversations[0]!.id); return empty })
const result = await service.run(request)
runId = result.runId!
expect(result).toMatchObject({ status: 'no_change', coverage: { omittedSources: 1, sources: 0, complete: true } })
}
if (when === 'before resume') await expect(service.resume(runId)).resolves.toMatchObject({ status: 'no_change', coverage: { omittedSources: 1, sources: 0, complete: true } })
expect(f.db.listSupervisionResults()).toEqual([])
expect(f.sql.prepare('SELECT COUNT(*) AS n FROM review_checkpoints').get()!.n).toBe(0)
})
it('resets mixed-leaf survivors only and invalidates their dependent navigation transitively', async () => {
const f = await fixture([['Deleted'], ['Survivor'], ['Unrelated']], { concurrency: 1 })
const store = f.db.supervisionReviewStore()
const runId = f.db.startSupervisionRun(request)
store.initialize(runId, { request, config: f.config })
const evidence = f.conversations.map(c => store.chunk(runId, c.projectId, c.id, f.config))
store.save(runId, f.conversations[0]!.projectId, '', [...evidence[0]!, ...evidence[1]!], empty)
store.save(runId, f.conversations[2]!.projectId, f.conversations[2]!.id, evidence[2]!, empty)
const batches = store.batches(runId)
const mixed = batches.find(b => b.evidence.length === 2)!, kept = batches.find(b => b.evidence.length === 1)!
store.saveNavigation(runId, 'affected', [mixed.id], empty)
store.saveNavigation(runId, 'unaffected', [kept.id], empty)
store.saveNavigation(runId, 'root', ['affected', 'unaffected'], empty)
f.db.deleteLocalConversation(f.conversations[0]!.id)
expect(store.batches(runId)).toEqual([kept])
expect(store.navigation(runId, 'affected')).toBeUndefined()
expect(store.navigation(runId, 'root')).toBeUndefined()
expect(store.navigation(runId, 'unaffected')).toEqual(empty)
expect(store.progress(runId)).toMatchObject({ sources: 2, remainingSources: 1, omittedSources: 1 })
await f.service().resume(runId)
expect(f.summarize.mock.calls.filter(([input]) => input.evidence[0]?.sourceType !== 'note').map(([input]) => input.evidence.map(e => e.content))).toEqual([['Survivor']])
expect(store.batches(runId)).toContainEqual(kept)
})
it('recovers the earlier deletion failure but keeps archived and modified existing conversations as explicit failures', async () => {
const f = await fixture([['Source']])
const store = f.db.supervisionReviewStore()
const id = f.db.startSupervisionRun(request)
store.initialize(id, { request, config: f.config })
f.sql.prepare("UPDATE supervision_review_runs SET state_json = json_set(state_json, '$.restartRequired', json('true')) WHERE run_id = ?").run(id)
f.db.failSupervisionRun(id, `Review source changed or was removed (message:${f.conversations[0]!.messages[0]!.id}); start a new review`)
f.sql.prepare('DELETE FROM conversations WHERE id = ?').run(f.conversations[0]!.id)
expect(store.progress(id).restartRequired).toBe(false)
await expect(f.service().resume(id)).resolves.toMatchObject({ status: 'no_change', coverage: { omittedSources: 1 } })
const g = await fixture([['Archived source']])
g.summarize.mockRejectedValueOnce(new Error('Pause'))
await expect(g.service().run(request)).rejects.toThrow('Pause')
g.sql.prepare("UPDATE conversations SET status = 'archived' WHERE id = ?").run(g.conversations[0]!.id)
await expect(g.service().resume(g.db.listSupervisionActivity()[0]!.id)).rejects.toThrow('source changed')
})
+24 -1
View File
@@ -64,7 +64,8 @@ it('preserves every source, revision, Unicode length and checkpoint for global,
const id = f.db.startSupervisionRun(state.request)
await f.db.initializeSupervisionReview(id, state, new AbortController().signal)
const expected = rows.filter(row => scope.kind === 'global' || row.project_id === f.project.id).map(row => ({
source: row.source, revision: hash(row.context), project_id: row.project_id, conversation_id: row.conversation_id,
source: row.source, revision: hash(row.context), project_id: row.project_id,
conversation_id: row.type === 'task' ? f.conversations[0]!.id : row.conversation_id,
sequence: row.sequence, length: Array.from(String(row.body)).length,
initial_offset: !reanalyze && row.source === source.source ? 3 : 0,
processed_offset: !reanalyze && row.source === source.source ? 3 : 0
@@ -137,6 +138,28 @@ it('fails explicitly when the worker entry is missing instead of running the sca
expect(f.db.supervisionReviewStore().progress(id).sources).toBe(0)
})
it('worker resume omits hard-deleted owners including knowledge references and tasks', async () => {
const f = await fixture()
const id = f.db.startSupervisionRun(f.state.request)
await f.db.initializeSupervisionReview(id, f.state, new AbortController().signal)
f.sql.prepare('DELETE FROM conversations WHERE id = ?').run(f.conversations[0]!.id)
await f.db.resumeSupervisionReview(id, new AbortController().signal)
expect(f.db.supervisionReviewStore().progress(id)).toMatchObject({ sources: 210, remainingSources: 210, omittedSources: 212 })
expect(f.sql.prepare('SELECT COUNT(*) AS n FROM supervision_review_sources WHERE run_id = ? AND conversation_id = ?').get(id, f.conversations[0]!.id)!.n).toBe(0)
})
it('drops conversation sources deleted while the worker copies its initialization manifest', async () => {
const f = await fixture()
const id = f.db.startSupervisionRun(f.state.request)
f.sql.exec(`CREATE TRIGGER delete_during_manifest AFTER INSERT ON supervision_review_sources
WHEN (SELECT COUNT(*) FROM supervision_review_sources) = 200 BEGIN
DELETE FROM conversations WHERE id = '${f.conversations[0]!.id}';
END`)
await f.db.initializeSupervisionReview(id, f.state, new AbortController().signal)
expect(f.db.supervisionReviewStore().progress(id)).toMatchObject({ sources: 210, omittedSources: 212, remainingSources: 210 })
expect(() => f.db.getConversation(f.conversations[0]!.id)).toThrow('对话不存在')
})
it('publishes full worker-selected coverage atomically and skips it on the next production review', async () => {
const f = await fixture()
const pool = new SupervisionModelPool()
+33 -2
View File
@@ -9,7 +9,7 @@ import {
} from '../../shared/supervision-contracts'
import { reviewScope, type ReviewBatch } from './review-checkpoint'
import { createHash } from 'node:crypto'
import type { SupervisionReviewStore, ReviewConfiguration, ReviewState } from './supervision-review-store'
import { ReviewSourcesOmitted, type SupervisionReviewStore, type ReviewConfiguration, type ReviewState } from './supervision-review-store'
import type { SupervisionReviewExecution, SupervisionReviewProgress } from '../../shared/supervision-review-contracts'
export type SupervisorSummarizerRequest = {
@@ -361,6 +361,10 @@ export class SupervisorService {
controller.signal.throwIfAborted()
const context = !navigation && this.review!.context ? await this.review!.context(request, controller.signal) : undefined
controller.signal.throwIfAborted()
if (!navigation) {
db.omitDeletedConversations(runId)
if (!db.hasSources(runId, evidence)) throw new ReviewSourcesOmitted('Deleted conversation sources omitted')
}
activity.inFlight++
let raw: unknown
try {
@@ -384,6 +388,11 @@ export class SupervisorService {
throw new SupervisionOutputError(error instanceof Error ? error.message : String(error), { cause: error })
}
}
for (;;) {
const omittedBefore = db.progress(runId).omittedSources ?? 0
try {
db.omitDeletedConversations(runId)
if (db.progress(runId).remainingSources) db.setPhase(runId, 'extracting')
for (;;) {
if (controller.signal.aborted) return stopped()
const groups = db.groups(runId, config.concurrency)
@@ -398,13 +407,15 @@ export class SupervisorService {
const sized = attempt ? { ...config, batchCharacters: Math.max(500, Math.floor(config.batchCharacters / scale)),
batchMessages: Math.max(1, Math.ceil(config.batchMessages / scale)) } : config
const evidence = db.chunk(runId, group.projectId, group.conversationId, sized)
if (!evidence.length) throw new Error('Pending source produced no reviewable content')
if (!evidence.length) return
try {
const output = await summarize(evidence, false, candidates)
controller.signal.throwIfAborted()
db.save(runId, group.projectId, group.conversationId, evidence, output)
return
} catch (error) {
db.omitDeletedConversations(runId)
if (!db.hasSources(runId, evidence)) return
if (!(error instanceof SupervisionOutputError) || attempt >= outputRetries || controller.signal.aborted) throw error
}
}
@@ -413,7 +424,15 @@ export class SupervisorService {
const failure = results.find(result => result.status === 'rejected')
if (failure?.status === 'rejected') throw failure.reason
}
if (db.omitDeletedConversations(runId)) continue
const progress = db.progress(runId)
if (progress.remainingSources) continue
const checkOmissions = () => {
db.omitDeletedConversations(runId)
if ((db.progress(runId).omittedSources ?? 0) !== (progress.omittedSources ?? 0)) {
throw new ReviewSourcesOmitted('Deleted conversation sources omitted; rebuild review navigation')
}
}
if (!progress.batches) {
this.store.noChange?.(runId)
// Earlier events may still await assignment (an upgrade backlog or a failed attempt).
@@ -423,6 +442,7 @@ export class SupervisorService {
type Card = { id: string; output: SupervisionSummaryOutput }
db.setPhase(runId, 'summarizing')
const merge = async (left: Card, right: Card): Promise<Card> => {
checkOmissions()
const id = createHash('sha256').update(JSON.stringify([left.id, right.id])).digest('hex')
const cached = db.navigation(runId, id)
if (cached) return { id, output: cached }
@@ -431,10 +451,12 @@ export class SupervisorService {
// A navigation merge has fixed inputs; an unusable answer is asked again once before the run fails.
let output: SupervisionSummaryOutput
try { output = await summarize(evidence, true) } catch (error) {
checkOmissions()
if (!(error instanceof SupervisionOutputError) || controller.signal.aborted) throw error
output = await summarize(evidence, true)
}
controller.signal.throwIfAborted()
checkOmissions()
db.saveNavigation(runId, id, [left.id, right.id], output)
return { id, output }
}
@@ -459,6 +481,7 @@ export class SupervisorService {
let previousProject: string | undefined, previousConversation: string | undefined
let first: ReturnType<SupervisionReviewStore['batches']>[number] | undefined
for (let offset = 0;; offset += 10) {
checkOmissions()
const batches = db.batches(runId, 10, offset)
if (!batches.length) break
for (const batch of batches) {
@@ -477,6 +500,7 @@ export class SupervisorService {
await push(scopeStack, (await finish(projectStack))!)
const root = await finish(scopeStack)
controller.signal.throwIfAborted()
checkOmissions()
db.assertComplete(runId)
db.setPhase(runId, 'saving')
// A resumed run reuses leaves saved earlier; offer their projects' candidates too.
@@ -488,12 +512,19 @@ export class SupervisorService {
}
for (const projectId of savedProjects) await candidatesFor(projectId)
controller.signal.throwIfAborted()
checkOmissions()
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 } }
await this.store.save(result)
await this.organizeStories(db, runId, request, config, controller.signal)
return { ...result, coverage: { ...db.progress(runId), complete: true } }
} catch (error) {
db.omitDeletedConversations(runId)
if (error instanceof ReviewSourcesOmitted || (db.progress(runId).omittedSources ?? 0) !== omittedBefore) continue
throw error
}
}
} catch (error) {
if (controller.signal.aborted) return stopped()
this.store.fail?.(runId, error instanceof Error ? error.message : 'Review failed')
@@ -23,6 +23,7 @@ export function SupervisionReviewStages({ row, stopping }: { row: SupervisionAct
})}
</ol>
<p className="supervisor-activity__coverage">{t('activity.coverage', { batches: progress.batches, remaining: progress.remainingSources })}</p>
{!!progress.omittedSources && <p className="supervisor-activity__note">{t('activity.omittedSources', { count: progress.omittedSources })}</p>}
{!progress.complete && !progress.phase && <p className="supervisor-activity__note">{t('activity.stageUnknown')}</p>}
{row.supervisionStatus === 'running' && !stopping && <p className="supervisor-activity__note">{t('reviewSettings.inFlight', { count: progress.inFlight ?? 0 })}</p>}
</div>
@@ -17,6 +17,20 @@ const render = (ui: React.ReactNode) => {
}
afterEach(async () => { cleanup(); vi.useRealTimers(); vi.unstubAllGlobals(); await changeUiLocale('zh-CN') })
it.each(['zh-CN', 'en-US'] as const)('shows deletion omissions alongside surviving coverage in %s', async locale => {
await changeUiLocale(locale)
vi.stubGlobal('goodbuddy', { supervision: { activity: vi.fn().mockResolvedValue([{
...row, status: 'no_change', supervisionStatus: 'no_change', reviewProgress: {
runId: 'saved-run', batches: 0, characters: 0, sources: 0, remainingSources: 0, omittedSources: 3, complete: true
}
}]) } })
render(<SupervisorActivity active projects={[]} onOpenResult={vi.fn()} />)
const notices = await screen.findAllByText(i18nResources[locale].heartbeat.activity.omittedSources.replace('{{count}}', '3'))
expect(notices[0]).toBeVisible()
expect(notices).toHaveLength(2)
expect(screen.queryByRole('button', { name: i18nResources[locale].heartbeat.reviewSettings.resume })).not.toBeInTheDocument()
})
it.each(['zh-CN', 'en-US'] as const)('retains operational failure details and recovery in %s', async locale => {
await changeUiLocale(locale)
const copy = i18nResources[locale].heartbeat
+1
View File
@@ -228,6 +228,7 @@ export function SupervisorActivity({ active, projects, onOpenResult, configId, c
{row.timeRange && <div><dt>{t('supervisor.period')}</dt><dd>{date(row.timeRange.from)} / {date(row.timeRange.to)}</dd></div>}
</dl>
{row.reviewProgress && <p>{t('reviewSettings.progress', { batches: row.reviewProgress.batches, characters: row.reviewProgress.characters, remaining: row.reviewProgress.remainingSources })}</p>}
{!!row.reviewProgress?.omittedSources && <p>{t('activity.omittedSources', { count: row.reviewProgress.omittedSources })}</p>}
{row.reviewProgress?.navigationNodes !== undefined && <p>{t('activity.navigationSaved', { count: row.reviewProgress.navigationNodes })}</p>}
{row.summary && <p className="supervisor-activity__summary">{row.summary}</p>}
{row.reviewProgress?.settings && <details><summary>{t('reviewSettings.configuration')}</summary>
@@ -63,6 +63,7 @@ export const heartbeat = {
stageStates: { completed: 'Complete', running: 'In progress', failed: 'Failed', paused: 'Paused', cancelled: 'Cancelled', cancelling: 'Cancelling', pausing: 'Pausing', pending: 'Not started', unknown: 'Not recorded', skipped: 'Not needed', no_change: 'No changes' },
stageUnknown: 'This historical run has no saved stage. Zero remaining sources does not mean the review was published.',
coverage: '{{batches}} saved batches · {{remaining}} sources remaining',
omittedSources: '{{count}} sources skipped because their conversations were deleted. Coverage includes only retained sources. If none remain, the review finishes without an empty report.',
navigationSaved: '{{count}} navigation merges saved',
resumeMerge: 'Continue merging', resumeSave: 'Retry publication', runDetails: 'Run details and configuration',
treePageHint: 'Expand projects and conversations to browse this page of saved batches. Tasks without a saved result are not listed here.',
@@ -60,6 +60,7 @@ export const heartbeat = {
stageStates: { completed: '已完成', running: '进行中', failed: '失败', paused: '已暂停', cancelled: '已取消', cancelling: '正在取消', pausing: '正在暂停', pending: '未开始', unknown: '未记录', skipped: '无需执行', no_change: '无变化' },
stageUnknown: '此历史运行未保存阶段信息。剩余来源为零不代表回顾已发布。',
coverage: '已保存 {{batches}} 批 · 剩余 {{remaining}} 个来源',
omittedSources: '会话已删除,已跳过 {{count}} 个来源;覆盖统计仅包含保留的来源。没有剩余来源时结束回顾,不生成空报告。',
navigationSaved: '已保存 {{count}} 次导航合并',
resumeMerge: '继续合并摘要', resumeSave: '重试发布', runDetails: '运行详情与配置',
treePageHint: '按项目、会话展开本页已保存批次。这里不包含尚未保存结果的任务。',
@@ -37,6 +37,8 @@ export type SupervisionReviewProgress = {
characters: number
sources: number
remainingSources: number
/** Sources excluded because their owning conversation was deleted; not reviewed coverage. */
omittedSources?: number
complete: boolean
restartRequired?: boolean
inFlight?: number