diff --git a/docs/architecture/desktop-storage-direction.md b/docs/architecture/desktop-storage-direction.md new file mode 100644 index 00000000..a3fd573c --- /dev/null +++ b/docs/architecture/desktop-storage-direction.md @@ -0,0 +1,75 @@ +# Desktop Storage Direction + +## Status and scope + +2026-10-05: proposed target for the remaining PERF-15/PERF-16 work. The user has +requested documentation first, followed by bounded local fixes and a commit. +The full storage-service migration remains a separate discussion after those fixes. +Current desktop storage still has Main-owned business writes and selected worker +reads; this document does not describe a completed migration. + +## Target responsibilities + +- Renderer owns presentation, domain stores and disposable visible-data caches. + It consumes summaries, pages and incremental notifications, not full database copies. +- Electron Main owns windows, trusted IPC, credentials, OS integration and service + lifecycle. Heavy SQL, JSON assembly and document processing execute elsewhere. +- A desktop storage utility process would own business database connections and + complete domain transactions. Retain the existing database files, schemas and + durability distinctions. One write owner per database does not require merging databases. +- Existing read-only workers can serve heavy queries under the storage service's + lifecycle. Do not place every read and write behind one long-running global job. +- Parsing, OCR, model inference and Runtime execution remain separate workloads. + Remote Agent databases remain remote; desktop persistence acknowledges remote + events only after the required local commit succeeds. + +The service boundary consists of domain operations such as publishing a review or +committing an event batch. Do not expose arbitrary SQL, transport transaction +callbacks, or split an atomic transaction across individual IPC calls. Synchronous +SQLite inside its dedicated execution environment can remain appropriate. + +## Correctness and lifecycle + +Preserve write-before-read ordering, result/checkpoint atomicity, cancellation +settlement, event deduplication and commit-before-ACK. A rejected caller Promise +does not establish that a writer stopped. Reuse existing operation IDs and unique +constraints to resolve uncertain outcomes; do not blindly replay mutations or add +a general recovery journal. + +Open business connections after required upgrades. Stop producers before draining +required writes and closing storage. A failed storage process must not silently +fall back to Main SQL or an empty persistent-data replacement. Keep bounded requests +and explicit failure/retry behavior without adding product approval gates. + +## VS Code comparison + +VS Code is a reference for service separation, asynchronous work, lazy activation, +cached UI state and shutdown coordination, not a requirement to copy its process map. +Its application/profile/workspace state connections are owned by Electron Main; +the backend uses asynchronous `@vscode/sqlite3`, while cached reads are synchronous +and writes are batched. GoodBuddy's relational history, source bodies and durable +remote-event acknowledgements require different payload and persistence boundaries. + +Sources reviewed on 2026-10-05 (upstream main, not a pinned performance comparison): + +- [Connection ownership](https://github.com/microsoft/vscode/blob/main/src/vs/platform/storage/electron-main/storageMain.ts) +- [SQLite backend](https://github.com/microsoft/vscode/blob/main/src/vs/base/parts/storage/node/storage.ts) +- [Cache and flush lifecycle](https://github.com/microsoft/vscode/blob/main/src/vs/base/parts/storage/common/storage.ts) +- [Extension hosts](https://code.visualstudio.com/api/advanced-topics/extension-host) + +## Current implementation boundary + +The immediate work reuses existing workers, narrows Supervisor loading and payloads, +optimizes measured queries, virtualizes lists, and removes repeated digest/snapshot +work. It does not add the utility process, convert every storage interface, or claim +exclusive ownership of assistant.sqlite. Read-only workers plus remaining Main writes +are an explicit transition state. + +Full migration should proceed through verified domain operations and converge by +database ownership. Do not leave undocumented competing write paths. Completion +requires no Main-owned desktop business SQL, transactions wholly inside the owner, +responsive reads/writes under heavy work, and validated startup/cancellation/shutdown. + +The [local implementation record](../review/kiss-local-fixes-2026-10-05.md) owns this +round's scope and measurements. The [performance principles](./performance-principles.md) +remain the acceptance criteria; regression ceilings are not equivalent to those budgets. diff --git a/docs/architecture/performance-principles.md b/docs/architecture/performance-principles.md index d03857f1..29cfe01b 100644 --- a/docs/architecture/performance-principles.md +++ b/docs/architecture/performance-principles.md @@ -141,3 +141,5 @@ Main 负责窗口、生命周期、权限和消息转发。数据库查询、文 | P9 | 性能阈值还没有接入 CI,也没有真实低端机的基线 | 2026-10-04 补充:监督回顾的来源初始化和继续前版本校验已移入现有 Worker 入口的独立连接,扫描不持有写锁,清单按 200 条提交,生产失败不回退 Main 同步扫描。最终发布事务仍在 Main;实现与实测边界见[监督者进度](../features/conversation-supervision/progress.md#2026-10-04-回顾初始化阻塞修复)。 + +2026-10-05 补充:监督者 UI 的概览、图谱、故事读取也已迁到现有只读 Worker;概览按需加载、列表窗口化及摘要缓存见[KISS 局部修复](../review/kiss-local-fixes-2026-10-05.md)。这与工具侧 Story Graph 读取是不同入口。Main 最终发布及部分大响应仍有超预算样本,不能把局部完成视为全项达标。存储服务目标另见[桌面存储方向](./desktop-storage-direction.md),本轮不实施全面迁移。 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 2cea1473..563f15fc 100644 --- a/docs/features/assistant-workbar/runtime-process-reuse-technical-design.md +++ b/docs/features/assistant-workbar/runtime-process-reuse-technical-design.md @@ -297,6 +297,12 @@ Agent 当前 Runtime installation + 兼容配置 发送 Prompt;原生调用迟到返回的 Session 只关闭自身,不能回收仍服务其他 binding 的共享进程。原生握手本身仍由进程 owner 共享,一个等待者退出不取消其他等待者。 +模型路由撤销不等待原生 HTTP 响应后才释放 backend 控制队列;broker 关闭、凭据释放 +及 terminal 提交仍按原顺序执行。启动准备保存原 transport 和取消 signal,安装完成时 +若已取消,按原 Session/operation 再撤销一次,避免早先清理被迟到安装覆盖。撤销沿用 +helper 的 operation 匹配检查,不删除同 Session 的其他 operation。HTTP 沿用 10 秒 +超时,进程退出或 transport dispose 中止未完成请求;无需新的持久状态或协议阶段。 + 原生 `chat.message` 在模型请求前设置该 Session 的工具与模型路由,不再按产品模式 过滤 Shell、编辑或写入;请求级端点与子会话归属仍独立。 OpenCode 1.18.29 的 ACP 不转发未注册子 Session 的权限请求,且原生子代理只继承父 diff --git a/docs/features/conversation-supervision/README.md b/docs/features/conversation-supervision/README.md index 6d7ed5d3..26f18e41 100644 --- a/docs/features/conversation-supervision/README.md +++ b/docs/features/conversation-supervision/README.md @@ -24,6 +24,7 @@ | [回顾分块调度设计](./review-scheduling-design.md) | 第 0 节定义生产分页、批次持久化、续跑、当前设置及剩余边界;其余章节保留改造前基线、目标设计和原型证据 | | [故事线模型改造设计](./storyline-model-design.md) | 全局 › 项目 › 功能 › 子线索的故事层级、故事归属与经验抽取阶段、用户调整及实施顺序(目标设计) | | [实施进度](./progress.md) | 已验证实现、验证证据和当前边界 | +| [Renderer 修复与测量](./frontend-progress.md) | K01、K03、K04 的界面回归、源码 Electron 对照及测量限制 | | [故事线图谱设计](../story-graph/prd.md) | 图谱对象、环绕时间轴与实体交互 | 生产入口已接通完整来源分页、叶子持久化、持续处理至完成、活动页主动暂停/继续及批次详情;设置可调页大小、批次正文/消息上限、单次响应容量、两个模型超时和共享并发。实际合同及限制见[生产接线](./review-scheduling-design.md#0-生产接线与剩余边界)。真实 DeepSeek 验证已覆盖一个完整的 27 条消息会话;较早的 129 条消息实验仍未完成。两次实验共 5 次付费请求,详情见进度。原始 AbortError 原因尚未由日志确定。 diff --git a/docs/features/conversation-supervision/frontend-progress.md b/docs/features/conversation-supervision/frontend-progress.md new file mode 100644 index 00000000..87d5dcb8 --- /dev/null +++ b/docs/features/conversation-supervision/frontend-progress.md @@ -0,0 +1,99 @@ +# Renderer 修复与测量 + +最终集成补注:本轮暂存内容在排除并行功能的隔离工作树通过完整验收,6,212 项通过、0 失败、87 跳过;类型检查与 lint 通过。下文保留分组与早期失败记录,最终提交验收以[统一记录](../../review/kiss-local-fixes-2026-10-05.md#最终检查)为准。 + +## 2026-10-05:K01、K03、K04 + +对应[局部修复清单](../../review/kiss-local-fixes-2026-10-05.md)及[性能原则](../../architecture/performance-principles.md)的 P5、P6、P7、P8、P10。此处只记录 Renderer;Main 查询、Worker、发布事务和数据库验证由[实施进度](./progress.md)记录。 + +`SupervisorWorkspace` 首次工作回顾不再请求图谱。进入图谱页时按所选结果读取,内存中只保留当前结果的一份图谱;普通页签切换复用,刷新、历史导航和修改后的刷新清除缓存。图谱错误不阻止阅读回顾,失效请求不能重新填入旧缓存。来源正文继续在点击后读取,未改变 IPC DTO。 + +工作回顾与图谱使用同一份故事读取状态,按范围和刷新版本复用。摘要在 workspace 中按故事快照和时间区间计算一次,子组件直接渲染该结果;父子关系索引替代逐故事全量扫描。保留直接子线索聚合、孤立子线索、跨项目关联、时间边界及排序语义。App 只移除了监督者刷新中的成果列表读取,未拆分全局状态或新增领域 store。 + +事件、实体、关系列表使用 `useListWindow`,保留选中和焦点行,按实际行高计算占位。新增组件处理跨窗口的方向键、Home/End、Tab/Shift+Tab 和选择,完整记录仍可滚动访问。原生 Electron 回归发现,窗口切换把已渲染行换成估计高度的占位后,末条焦点行会移出视口;共享 hook 已补可见焦点保护,并验证主动滚离焦点行时不会被拉回。该行为也有独立共享 hook 回归。 + +## 回归证据 + +修改前运行新用例,三项均按预期失败:概览调用了 `graph`;同一故事快照计算摘要两次;600 个事件全部挂载。修改后,监督者、心跳中心、摘要及共享列表相关回归通过。历史结果、迟到图谱和来源响应、图谱失败与重试、实体修改后重新读取、来源正文按需读取均有覆盖。App 集成回归验证首次和缓存路由返回不再重读成果列表。 + +最后复查补充了历史结果已删除的回归:显式结果 ID 查询为空时,工作回顾显示可重试错误,重试仍使用原 ID,不退回最新结果,也不请求图谱。该失败分支在修复前已运行并失败,修复后单独验证;不改变下述性能 fixture 的成功结果路径。 + +`tests/supervisor-layout.electron.test.ts` 在 Vite 中运行生产组件,新增 `?windowed=1&recap=1` 固定数据,包含各 600 条事件、实体和关系。原生键盘遍历跨越未挂载行,检查首尾可达、焦点可见、选中状态、滚动后视口有内容;随后执行来源读取、回顾与图谱切换、历史切换、实体编辑和 390px 窗口检查。常规 1440、1024、390px、浅深主题及密集图谱布局场景继续通过。 + +测试使用真实 Chromium 布局、鼠标及键盘输入和生产 React 组件。bridge 返回固定合成数据,编辑写入测试内存;这不是生产 Main、SQLite、模型调用的端到端证明。完整 App 路由由 jsdom 集成用例覆盖。未运行模型请求、生产构建、打包或已打包应用探针。 + +## 源码性能对照 + +Windows x64,Intel Core Ultra X7 358H,Electron 44.5.1,不降速。基线取独立临时 worktree 的 `e50ca6d1`,复用现有 Supervisor Electron/Vite fixture;Vite 从 worktree 读取基线 Renderer/shared 源码。两版本使用相同合成数据和原生鼠标输入,最终顺序为基线 1、修复 1、基线 2、修复 2、基线 3、修复 3。每次启动独立 Vite 缓存和 Electron 用户目录。 + +下表每格按三轮列值,单位为 ms。点击反馈为点击事件至两次 `requestAnimationFrame` 的代理指标;就绪帧指帧回调中观察到 `aria-busy=false`,不等于完整内容提交或合成器呈现时刻。长任务来自 Renderer `PerformanceObserver`,未与 Main 查询耗时相加。 + +| 场景与指标 | 基线三轮 | 修复三轮 | +| --- | --- | --- | +| 已预载路由,2,000 事件,图谱点击反馈 | 346.3 / 325.0 / 188.3 | 29.9 / 38.5 / 24.3 | +| 同场景,图谱长任务时长 | 338+203 / 317+169 / 182+131 | 0 / 0 / 0 | +| 同场景,挂载行数 / 文档元素数 | 2,000 / 8,207 | 24 / 329 | +| 2,000 故事各十事件,首次回顾摘要 CPU | 80.0 / 73.2 / 48.2 | 15.4 / 16.2 / 9.3 | +| 同场景,首次回顾长任务时长 | 83 / 75 / 50 | 0 / 0 / 0 | +| 同场景,图谱返回回顾的摘要 CPU | 140.8 / 117.5 / 88.7 | 0 / 0 / 0 | +| 同场景,缓存路由返回的摘要 CPU | 67.4 / 74.1 / 42.6 | 0 / 0 / 0 | +| 同场景,缓存路由返回点击反馈 | 73.4 / 81.1 / 47.5 | 5.2 / 8.7 / 6.7 | +| 注入 250ms 图谱延迟,首次回顾就绪帧 | 282.7 / 265.1 / 263.8 | 16.2 / 10.9 / 10.6 | +| 未预载模块,八事件,首次回顾出现帧 | 340.9 / 341.9 / 308.1 | 312.5 / 343.8 / 331.1 | + +修复版本首次回顾的图谱请求数为零;图谱首次打开才承担模拟延迟。普通图谱/回顾切换不再请求故事,缓存路由返回不再计算摘要。摘要首次的计数包含一次空数据计算和一次已加载快照计算,后续同一快照复用。 + +未预载模块的首次回顾仍约需 0.31~0.34 秒,包括 Vite 模块加载及 React Suspense 行为,未证明这一项有改善。在该场景随后首次打开八事件图谱时,修复版本第二轮仍出现一次 55ms 长任务;其余修复场景未观察到长任务。三轮样本不足以声明 p95 达标,源码模式也不能代替发布包测量。三个场景均未加载 Three.js,此处没有测量螺旋的 WebGL 初始化。 + +原始记录及临时测量脚本保留在本机批准的临时目录 `opencode`:`supervisor-comparison.jsonl`、`supervisor-audit-runner.mjs`、`supervisor-audit-driver.mjs`。最终数据标签为 `baseline-final` 和 `fixed-final`;文件还保留较早的对照轮次,不能混算。首次审查和各轮间波动明显,因此记录逐轮数值,不把模块加载、查询等待和渲染 CPU 合并归因为存储。 + +## 验证状态 + +- 最终定向 Renderer 与共享列表测试:8 个文件、139 个用例通过。此前包含相关 Main 文件的定向重跑为 368 通过、1 跳过。 +- App 首次入口、缓存路由返回及侧栏图谱导航:3 通过。 +- 完整 Supervisor 源码 Electron 布局与交互:通过;`npm run lint` 通过。 +- 完整 `npm test` 执行结束:515 个测试文件通过、6 个失败、15 个跳过;6,208 个用例通过、7 个失败、87 个跳过,另有一个未处理拒绝。随后修正并重跑了本任务的焦点问题;相关 Main 失败在其负责方更新后重跑通过,Magic Notes 原生布局独立重跑通过。 +- 当时 `tests/message-expansion.electron.test.ts` 独立重跑仍触发 90 秒超时;后续定位、修复及定向绿色验证见下节。全量测试尚未整体重跑,不能将定向验证记为全量通过。 +- 最终 `npm run typecheck`、`npm run lint`、`git diff --check` 通过。文案扫描无阻断项;复核候选为加载、错误和测量范围的必要限制说明,已保留。 + +## 2026-10-05:消息展开测试复查 + +该 Electron 测试在 2,000 和 10,000 条聊天记录场景之后,还执行监督者来源请求的语言切换失效场景。聊天历史使用独立的消息窗口机制,不调用本轮修改的 `useListWindow`;监督者 fixture 只有一个事件,也未进入长列表窗口路径。相同断言下,`e50ca6d1` 和当前源码的普通复跑分别在 16.4 秒和 15.8 秒通过,未复现确定性的 Renderer 性能回退。 + +加入有界的 100ms 替换图谱响应后,两版本均重现测试同步错误:语言已切换,旧来源按钮仍短暂存在,测试便继续执行;固定两帧后读取按钮的 `disabled` 时,刷新已卸载该节点,抛出空引用错误。测试现在通过 fixture 的来源标题区分第几次图谱响应,等待第二次响应实际渲染后再完成旧来源请求并检查失效结果。初始语言固定为中文,保证切换到英文确实触发刷新。 + +轮询辅助函数另有一个已验证的超时问题:第二次及后续动画帧中的谓词异常没有进入 Promise 的拒绝路径,Promise 会永久等待,直到外层 90 秒看门狗杀死 Electron。批准临时目录中的 `expansion-wait-probe.mjs` 使用两版本的原始辅助函数和同一“节点消失”输入验证:旧版为 `pending` 加未捕获错误,修复版为 `rejected` 且无未捕获错误。已将谓词异常接入拒绝路径,并保留原有 10 秒等待上限;外层 90 秒上限、DOM 数量、展开状态、键盘和旧来源断言均未放宽。 + +原先的 90 秒日志没有阶段信息,无法证明它一定由上述某一个等待点触发。此次修复针对已重现的同步错误与未结束的等待 Promise,不将先前超时归因于共享列表或存储。新增阶段耗时及 Renderer 错误输出便于后续定位。没有修改产品 Renderer 或后端代码来绕过测试。 + +严格 fixture 下,基线完整测试在 15.6 秒通过。当前源码最终合并运行消息展开、监督者布局和共享列表等 10 个测试文件,141 个用例通过,总耗时 69.7 秒;其中消息展开各阶段合计 14.7 秒。两种聊天规模均访问 1,397 条不同记录,最多挂载 15 行、926 个文档元素;保留的展开项从 38 条在删除后归零,原生 Space 收起、输入草稿、监督者迟到来源失效均通过。Supervisor 继续覆盖原生首尾跳转、跨窗口 Tab、焦点可见、主动滚离焦点、历史、来源、编辑及窄窗口。最终类型检查和 lint 通过。 + +## 2026-10-05:独立复核修正 + +独立复核发现本轮改动引入了两个问题,均在修复前运行回归确认。 + +故事加载的旧 `act()` 会保留发起时的 `load()` 回调。A 范围的重命名尚未结束时切到 B,若 A 修改完成后再调用旧回调,它会先递增共享 generation,导致 B 的在途读取也被丢弃。相同范围换刷新版本时,旧回调还会覆盖新结果。新测试分别重现了 B 保持空数据和刷新后显示旧快照;另一个测试确认旧刷新回调会多发一次读取。 + +`SupervisionStories` 现在在提交时记录当前范围与刷新版本,在递增 generation 和发出读取之前核对回调归属;响应也须属于同一归属对象和请求序号。卸载后清除归属。四个 hook 回归覆盖跨范围修改完成、同范围刷新版本变化、保留回调在刷新后及卸载后的调用,以及当前修改正常重读并在页签返回时复用快照。修复前四项中三项失败,修复后全部通过;没有改变修改请求本身或增加全局状态管理。 + +列表原先只保留视口附近、选中和焦点行。滚到中间后,从面板按 Tab 会进入第一条已挂载记录;从后续控件按 Shift+Tab 会进入最后一条已挂载记录。原生 Electron 回归实际得到 `event-285` 和 `event-314`,且随后不能按完整列表的边界顺序退出。 + +`SupervisorSelectionList` 通过已有 `keepIds` 额外保留逻辑首、末行,继续使用原生 Tab 进入和退出,不增加焦点哨兵或拦截外部控件的键盘事件。新增 Electron 场景先选择中间记录、将列表滚到中部,再从真实前后控件用原生 Tab/Shift+Tab 进入。事件、实体、关系三类均检查首末记录、焦点可见、反向退出至前一控件和正向退出至后一控件;随后继续原有方向键、Home/End、跨窗口 Tab、滚动保留、历史、来源和编辑检查。测试准备阶段在同一次浏览器求值中检查并选择中间行,等待选中提交,避免把尺寸重测期间的临时 DOM 当作稳定状态。 + +最终定向运行包括消息展开、完整 Supervisor 布局、故事加载、workspace、心跳中心、摘要和共享列表:11 个文件、145 个用例通过,总耗时 69.3 秒。App 中相关监督者、心跳和图谱导航另有 11 个用例通过;类型检查和 lint 通过。消息展开仍覆盖 2,000/10,000 条记录,最多挂载 15 行、926 个元素,三个展开状态恢复、原生 Space、草稿保留、删除清理及迟到来源失效均通过。 + +本次没有运行完整仓库测试、生产构建或模型调用,也没有提交。上文性能表保留其测量时的代码状态;首末行保留会额外挂载至多两行,本次未重跑性能对照,不将旧节点数称为此次修正后的测量结果。原生列表回归中的有界 DOM 断言继续通过。 + +## 修改归属 + +本次 Renderer 任务的产品文件为 `App.tsx`(仅删除成果刷新)、`SupervisorWorkspace.tsx`、`SupervisorSelectionList.tsx`、`SupervisionStories.tsx`、`SupervisionStoryDigest.tsx`、`supervision-story-digest.ts`、`supervisor-workspace.css`、`use-list-window.ts`,以及对应的 `App.test.tsx`、`HeartbeatCenter.test.tsx`、`SupervisorWorkspace.test.tsx`、`SupervisionStories.test.tsx`、`supervision-story-digest.test.ts`、`use-list-window.test.tsx`。当前 `SupervisorWorkspace.tsx` 相对 `e50ca6d1` 的修改均来自本任务,包括最后的历史结果缺失检查。 + +本任务的 Electron 文件为 `tests/supervisor-layout.electron.test.ts`、`tests/message-expansion.electron.test.ts`,以及 `tests/support/` 下的 `supervisor-layout-driver.mjs`、`supervisor-layout-regression.tsx`、`supervisor-selection-driver.mjs`、`message-expansion-driver.mjs`、`message-expansion-regression.tsx`。 + +| 文档 | 本任务修改 | 并行修改 | +| --- | --- | --- | +| `frontend-progress.md` | 全文 | 无已识别的其他修改 | +| `README.md` | 文档表中的“Renderer 修复与测量”一行 | 未发布回顾遇到会话删除的说明段落 | +| `ui-design.md` | 按需图谱读取、历史和刷新等待规则、列表窗口与键盘规则 | 删除会话后省略来源的活动说明段落 | + +其余 Main、Agent、shared DTO、`SupervisorActivity`、`SupervisionReviewStages`、中英文 heartbeat 文案、监督者技术/主进度/需求/逻辑文档、公共评审与路线图、Telegram 和宣传稿修改均不属于本 Renderer 任务。此归属用于区分本任务的改动,未替其他负责方审定或提交其代码。 diff --git a/docs/features/conversation-supervision/progress.md b/docs/features/conversation-supervision/progress.md index eb09284f..16b08d5a 100644 --- a/docs/features/conversation-supervision/progress.md +++ b/docs/features/conversation-supervision/progress.md @@ -4,6 +4,46 @@ 当前记录以已验证生产行为为准。监督者尚未覆盖全部 user stories。 +## 2026-10-05 监督页面读取与发布局部优化 + +本节记录 K01 的后端来源投影及 K02、K05、K06,基线为 `e50ca6d1`。生产 IPC 的概览、图谱、故事/经验读取已接入现有只读 Worker,文件数据库失败时不回退 Main 查询。schema 62 只增加概览查询索引;图谱只传来源标题,完整正文仍可按来源 ID 读取。发布只构造一次最终图谱快照,原 Main 事务和原子提交规则保留。接口及顺序规则见[技术设计](./technical-design.md#页面读取与发布2026-10-05)。本节不承担 Renderer 懒加载、摘要和虚拟列表的验证。 + +先加回归后改实现:最初 6 个用例在旧代码全部失败,分别暴露来源正文随图谱返回、无关项目仍读取全部经验、缺少 UI Worker 路径及重复重写快照等问题。新增文件最终 8 项通过,包含另一连接提交期间的读快照一致性、跨叶子复用身份时首叶子快照与来源引用保留。原子性用例在 checkpoint 写入处注入失败,确认结果、来源、运行状态和 checkpoint 一起回滚,叶子批次可重试。IPC 回归使用真实文件数据库和在临时目录编译的 Worker 源码,断言三个生产 handler 不调用 Main 同步读取;Worker 终止后请求拒绝,来源详情仍返回完整正文。 + +源码测量复用 `build/run-main-perf.cjs` 的 esbuild/Electron-as-Node 方法。基线从独立临时 worktree 读取,修复版从工作树读取,各运行三轮,第二轮反转版本及数据量顺序。Windows x64,Core Ultra X7 358H,Electron 44.5.1/Node 24.21.0,无 CPU 降速;同机还有并行开发活动,保留全部波动。每个版本每轮使用独立合成 SQLite,100 个会话、20 份结果,最新结果持有全部图谱事实;小/中/大样本分别为 2,000/20,000/40,000 条消息和 200/2,000/8,000 个事件及来源,消息与来源正文各 2,048 个 ASCII 字节。最大库约 212 MB。这是压力分布,不代表用户库。 + +每个读取入口测首次调用和三个立即重复调用,期间用 `setImmediate` 记录调用方事件循环最大间隔。首次概览包含懒启动 Worker;它不是冷 OS 缓存测量。下表为大样本每轮四次调用中的最大 Main 间隔,单位 ms: + +| 入口 | 基线三轮 | 修复后三轮 | +| --- | --- | --- | +| 概览 | 389.03/471.16/872.84 | 2.45/2.10/3.17 | +| 结果图谱 | 583.03/571.86/646.16 | 12.04/12.29/15.79 | +| 故事及经验 | 82.92/77.06/72.28 | 8.56/6.94/19.74 | + +修复后大样本概览的首次完成耗时为 94.01/89.65/117.37 ms,暖调用中位数为 0.74/0.74/1.05 ms。图谱首次完成仍为 453.89/476.75/518.11 ms,暖调用中位数为 573.05/551.27/686.15 ms,小时密度仍需读取消息正文。故事及经验首次完成为 90.99/84.18/95.01 ms;SQL 数量从 1,204 降至 6,但全局返回的 17,201 条 SQL 行和完整成员响应保留,不能据此声称整个故事请求变快。 + +大样本图谱 JSON 从 25,742,067 降至 7,143,667 字节;概览保持 7,901 字节,故事响应保持 3,317,180 字节。数值是 UTF-8 JSON 大小,不是 Electron IPC 实际传输字节。以上计时覆盖生产仓储方法、真实 Worker 及线程传输;不含 Renderer 解析/渲染、窗口 IPC 序列化、点击到下一帧,也不把几个探针耗时相加为端到端结果。 + +发布另用独立合成库,每批 20 个真实消息来源、20 个事件和 20 个实体,实际执行清单初始化、叶子保存和 `saveSupervisionResult`。本次复测保留并行加入的删除会话校验,测试来源确实存在。测量期间关闭自动 WAL checkpoint,以分离事务本身,未改变产品配置或落盘同步级别。三轮交替结果如下,单位 ms: + +| 批数/来源数 | 基线同步耗时 | 修复后同步耗时 | +| --- | --- | --- | +| 10/200 | 27.86/28.77/30.96 | 28.60/18.33/24.88 | +| 50/1,000 | 194.20/228.55/223.33 | 146.28/151.44/127.86 | +| 100/2,000 | 686.15/729.16/729.95 | 271.02/286.14/303.41 | + +100 批的快照 UPDATE 从 101 次降至 1 次,快照 SQL 从 444.31/473.67/463.00 降至 14.85/15.49/14.78 ms;修复后 COMMIT 为 27.40/30.89/33.01 ms,整个事务仍为 271.00/286.11/303.39 ms,事件循环最大间隔为 271.06/313.32/303.73 ms。小批次波动大于收益,不声明稳定提速。所有发布样本的 checkpoint 数都与来源数相等。合并后的叶子保存有一次 21.93 ms 峰值,也超过 16 ms,未修改其校验或放宽断言。 + +迁移验证直接复制基线 worktree 生成并已关闭的 schema-61 合成库,再运行生产 `upgradeAssistantStorage`。55 张用户表、32,947 行及 rowid 的逐行哈希在升级前后相同;20 份概览、2,000 条来源标题及完整正文、100 个故事、200 条经验的结果与排序等价,另比对项目及无关项目读取。升级耗时 61.73 ms,文件由 95,526,912 增至 95,711,232 字节;只报告 `upgrading`,没有内容转换或压缩,重复升级不再执行。`integrity_check` 为 `ok`、外键检查为空,基线文件 SHA-256 未变。单元测试另覆盖旧库有空闲页时也不回收。 + +最终定向命令包含 `supervision-ui-reads`、`supervision-history`、`supervision-timeline`、`supervision-stories`、`supervision-experiences`、`supervision-review`、`supervision-worker`、`readonly-query-reader`、`assistant-storage-upgrade`、`assistant-database` 和 `ipc` 的测试文件:11 文件、422 项通过、1 项原有跳过。`tests/supervisor-layout.electron.test.ts` 与 `tests/magic-notes-layout.electron.test.ts` 独立复跑,2 项通过。`npm test` 全量曾有 6 项失败、6,209 项通过、87 项跳过:两个新发布 fixture 未保留真实会话、旧 IPC fixture 只模拟一次 429 却要求整个请求失败,以及并行删除和两项 Electron 布局回归。发布 fixture 已改用真实消息;IPC fixture 覆盖重试耗尽后再次执行恢复;上述失败文件的最终复跑均通过。未将这次复跑记成全量重跑通过。 + +最终 `npm run typecheck`、`npm run lint` 和 `git diff --check` 通过。类型检查发现新 fixture 的阶段字面量不在合同中,已改为既有 `saving`,该文件 8 项再次通过。两份后端文档的中文审校扫描阻断项为 0;人工复核保留数据范围、性能预算和未测项说明。 + +原始脚本、18 组读取样本及迁移报告保留在系统临时 `opencode/supervisor-kiss-comparison-DzVb85`,读取汇总为 `entry-summary.json`,迁移报告为 `migration-integrity.json`。并行删除校验合并后的 18 组发布样本位于其 `publication-current/publication-measurements.json`;此前同目录外层的发布报告只代表合并前源码。 + +剩余预算问题:故事读取有一次 19.74 ms 的 Main 间隔,超过 16 ms;大图谱仍要等待数百毫秒的 Worker 查询;首次 Worker 启动也会增加小库概览等待。Main 最终发布仍有约 271~303 ms 的同步阻塞,并未达到性能原则。业务写入迁移、独立存储服务、IPC 实际序列化与并发写锁等待不在本次已验证范围,后续方向见[桌面存储职责](../../architecture/desktop-storage-direction.md)。本轮不迁移全部写入,也不放宽预算。外部模型调用为 0;没有读取用户正文、修改用户库、读取环境文件、生产构建、打包或启动已打包应用,没有提交。 + ## 2026-10-05 监督模型临时失败重试 对应 FR-S6。`runSupervisionModel` 已接入一次可取消的临时失败重试,覆盖监督者各模型阶段;错误分类、清理及每次尝试的超时规则见[技术设计](./technical-design.md#模型阶段超时与调度)。直连文本请求保留 HTTP status,并交出失败流中已报告的用量。OpenCode/Continue、直连传输重试和叶子/导航输出重试未改。监督者仍在桌面 Main 直连模型,不经过远程 Agent 或模型桥。 diff --git a/docs/features/conversation-supervision/technical-design.md b/docs/features/conversation-supervision/technical-design.md index 5c084e40..f02a5d63 100644 --- a/docs/features/conversation-supervision/technical-design.md +++ b/docs/features/conversation-supervision/technical-design.md @@ -9,7 +9,7 @@ | 产品设计 | [监督者应用产品设计](./supervisor-prd.md) | | 行为规则 | [监督者逻辑设计](./logic-design.md) | | 界面设计 | [监督者 UI 设计](./ui-design.md) | -| 实施进度 | SQLite schema 50(心跳介入与监督建议见[心跳触发与建议](#心跳触发与建议2026-09-30));当前算法、设置和验收边界见[生产接线](./review-scheduling-design.md#0-生产接线与剩余边界) | +| 实施进度 | SQLite schema 62;页面读取与索引见[页面读取与发布](#页面读取与发布2026-10-05),当前算法、设置和验收边界见[生产接线](./review-scheduling-design.md#0-生产接线与剩余边界) | 本文回答如何在现有 GoodBuddy 桌面端中实现监督者。它不改变产品范围,也不把模拟 Demo 当作生产数据模型。 @@ -33,7 +33,7 @@ schema 43 在结果上增加 `graph_snapshot_json`,保存当次实体名称、 升级从现有事件实体关联、实体变化和来源引用恢复结果成员,不猜测名称匹配;旧数据缺失的归属或已经覆盖的内容无法还原,原对象仍保留。新结果即使实体没有事件或来源,也会保存完整成员。结果内容与运行、事件、来源在同一事务提交。 -`overview({ target? })` 返回稳定的 `id`、`storyLineId` 及来源 ID。指定 Conversation/Task 时,Main 读取目标实际项目,查询同时要求目标来源匹配、结果 scope 为 global 或包含该项目;LIMIT 在过滤之后。卡片展示结果真实范围,global 结果不会伪装成单会话摘要。`graph({ resultId, storyLineId? })` 校验二者归属,只返回该结果的事件、对象和来源;显式 ID 无效时不回退到最新结果。继续讨论按结果主键读取,并验证来源属于该结果。 +`overview({ target? })` 返回稳定的 `id`、`storyLineId` 及来源 ID。指定 Conversation/Task 时,只读 Worker 读取目标实际项目,查询同时要求目标来源匹配、结果 scope 为 global 或包含该项目;LIMIT 在过滤之后。卡片展示结果真实范围,global 结果不会伪装成单会话摘要。`graph({ resultId, storyLineId? })` 校验二者归属,只返回该结果的事件、对象和来源标题;显式 ID 无效时不回退到最新结果。继续讨论按结果主键读取,并验证来源属于该结果。 工作回顾保存所选结果,切换或刷新使用请求序号忽略迟到图谱。侧栏按目标重建卡片,清空来源和预览,并忽略旧请求;工作栏 tasks 实例的 `targetRef` 可保存 conversation/task 固定目标,取消固定后恢复跟随当前选择。该绑定只控制监督卡片,不改变任务列表的项目范围。继续讨论预览显示实际目标名称和 ID,发送回调显式携带该会话 ID,由 Main 使用该会话的 Runtime 与项目。 @@ -45,6 +45,20 @@ schema 43 在结果上增加 `graph_snapshot_json`,保存当次实体名称、 已确认记忆作为当前背景,不参与新增正文覆盖。最多四条、每条 500 字符,时间来自真实更新时间;模型不能把背景引用为本批新增证据。旧结果继续保留原来源类型和 locator。监督正文上限现用于分批,余段继续处理;心跳报告的有界输入语义没有改变。 +## 页面读取与发布(2026-10-05) + +对应 K01 的来源投影及 K02、K05、K06。`supervision:overview`、`supervision:graph`、`supervision:stories` 的生产 IPC 校验后分别调用 `listSupervisionResultsAsync`、`getSupervisionGraphAsync`、`getSupervisionStoriesAsync`。它们复用 `readonly-query-worker` 的 AssistantDatabase 独立只读连接,每次领域读取包在一个 `readSnapshot` 内;故事响应中的故事、经验、未归属计数及撤销状态来自同一 SQLite 快照。调用前已提交的写入对下次读取可见。 + +文件数据库缺少 Worker 配置、启动失败、崩溃或处于原有退避期时,请求直接失败,页面沿用既有错误与重试显示;这三个入口不使用 `readWithFallback`,不在 Main 重跑重查询。内存 SQLite 测试保留同步分支。原工具 `readStoryGraphAsync`、来源详情及其他存储入口保持各自原有实现,没有增加存储进程或通用协议。 + +结果图谱和 scope 图谱的 `sources` 都只查询共享合同已有的 `id`、`title`、`occurred_at`,并按原表 rowid 顺序返回。正文、来源类型、locator 等仍完整保存在 `supervision_sources`,通过 `supervision:source`/`source-context` 按 ID 读取;历史结果、所有事件、对象和关联均保留。概览是否请求图谱由 Renderer 的页面加载规则决定,后端不截断图谱内容。 + +schema 62 在既有迁移事务中新增 `supervision_sources(result_id, occurred_at, source_type, id)` 和 `supervision_results(created_at)` 索引。前者供每份概览的首来源定位,后者支持结果时间及 rowid 倒序读取。升级沿用启动存储 Worker,保留表内容和 rowid,不重写正文,不触发历史转换或 `VACUUM`。新版本仍遵守既有的禁止旧客户端打开更高 schema 规则。 + +故事查询先按 scope 选择故事,再一次读取这些故事的事件成员;经验查询在 SQL 中筛选仍有形成事件、且与所选项目有关的经验,再一次读取其全部有效事件关联。经验的跨项目关联不会随项目筛选被裁掉。两类列表各至多两条 SQL,原列表和成员排序、人工调整状态及完整内容访问保留。 + +发布继续使用 Main 原有 `BEGIN IMMEDIATE` 事务。每个叶子写入后收集当时的实体、关系及来源引用,末尾只写一次 `graph_snapshot_json`;重复持久身份仍采用原 SQL 分组规则,保留首叶子快照值和全部叶子详情。结果、运行状态、来源、时间线与 checkpoint 仍一起提交或回滚。没有将写入迁到 Worker,也没有把一个完整结果分多次提交。大批量发布仍超过 Main 16 ms 预算,实测与剩余限制见[实施进度](./progress.md#2026-10-05-监督页面读取与发布局部优化)。独立存储服务的职责迁移继续留在[目标方向](../../architecture/desktop-storage-direction.md)。 + ## 监督者模型连接 `ApplicationSettings.supervisorModelProfileId` 只保存已有连接 UUID,`null` 或旧设置缺少字段时跟随 `RuntimeSettings.defaultModelProfileId`。Main 的 `resolveSupervisorRuntime` 从 `getResolvedSettings()` 读取连接和已有凭据,校验文本协议及认证后,用 `createModelProfileRuntime` 创建无工具直连 Runtime。Renderer 仅接收公开连接信息,不新增凭据存储或 IPC 方法。 @@ -61,7 +75,7 @@ schema 43 在结果上增加 `graph_snapshot_json`,保存当次实体名称、 `runSupervisionModel` 为提取、导航、故事、经验和建议共用一次临时错误重试,错误范围见[逻辑规则](./logic-design.md#监督者模型选择)。每次尝试使用新的 request/conversation ID 和隐藏任务,正文缓冲单独创建;用量按各次任务保存。失败后先清理计时器并释放临时会话,再等待;等待继续持有监督池槽位,受父 signal 和池取消控制,结束或失败后释放槽位。`error` 事件的 `status: cancelled` 优先于错误文字;抛出的结构化错误检查 status、statusCode 和 cause,字符串只识别明确的 HTTP 状态或已知网络/流中断格式。 -直连文本请求保留 HTTP 错误的 status,并在响应流失败前交出已收到的用量;没有提供方用量时不估算补记。重试决策只在监督包装层,直连传输原有重试、OpenCode 和 Continue 策略不变。429 使用同一 500ms 等待,不新增 Retry-After、UI 或配置项。 +直连文本请求保留 HTTP 错误的 status,并在响应流失败前交出已收到的用量;没有提供方用量时不估算补记。监督响应超容量后立即 abort 并清空正文,最多继续消费 1 秒,仅保存用量事件,不累积后续文本;随后请求关闭迭代器、释放临时会话和池槽位,迟到事件不再写入。容量错误保持原文,不被随后到达的超时或流错误替换,也不重试;已缓冲完成响应和中止响应均沿用原 call ID 记账。重试决策只在监督包装层,直连传输原有重试、OpenCode 和 Continue 策略不变。429 使用同一 500ms 等待,不新增 Retry-After、UI 或配置项。 `supervisorModelConcurrency` 为 1 至 4 的整数,默认 1。报告和监督整理共用 Main 内 `SupervisionModelPool` FIFO 池,普通聊天不入池;降低上限不终止在途请求。报告槽位从领取前持有至报告完成或无变化提交后,在下游监督开始前释放,避免并发 1 时相互等待。监督服务外层运行仍串行;该池不是完整分块调度器,也不提供跨聊天与监督的提供商全局并发、RPM、TPM 或 Retry-After 控制。 diff --git a/docs/features/conversation-supervision/ui-design.md b/docs/features/conversation-supervision/ui-design.md index c9850139..97b8aec5 100644 --- a/docs/features/conversation-supervision/ui-design.md +++ b/docs/features/conversation-supervision/ui-design.md @@ -12,6 +12,8 @@ 工作回顾与故事线图谱在主页签下方共用回顾上下文栏。历史选择器最多占 280px,收起时只显示生成日期时间,随后是“刷新”和“新回顾”,不在右侧重复范围和覆盖区间。展开复用共享锚定单选菜单,每项显示生成时间及该结果自身的真实范围、覆盖起止时间,长说明换行;当前项带选中标记,触发按钮的无障碍描述保留所选结果的完整上下文。即使只有一条结果也显示选择器。切换页签和图谱模式时,这一栏的位置、内容和选择保持一致,空态及读取失败时仍可访问。 +首次打开工作回顾只读取回顾及其范围的故事摘要,不等待图谱。进入故事线图谱时才读取所选结果的图谱;读取期间在图谱页显示加载状态,返回工作回顾仍可阅读及选择历史。工作回顾与图谱共用已读的故事数据,普通页签切换不重复读取。当前结果的图谱在切换页签时保留;切换历史结果、显式刷新、实体或关系修改后的刷新会使旧图谱失效。来源列表只需标题和时间,点击来源才读取完整正文。 + 点击“新回顾”才展开范围、时间、发起按钮和重新整理菜单。表单位于上下文栏下方,以次级表面区分新请求与已选结果;再次点击收起并保留输入。工作回顾正文铺满页面壳层,不另设阅读宽度上限。容器宽于 860px 且有辅助内容时,左侧约占三分之二,显示工作总结和本次变化;右侧显示未解决事项、故事进展和经验,以弱分隔线分区。没有辅助内容时只保留全宽正文。窄屏按正文、辅助内容顺序排列为单列。摘要保留原有段落和换行,故事摘要保留“显示更多”及进入图谱的选择。 新回顾运行中保留已显示结果及其日期、范围,范围、时间、发起和刷新控件保持可用。手动发起、完成、暂停、取消、无变化和失败统一使用应用 Toast;重复点击发起提示已有运行,不新建或排队。同一回顾操作的通知互相替换,关闭通知不暂停或取消执行。成功和信息沿用共享自动消失时间,错误由用户关闭。页面不保留操作状态条或错误横幅,加载历史结果、切换页签和重新进入页面不触发回顾通知。 @@ -94,9 +96,9 @@ Modal 按[共享表单 Modal 规则](../../../UI-DESIGN.md#615-表单-modal)复 没有自动计划也能手动回顾;成功结果显示自身冻结的范围、时间和未解决事项,可选择历史结果或进入图谱。来源筛选由本次回顾范围和时间限制;图谱详情只列出所选事件的关联来源,实体或关系列出相关事件的来源。独立的来源类型选择器尚未实现。 -选择历史结果后,图谱读取该结果的成员和内容,历史菜单显示其回顾区间和生成时间。切换时清空旧图谱和详情并显示加载状态,迟到请求不得替换新选择。确认、修订和移除反馈更新当前所选结果,后续自动回顾不改写旧结果。 +选择历史结果后,历史菜单显示该结果的回顾区间和生成时间,旧图谱和来源详情清空。在图谱页选择历史时立即读取对应成员和内容;在工作回顾页选择时,图谱读取留到下次进入图谱页。迟到请求不得替换新选择。确认、修订和移除反馈更新当前所选结果,后续自动回顾不改写旧结果。 -切换语言或手动回顾完成引起刷新时,同时解除被替代的来源读取、实体和关系操作的等待状态。新图谱读取完成后,历史选择、刷新和详情操作恢复可用;旧请求迟到时既不显示旧内容,也不能解除后来发起的请求的等待状态。 +切换语言或手动回顾完成引起刷新时,同时解除被替代的来源读取、实体和关系操作的等待状态。工作回顾在回顾读取完成后恢复历史选择和刷新;图谱页等待对应图谱读取完成后恢复操作。图谱失败只在图谱页提示并提供重试,不阻止阅读已读回顾;旧请求迟到时既不显示旧内容,也不能解除后来发起的请求的等待状态。 手动“回顾当前进展”新建回顾,活动页“继续”恢复原 run;运行中再次发起按上文显示提示,停止清理结束后才能开始新回顾。失败保留上次结果、本次范围和成功批次;已取消回顾不能继续。手动操作不推进自动位置,删除报告也不重置自动位置。 @@ -131,6 +133,8 @@ Modal 按[共享表单 Modal 规则](../../../UI-DESIGN.md#615-表单-modal)复 左侧使用共享 PageTabs 切换“事件 / 实体 / 关系”,显示各类数量,一次只展示一类列表,无记录时显示本次回顾的分类空态。页签固定在顶部,列表占满剩余高度并独立滚动。切换页签不清除当前图谱选择;从画布、事件浏览或详情选择对象时同步切到对应页签。支持方向键、Home/End 和面板键盘聚焦。 +事件、实体和关系长列表复用共享列表窗口,只挂载视口附近的行,并保留选中行和焦点行。记录总数、完整名称及末条记录均可访问,不以截断记录提速。列表内上、下方向键移动焦点,Home/End 到首、末条,Enter 或 Space 选择;Tab/Shift+Tab 按完整记录顺序移动,在首尾离开列表。键盘移动跨过未挂载的行时,先挂载目标行再聚焦。行高测量或窗口变化不得把刚聚焦的可见行移出视口;用户主动滚走时保留焦点行,但不把滚动位置拉回。 + 使用 `supervisor` 页面壳层,最大内容宽度为 `1440px`。当前生产图谱左侧为紧凑的事件、实体和关系列表,中间为 SVG 画布,右侧为详情与来源。桌面列宽为 `216px minmax(0, 1fr) 280px`,画布获得主要空间。外圈事件按发生时间排序后等距逆时针排列,不表示实际时间间隔;起止之间保留缺口和方向箭头。内部显示真实实体名称,实线仅连接实际事件与实体,虚线仅连接实际实体关系。 高度按窗口剩余可见空间计算,底部保留约 24px 边距(不小于页面底部内边距)。平铺图最低 660px,时间螺旋最低 320px;低于最小高度时页面允许纵向滚动。中间画布不内部滚动,工具栏与事件浏览栏之外的空间给图;左侧列表和右侧详情各自滚动。两种视图的图例均使用共享 InlineHelp 的纯 i 图标,保留可访问名称和 title;点击或 Enter/Space 固定展开,再次点击、Escape、点击或聚焦外部关闭。平铺可见数量仍在画布角落;螺旋左下角不显示圈长及圈数,这些信息收进图例。窗口尺寸及上方表单展开、收起后重新计算可用高度。 diff --git a/docs/features/remote-host/technical-design.md b/docs/features/remote-host/technical-design.md index 742868c6..bbb34608 100644 --- a/docs/features/remote-host/technical-design.md +++ b/docs/features/remote-host/technical-design.md @@ -371,6 +371,12 @@ Runtime 包。2026-09-19 完整桌面验证覆盖联合包安装、CN 项目创 阶段。实现规则见[共享进程与 Session 所有权](../assistant-workbar/runtime-process-reuse-technical-design.md#61-连接与所有权), 本次源码 Host 验证及调用计数见[统一执行进度](../unified-execution/progress.md#2026-10-05-runtime-生命周期审查修复)。 +同日 K07 补齐模型路由准备后的取消:broker 关闭及终态提交保持原顺序,原生路由 +撤销的 HTTP 等待移出全局控制队列。安装晚于取消完成时,以捕获的 transport、Session +和 operation 再次撤销该路由;原生 helper 只删除 operation 匹配的项。路由 HTTP 请求 +仍有 10 秒上限,transport 退出或释放时中止未完成请求。当前源码的 OpenCode/Continue +实机证据见[局部修复验证](../unified-execution/progress.md#2026-10-05-k07k08-局部修复)。 + ## ACP 与断线 - 管控面只负责有界 JSON-RPC:连接、查询、启动、取消、关闭和重连。数据面只负责 diff --git a/docs/features/telegram-channel/prd.md b/docs/features/telegram-channel/prd.md index bf82a859..d4619828 100644 --- a/docs/features/telegram-channel/prd.md +++ b/docs/features/telegram-channel/prd.md @@ -138,6 +138,8 @@ Token 使用现有加密凭据存储,保存后不在普通设置快照中返 已生成并保存的结果沿用现有发件箱有限重试,发送失败与任务执行失败分别记录。补发回复不重新调用模型或重跑任务。用户阻止 Bot 等不可恢复错误应停止无效重试。 +已有 webhook 或另一实例占用轮询时,已确认 Bot 身份且目标匹配的待发回复仍可发送;接收冲突本身不能永久终止这些回复。发送内部重试达到上限后,发件箱的下一次尝试仍须遵守最近一次 429 响应的等待时间,停用可取消等待。 + 同一驱动实例内且分段进度仍在缓存时,多段回复从未发送部分继续。进度不跨重启持久化,重启或缓存淘汰后补发可能重复已发送段落;网络中断造成平台接收结果不明时也可能重复。首版不承诺平台级发送幂等。 ### FR-10 启停与历史保留 @@ -162,6 +164,8 @@ Telegram 待接收更新最多保留 24 小时,超过平台保留期的离线 状态和错误推送只更新状态区域,不覆盖尚未保存的 Token 或白名单草稿。解决轮询冲突后可重新保存当前配置以启动连接;单独测试连接成功不改变接收状态。 +接收正常而结果发送失败时,保留已连接状态并显示脱敏的发送错误。后续健康轮询不清除发送提示,投递成功后清除;接收也发生错误时优先展示接收原因,恢复后保留尚未清除的发送提示。 + 状态通过文字表达,不能只靠颜色。错误保留可操作原因,如 Token 无效、网络不可达、webhook 占用或另一实例正在轮询。测试成功、发送失败和模型失败使用各自准确文案,避免统一显示“连接失败”。 界面沿用 [UI-DESIGN.md](../../../UI-DESIGN.md) 的控件、通知、键盘和可访问性规则,不新增独立视觉体系。 diff --git a/docs/features/telegram-channel/progress.md b/docs/features/telegram-channel/progress.md index 0ad6563c..1185bb6a 100644 --- a/docs/features/telegram-channel/progress.md +++ b/docs/features/telegram-channel/progress.md @@ -55,6 +55,32 @@ npm test -- tests/telegram-channel.electron.test.ts 补充检查:钉钉错误码断言加入后,单独复跑 9 项通过;修改的 TS 文件 ESLint、Renderer typecheck 和 `git diff --check` 通过。早期全仓检查受到并行修改中的类型与 lint 错误阻塞,负责方修正后最终检查均通过;完整测试为 6,136 项通过、0 失败、87 跳过。汇总见[阶段修复验收](../../review/stage-audit-fixes-2026-10-05.md#最终检查)。 +## 2026-10-05 K09 投递修复 + +本轮只修改 Telegram 驱动、Manager 及其测试,复用现有服务重试、SQLite 发件箱和状态 IPC。未改数据库结构、Main IPC、Runtime 或 Agent;本地与远程任务完成后的通道结果均使用同一发送路径,无需独立 Agent 修复。 + +修复前先运行持久回归:`telegram-delivery.test.ts` 的 3 项 loopback HTTP/SQLite 用例和 Manager 的状态用例共 4 项失败。启动 webhook 冲突时,同 Bot 的待发回复未发出且变为终止记录;403 后状态仍为 `running` 且无错误;连续 429 的最后响应要求等待 3 秒,下一次发送却只间隔约 1.010 秒。扩展的 Electron 设置 fixture 也在“已连接时显示发送失败”断言处失败。 + +修复后,启动阶段保留 `getMe` 确认的身份,已有 webhook 时发送同 Bot 的待发回复,其他 Bot 的记录仍被拒绝,轮询状态仍报告冲突。发送错误经脱敏后由现有状态区域显示,健康轮询不清除;发送成功清除投递提示但不覆盖轮询错误。驱动在内部重试耗尽后仍完成最后一次 `retry_after` 等待,再返回发件箱;新增取消用例验证等待可中断、记录保持 `pending / attempts=0`,重新启用后只补发一次且不运行 executor。 + +| 检查 | 最终结果 | +| --- | --- | +| 9 个通道/Renderer 聚焦文件 | 127 项通过,包含 4 项 loopback HTTP/SQLite 回归 | +| 现有 Electron 设置 fixture | 1 项通过;生产设置 UI、Preload、IPC、Manager、驱动与 SQLite,403 提示、已连接状态、Token/白名单草稿、重载恢复均通过 | +| 修改的 6 个 TS 文件 ESLint | 通过 | +| `npm run typecheck` | Main、Agent 通过;Renderer 被并行工作区的 `App.tsx:174` 未使用 `refreshArtifacts` 导入阻塞,未修改该文件 | + +最终聚焦命令: + +```powershell +npm exec -- vitest run src/main/channels/telegram-delivery.test.ts src/main/channels/telegram-channel-driver.test.ts src/main/channels/channel-manager.test.ts src/main/channels/channel-service.test.ts src/main/channels/channel-settings-store.test.ts src/main/channels/sqlite-channel-state.test.ts src/renderer/src/ChannelSettingsSection.test.tsx src/renderer/src/channel-status-store.test.ts src/main/channels/dingtalk-channel-driver.test.ts +npm exec -- vitest run tests/telegram-channel.electron.test.ts +npm exec -- eslint src/main/channels/telegram-channel-driver.ts src/main/channels/channel-manager.ts src/main/channels/telegram-delivery.test.ts src/main/channels/telegram-channel-driver.test.ts src/main/channels/channel-manager.test.ts tests/support/telegram-main.ts +npm run typecheck +``` + +Electron 最终运行使用 2 次本地 HTTP 模型响应;连同修复前失败运行和中间验证,本轮共 6 次本地 fixture 请求、0 次外部 Provider 请求、0 次真实 Telegram 收件人消息。未读取 `.env`,未运行生产构建。完整测试、全仓 lint 及合并后的类型检查由协调方执行;本节不声明全仓验收通过或性能提升。 + ## 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 次,无外部模型调用。 diff --git a/docs/features/telegram-channel/technical-design.md b/docs/features/telegram-channel/technical-design.md index 6c3a0f16..6a3753c7 100644 --- a/docs/features/telegram-channel/technical-design.md +++ b/docs/features/telegram-channel/technical-design.md @@ -6,7 +6,7 @@ 设置页经生产 Preload 和 IPC 调用 `ChannelManager`,由 `ChannelSettingsStore` 保存配置。Manager 按需加载 `TelegramChannelDriver`,向其注入 Electron `net.fetch`;驱动默认网络实现也使用 `net.fetch`。请求走 Electron 网络栈及系统代理,不依赖 Node 的 `HTTPS_PROXY` 配置,不使用 grammY 或 Telegraf。 -驱动先调用 `getMe` 与 `getWebhookInfo`,拒绝已有 webhook,不删除外部配置。测试连接只做这两项检查,不启动轮询。启用后使用 `getUpdates`,`timeout=30`、`limit=100`、`allowed_updates=['message']`;普通请求超时 15 秒,轮询请求超时 40 秒。临时错误以 1~30 秒退避重连,429 按 `retry_after` 等待;其他 4xx 停止轮询并报告错误。 +驱动先调用 `getMe` 与 `getWebhookInfo`,已有 webhook 时拒绝启动轮询,不删除外部配置。`getMe` 验证的 Bot 身份独立保留;启动 webhook 冲突与运行中的轮询冲突均不阻止身份匹配的已保存回复发送,也不因此终止其发件箱记录。测试连接只做这两项检查,不启动轮询或改写运行中的身份。启用后使用 `getUpdates`,`timeout=30`、`limit=100`、`allowed_updates=['message']`;普通请求超时 15 秒,轮询请求超时 40 秒。临时错误以 1~30 秒退避重连,429 按 `retry_after` 等待;其他 4xx 停止轮询并报告错误。 ## 身份、执行与保存 @@ -29,7 +29,7 @@ 生成图片后,executor 先把 `artifactIds` 写入桌面会话,再向 `ChannelService` 返回通道契约中的状态、正文、错误和附件。图片字节保留在附件中;桌面成果 ID 不进入严格校验的通道结果,避免成功任务被误报为“无效结果”。该转换位于本地和远程 Runtime 事件处理之后,不改变 Runtime 或 Agent 协议。 -驱动按最多 4096 个 UTF-16 码元拆分纯文本,并避开代理对中间位置;不设置 `parse_mode` 或引用原消息。分段进度只在驱动内存保存,最多 128 条,停止时清空。单次发送的 429 最多等待重试 3 次,仍失败则交回发件箱处理。跨重启补发与不确定网络结果允许产生重复段落。 +驱动按最多 4096 个 UTF-16 码元拆分纯文本,并避开代理对中间位置;不设置 `parse_mode` 或引用原消息。分段进度只在驱动内存保存,最多 128 条,停止时清空。单次发送遇到 429 最多额外尝试 3 次;最后一次仍被限流时,先等待该响应的 `retry_after`,再将失败交回发件箱,避免服务层提前重发。等待沿用调用方与服务生命周期的取消信号;停止期间不增加失败计数。跨重启补发与不确定网络结果允许产生重复段落。 停用会中止轮询、发送等待、活动执行与排队工作,等待活动操作结束后报告停止。清除凭据时设置页同时提交 `enabled=false`;底层拒绝启用但无 Token 的配置。Token 替换先验证,失败不停止旧服务。项目与历史不因停用或清除凭据删除。 @@ -37,6 +37,8 @@ Manager 通过 `{ channel, status }` 增量 IPC 推送状态,忽略旧服务实例的迟到事件。Telegram 连接状态由驱动维护,发送成功不会覆盖轮询错误。状态值为 `disabled`、`starting`、`running`、`stopped`、`error`;重连使用 `starting` 加错误说明,未配置由界面结合凭据状态显示。 +Manager 在当前服务实例内保留最近一次发送错误,经既有脱敏函数处理后,附在连接状态的错误说明中。轮询正常时仍显示已连接,同时提示 `Telegram sending failed: ...`;健康轮询不会清除该提示。若同时有轮询错误,优先显示轮询原因;恢复接收后仍可看到未清除的发送错误。后续投递成功清除发送提示,停用或替换服务释放该实例的错误状态,无新增持久化记录或 IPC 字段。 + Renderer 的 `channel-status-store.ts` 订阅事件,selector 只向状态区域提供对应通道数据。首次快照不会覆盖已收到的较新事件;状态更新不重新加载整份设置,不重置 Token 或白名单草稿。配置页复用共享页签、开关、项目设置字段和通知控件。 首次配置说明、空白名单提示和发送者 ID 字段帮助均明确:ID 可先留空,先启用并保存通道,连接后私聊自己的 Bot 发送 `/whoami`,取得本人数字用户 ID 后填入并再次保存。仅测试连接不启动消息接收;用户名和 Bot ID 不能代替发送者 ID。白名单为空时仍只提供帮助,不接受 AI 请求。 diff --git a/docs/features/unified-execution/progress.md b/docs/features/unified-execution/progress.md index 733b52fa..844a0874 100644 --- a/docs/features/unified-execution/progress.md +++ b/docs/features/unified-execution/progress.md @@ -1,5 +1,71 @@ # 进度与证据 +## 2026-10-05 K07/K08 局部修复 + +最终集成补注:排除并行删除功能后,待提交内容的隔离全量测试为 6,212 项通过、0 失败、87 跳过,类型检查及 lint 通过。下文分组阶段的失败轮次不是最终结果;完整证据见[KISS 验收](../../review/kiss-local-fixes-2026-10-05.md#最终检查)。 + +K07 已修复共享 Runtime 在模型路由清理阶段阻塞控制队列、取消后迟到安装留下路由的 +问题。broker 关闭与终态提交顺序保留,原生路由撤销在队列外完成;迟到安装按原 +transport/Session/operation 再次清理。transport 退出中止未完成路由 HTTP 请求。 +规则见[Session 所有权](../assistant-workbar/runtime-process-reuse-technical-design.md#61-连接与所有权)。 + +K08 在监督正文超容量时立即取消,清空正文并只消费用量事件,清理等待上限 1 秒。 +原容量错误优先于后续流错误与超时,不重试、不重复记账;超时以后返回的事件不写库。 +该路径由桌面直连模型执行,远程项目的监督也使用同一实现;具体用量规则见 +[监督模型调度](../conversation-supervision/technical-design.md#模型阶段超时与调度)。 + +### 回归与检查 + +首轮七项持久回归在修复前全部失败:取消/截止时间后的 peer 查询仍等待路由释放, +迟到安装残留,OpenAI/Anthropic 已收到的 12 input/3 output 用量丢失,以及超限后的 +记账消费与清理边界不符。修复后增加缓冲完成响应、transport dispose/exit 回归, +共十一项新增用例通过。检查取消终态只有一条、peer 仍 running、无重复 Prompt、 +用量只有一个 call,以及迟到事件不会在池槽释放后写入。 + +```text +npx --no-install vitest run src/main/assistant/supervision-model-retry.test.ts src/main/assistant/supervision-production.test.ts src/main/agent/model-runtime.test.ts src/agent-daemon/runtime-acp-backend.test.ts src/agent-daemon/agent-owned-acp-prompt.test.ts src/agent-daemon/protocol-server.test.ts +npm run typecheck +npm run lint +``` + +上述六文件 243 项通过、1 项跳过;12:22 的 Main/Agent/Renderer 类型检查及全仓 lint 通过。 +并发工作树的完整 `npm test` 为 6,214 项通过、5 项失败、87 项跳过,耗时 870.20 秒; +失败位于监督布局、IPC 的 provider 重试、监督恢复和快照读取测试,K07/K08 文件均通过。 +原始输出为批准临时目录中的 `k07-full-tests-1791174423486.log` 及同名 `.json`。 +这轮结果不能当作全仓验收通过;未修改其他任务负责的数据库、IPC 或界面文件。 +12:45 单独复跑 IPC provider 场景通过(1 项通过、190 项按筛选跳过);期间其他任务仍在 +修改测试,后一次类型检查报 `supervision-ui-reads.test.ts` 的 `publishing` phase 类型 +错误。最终整体检查需由合并工作树的负责方复跑,不能用本模块的通过结果替代。 + +### Linux x64 当前源码 + +复用前阶段 `a01-runtime-host` 的凭据与 SSH 固定身份入口,仅以 esbuild 生成当前 +Desktop driver、Agent daemon 和 CLI/helper 测试 bundle。使用 Host 已安装的原生 +OpenCode/Continue,在 `/root/tmp/gb-lifecycle-*` 专用目录运行,未安装生产包或更换 +Host current。阻塞点只由 harness 注入;路由安装、撤销及 409 缺失校验通过真实 helper。 + +| 场景 | peer 查询 | 取消完成 | 外部文本 HTTP 请求 | 结果 | +| --- | ---: | ---: | ---: | --- | +| 首轮 OpenCode | 51 ms | 206 ms | 2 | 取消及迟到路由清理通过;后续工具恢复触及旧夹具的两次调用上限 | +| 最终 OpenCode | 13 ms | 176 ms | 3 | peer 完成、工具执行、detach/恢复、测试 Agent 提升、终态重放通过 | +| 最终 Continue | 46 ms | 194 ms | 3 | 同上 | + +准确合计 **8 次外部文本提供商 HTTP 请求、0 次图片提供商调用**,八个响应均 HTTP 200。 +最终每个 Runtime 包含一次短文本 peer 调用和两次工具/恢复调用,预算在 harness 中 +显式设为三次;被取消的启动未调用提供商。三个运行结束时所属进程均为 0,额外的 +只读 SSH/SFTP 检查确认本轮时间范围内的专用目录残留为 0,该检查新增模型调用 0 次。 +延迟是单次场景观察,不作为 p95 或桌面性能预算结论。 + +最终 Host 事件通过现有批处理器和隔离 SQLite 校验:OpenCode 41 条事件、21 个 +checkpoint;Continue 16 条事件、8 个 checkpoint;重放去重通过。没有读写用户生产库。 + +原始脚本位于 `C:/Users/jiang/AppData/Local/Temp/opencode`: +`k07-runtime-host-build.cjs`、`k07-runtime-host-run.cjs`、`k07-host-cleanup-build.cjs`。 +日志位于其 `k07-runtime-host/` 子目录:`opencode-1791173985798.log`、 +`opencode-1791174187014.log`、`continue-1791174187010.log`、`inspect-1791175306647.log`。 +验证不覆盖签名安装包、macOS、Linux arm64、完整 UI 操作或生产规模性能;远程修复需 +部署包含本次源码的 Agent,当前未提交或发布。 + ## 2026-10-05 Runtime 生命周期审查修复 A01(远程启动阻塞控制队列)和 A02(本机 OpenCode 提交失败后等待静默 SSE)已修复。 diff --git a/docs/review/kiss-local-fixes-2026-10-05.md b/docs/review/kiss-local-fixes-2026-10-05.md new file mode 100644 index 00000000..4143a761 --- /dev/null +++ b/docs/review/kiss-local-fixes-2026-10-05.md @@ -0,0 +1,106 @@ +# KISS 局部修复与验证 + +## 范围 + +基线为 `e50ca6d1`。先更新文档,再实测实施,完成后回填证据并提交本轮改动。 +目标架构见[桌面存储方向](../architecture/desktop-storage-direction.md);独立存储服务迁移留待之后讨论。 +本轮复用现有 Worker、数据库、IPC 和控件,不全面异步化存储,不更换数据库驱动,不增加通用恢复框架。 +工作区已有三份宣传稿修改,以及实施期间并行加入的“删除会话后继续回顾”改动,不属于本轮提交;共享文件按片段区分。 + +## 实施清单 + +| 编号 | 局部改动 | 验收要点 | 状态 | +| --- | --- | --- | --- | +| K01 | 概览不提前加载图谱,来源正文按需读取 | 首次概览无需 graph;图谱和来源内容完整,历史导航正确 | 已实现,组件及 Electron 通过 | +| K02 | 概览索引与故事/经验批量、按项目查询 | 查询结果与排序等价;升级保留原数据,实测查询及输出规模 | 已实现,schema 61→62 数据等价验证通过 | +| K03 | 故事摘要建索引并只计算一次 | 摘要分类及时间规则不变;暖切换不重复计算 | 已实现,三轮对照及回归通过 | +| K04 | 图谱选择列表虚拟化 | DOM 有界,键盘、焦点、选中、滚动和内容访问保留 | 已实现,补齐外部 Tab 入口后 Electron 通过 | +| K05 | 监督重读取接入现有只读 Worker | 生产 UI 调用确实离开 Main,读快照一致,失败不静默回退重查询 | 已实现,真实 Worker/IPC 回归通过 | +| K06 | 发布只构造一次最终快照 | 完整结果与 checkpoint 原子提交;实测剩余 Main 阻塞,不隐藏未解决预算 | 已实现一次构造;Main 发布仍超预算,待架构迁移 | +| K07 | 远程取消清理等待及迟到路由 | 不占全局控制队列,清理后迟到安装不残留,活跃 peer 正常 | 已实现,当前源码 Linux x64 Host 通过 | +| K08 | 超容量响应保留已收到模型用量 | 不保留超限正文、不重试容量错误,实际用量不丢失或重复 | 已实现,两个协议及清理期限回归通过 | +| K09 | Telegram 启动冲突、投递错误及限流 | 待发回复不因接收冲突永久终止;错误可见;跨层遵守 retry_after | 已实现,loopback/SQLite/Electron 通过 | + +## 已有审查基线 + +前三轮合成 SQLite:20 份结果、40,000 条消息、8,000 个事件/来源时,概览同步读取 +310/328/324 ms;完整图谱 379/383/387 ms,JSON 约 25.74 MB。400 个故事、800 条经验 +的响应执行 1,204 条 SQL,首次 52~65 ms。100 批次、每批 20 来源的最终发布为 +567/711/740 ms。该分布是压力样本,不代表用户实际库,首次调用也不等于冷 OS 缓存。 + +Renderer 源码 Electron 场景中,2,000 事件产生 8,207 个 DOM 元素,出现 152~314 ms 长任务; +2,000 故事各十事件的摘要首次计算 77~81 ms,缓存路由返回仍重复计算。 +原始基线脚本位于批准的临时目录,后续对照必须固定数据、版本和口径。 + +## 验证要求 + +- 对功能缺陷先运行失败回归,再修复;不放宽断言、超时或性能预算。 +- 性能对照至少三轮交替执行,基线用独立临时 worktree;分别记录 Main 阻塞、查询耗时、 + 响应大小、Renderer 长任务及点击反馈,不能把不同探针耗时相加成端到端值。 +- 数据用隔离合成 SQLite 或一致性副本,不修改 portable 原始库;索引升级遵守迁移指南。 +- 真实 Electron 验证首次/热切换、历史与详情、键盘、焦点、选中和滚动;不以截断内容提速。 +- 远程 Runtime 改动在共享 Linux x64 Host 验证当前源码,使用专用目录及进程;记录有界真实模型调用数。 +- 最后运行完整测试、类型检查和 lint;发布准备不运行本地生产打包或已打包应用探针。 + +## 实施与测量结果 + +### 监督者读取与界面 + +后端保留原业务合同,文件数据库的三个 UI 读取在现有 Worker 的读快照中执行,失败直接报告,不回退 Main 重查询。概览新增匹配索引;故事/经验批量查询按项目筛选;图谱保留所有事件和关联,来源只传已有 schema 所需标题与时间,完整正文仍走来源详情。 + +| 测量项 | 基线 | 修复后 | +| --- | --- | --- | +| 大样本概览:调用方最大循环间隔,三轮 | 389/471/873 ms | 2.45/2.10/3.17 ms | +| 大样本图谱:调用方最大循环间隔,三轮 | 583/572/646 ms | 12.04/12.29/15.79 ms | +| 故事/经验响应的 SQL 数 | 1,204 | 6 | +| 大样本图谱 JSON 大小 | 25,742,067 字节 | 7,143,667 字节 | +| 2,000 事件图谱点击至两帧反馈 | 188~346 ms | 24~39 ms | +| 同场景挂载列表行/文档元素 | 2,000/8,207 | 24/329(首轮窗口实现) | +| 2,000 故事各十事件的首次摘要 CPU | 48~80 ms | 9~16 ms | +| 缓存路由返回的摘要 CPU | 43~74 ms | 0 ms | +| 100 批次发布的同步耗时 | 686/729/730 ms | 271/286/303 ms | +| 100 批次的快照 UPDATE 数 | 101 | 1 | + +数据来自固定基线 `e50ca6d1` 与修复版三轮对照,平台为 Windows x64、Core Ultra X7 358H、Electron 44.5.1。 +存储测量使用真实合成 SQLite 和 Worker,界面测量使用生产 React 组件与模拟 bridge,二者没有相加成端到端时间。 +发布复测包含工作树并行删除校验,不能作为本轮独立提交的精确耗时保证。迁移前后 55 张表、32,947 行含 rowid 的哈希一致,外键及完整性检查通过,未执行正文转换或回收。 + +独立复审后,故事读取增加 scope/刷新版本所有权校验,避免旧编辑完成时使新读取失效。 +虚拟列表额外保留逻辑首末行,保证从外部 Tab/Shift+Tab 进入时仍按完整顺序访问;最终挂载量比上述首轮测量最多多两行,后续 Electron 继续检查有界性与可见焦点。共享列表的主动滚离焦点行为保持不变。 + +新 Electron 回归还暴露既有消息展开测试的等待缺陷:语言切换后等到了旧节点,且帧轮询回调异常会留下未结算 Promise。测试现等待替代请求的最终渲染,帧回调异常正常拒绝;原超时及断言不放宽,相同严格场景在基线与当前源码均通过。 + +完整后端和前端逐轮数据分别见[后端记录](../features/conversation-supervision/progress.md#2026-10-05-监督页面读取与发布局部优化)和[界面记录](../features/conversation-supervision/frontend-progress.md)。 + +### 运行时与交付 + +K07 的路由 HTTP 清理不再占用全局控制队列;迟到安装按原 Session/operation 清除,不影响活跃 peer。 +当前源码通过共享 Linux x64 的 OpenCode 与 Continue:peer 查询分别 13/46 ms,取消完成分别 176/194 ms;工具、断开恢复、测试 Agent 提升及事件重放均通过。本轮准确为 **8 次外部文本 HTTP 请求、0 次图片调用**,含首次测试触及旧请求数上限前的两次请求;各运行结束时所属进程和目录残留均为零。详见[运行时记录](../features/unified-execution/progress.md#2026-10-05-k07k08-局部修复)。 + +K08 超容量后立即中止并清空正文,最多消费一秒用量事件,保留原错误且不重试。OpenAI/Anthropic、已缓冲完成响应和不响应取消的迭代器均有回归。 + +K09 已验证启动 webhook 冲突时仍可发送已验证身份匹配的待发结果;发送错误可见且不覆盖接收错误;最后一次 429 的等待可取消并跨越驱动/发件箱边界。 +Telegram 验证只使用 loopback HTTP、合成 SQLite 和真实 Electron 设置入口,没有向真实收件人发消息。详见[通道记录](../features/telegram-channel/progress.md)。 + +### 最终检查 + +将本轮 59 个路径的暂存内容应用到 `e50ca6d1` 的隔离工作树,排除并行删除功能和宣传稿后执行完整验收。验证时暂存树为 `31e6199b91cb703ddffa3806807326143064a7f1`;随后只补充文档中的验收结果,没有再改代码。暂存代码、测试与独立工作树逐文件核对一致。 + +| 命令 | 最终结果 | +| --- | --- | +| `npm test` | 523 文件通过、15 跳过;6,212 用例通过、0 失败、87 跳过,832.51 秒 | +| `npm run typecheck` | Main、Agent、Renderer 全部通过,7.36 秒 | +| `npm run lint` | 通过,24.24 秒 | +| `git diff --cached --check` | 通过 | + +完整测试使用本机缓存的 Runtime 二进制及配置,包含原生 OpenCode、监督者布局、消息展开和 Telegram Electron 场景;未开启外部模型验收开关。首次隔离测试因缺少 Runtime 缓存多跳过 13 项,不作为最终结果;补齐已有缓存后完整重跑得到上表计数。并行实施期间的其他失败轮次保留在各组记录中,不与最终结果混算。 + +日志、JSON 测试结果、跳过清单、暂存快照及文件哈希位于批准临时目录 `opencode/kiss-staged-validation-Bmc3dm`,以 `final-verification.json`、`snapshot.json` 和 `test.log` 为索引。验证前后原工作区 77 个已变更文件逐字节未变;未暂存的并行修改完整保留。本轮没有生产打包、推送或发布。 + +## 剩余限制 + +- 大批量发布仍在 Main,约 271~303 ms,未达到 16 ms 预算;保留原事务及持久化语义,后续讨论迁移。 +- 故事完整响应仍约 3.32 MB,曾有一次 19.74 ms 的调用方间隔;图谱正文统计在 Worker 仍需数百毫秒。 +- 未预载的源码模块首次进入仍约 0.31~0.34 秒;一轮小图谱首开出现 55 ms 长任务,没有宣称所有预算达标。 +- 未覆盖完整生产窗口 IPC 的端到端性能、低端机节流、macOS/ARM64、签名包安装或真实 Telegram 平台收发。 +- schema 62 按既有规则禁止旧客户端打开;独立存储服务、全仓异步化和发布版本号调整均不属于本轮。 diff --git a/docs/review/performance-phase-1-report-2026-10-04.md b/docs/review/performance-phase-1-report-2026-10-04.md index 007ed156..71291f46 100644 --- a/docs/review/performance-phase-1-report-2026-10-04.md +++ b/docs/review/performance-phase-1-report-2026-10-04.md @@ -1,5 +1,7 @@ # 桌面端性能改造(第一期,参照 VS Code 架构)结项报告 +2026-10-05 校正:本文保留第一期测量截面,开头结论只适用于当时已测场景。生产监督者首次进入和最终发布未被这些场景覆盖,后续实测发现阻塞并开展[局部修复](./kiss-local-fixes-2026-10-05.md)。`perf:check` 的历史回归上限也不等于性能原则的全部预算。 + - **时间:** 2026-10-02 至 2026-10-04 - **范围:** Desktop 的 Electron Main、Preload、Renderer 及其后台线程。远程主机(Agent、SSH、远程 Runtime)的稳定性和性能不在本期范围,下一期单独审查。 - **结论:** 本期计划内的工作已完成。用户日常能感受到的卡顿(打字、流式输出、切换会话、滚动、启动、写库)都已测量并修复。剩下的工作是架构上的长期项(存储接口异步化、独立数据进程),风险高,已经写明理由,留到后续单独立项。 @@ -72,7 +74,7 @@ Main 卡顿的数据来自真实数据,以及 5 万个文本块的知识库。 | Markdown 解析放到后台线程 | 需要重写渲染流程。标准机上流式输出已经不卡,只有低端机上的长回答还有余量 | | 窗口提前加载,接口请求排队等待 | 可省 200–300 ms,但要改动所有接口的注册顺序和首次运行流程 | | 会话队列移出 App | 和发送、恢���、中断联系太紧,拆出来的风险大于收益 | -| 性能阈值接入 CI | 仓库目前没有 CI 配置 | +| 性能阈值接入 CI | 当期未将性能基准接入已有 CI;原文“仓库没有 CI 配置”有误 | ## 4. 已知问题和下一步 diff --git a/docs/roadmap/product-performance-experience-improvement-plan.md b/docs/roadmap/product-performance-experience-improvement-plan.md index b0e47e24..62fff8a0 100644 --- a/docs/roadmap/product-performance-experience-improvement-plan.md +++ b/docs/roadmap/product-performance-experience-improvement-plan.md @@ -1019,6 +1019,10 @@ Renderer 主线程降速 4 倍(Main 不降速,每次 reload 后重新应用 ### PERF-15 存储接口异步化 +2026-10-05 范围澄清:目标职责与迁移边界见[桌面存储方向](../architecture/desktop-storage-direction.md)。 +当前先实施[监督者及执行路径的 KISS 局部修复](../review/kiss-local-fixes-2026-10-05.md), +完成、验证并提交后再讨论全面迁移。局部 Worker 复用不等于 PERF-15/16 已完成。 + - **优先级 / 状态:** P1 / 进行中(热点读走 worker;高频写降为 NORMAL、checkpoint 移出 Main、启动恢复按索引,见 7.2.2;接口异步化未开始) - **范围:** 在原进程内把 `AssistantDatabase`、`KnowledgeDatabase` 等对外接口改为异步, 调用方全部 `await`;会话列表只返回摘要,搜索下推到 SQL,活动记录改为增量追加。 @@ -1030,9 +1034,10 @@ Renderer 主线程降速 4 倍(Main 不降速,每次 reload 后重新应用 - **优先级 / 状态:** P1 / 待开始 - **实施条件:** `PERF-15` 完成,调用方已全部异步。 -- **范围:** 新增独占 SQLite 的 `utilityProcess`,承接会话、知识库、向量检索、Story Graph - (含 Agent 主动调用的检索)、远程 Runtime 事件持久化、活动记录及文档解析;Main 只转发; - 保证同一时刻只有一个写入方。吸收 `PERF-06` 的范围。 +- **范围:** 目标为统一管理业务 SQLite 的 `utilityProcess`,承接会话、知识库、Story Graph、 + 远程 Runtime 事件持久化和活动记录的业务事务;Main 只做校验与转发。重查询由受管只读 Worker + 分担,解析、OCR 与 Runtime 执行保持独立,每个数据库明确一个写入所有者;不合并现有数据库。 + 职责、生命周期及 VS Code 对照以[桌面存储方向](../architecture/desktop-storage-direction.md)为准。 - **验收:** 大知识库检索、会话列表和文档导入期间 Main event-loop 延迟达到批次目标;取消、 崩溃恢复和迁移语义不回退。 diff --git a/src/agent-daemon/agent-acp-connection.ts b/src/agent-daemon/agent-acp-connection.ts index 5e38d105..f1e606fa 100644 --- a/src/agent-daemon/agent-acp-connection.ts +++ b/src/agent-daemon/agent-acp-connection.ts @@ -18,6 +18,7 @@ export class AgentAcpConnection { private closed = false private closeInput?: () => void private bridgeOrigin?: string + private readonly routeLifetime = new AbortController() constructor( process: RuntimeAcpProcessOwner, @@ -43,6 +44,7 @@ export class AgentAcpConnection { this.unsubscribeExit = process.subscribeExit?.(() => { if (this.closed) return const error = new Error('ACP Runtime process exited') + this.routeLifetime.abort(error) controller?.error(error) controller = undefined rejectExit(error) @@ -89,17 +91,19 @@ export class AgentAcpConnection { socketPath?: string, imageToolName?: string ): Promise { + this.routeLifetime.signal.throwIfAborted() if (!this.bridgeOrigin) throw new Error('Shared model bridge is not ready') const response = await fetch(`${this.bridgeOrigin}/session`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ sessionId, operationId, ...(socketPath ? { socketPath, imageToolName } : { release: true }) }), - signal: AbortSignal.timeout(10_000) + signal: AbortSignal.any([this.routeLifetime.signal, AbortSignal.timeout(10_000)]) }) if (!response.ok) throw new Error(`Shared model bridge route failed (${response.status})`) } dispose(): void { if (this.closed) return this.closed = true + this.routeLifetime.abort(new Error('ACP transport is closed')) this.sessions.clear() this.unsubscribeOutput() this.unsubscribeExit() diff --git a/src/agent-daemon/agent-owned-acp-prompt.test.ts b/src/agent-daemon/agent-owned-acp-prompt.test.ts index 291bc34b..0bc4b9f7 100644 --- a/src/agent-daemon/agent-owned-acp-prompt.test.ts +++ b/src/agent-daemon/agent-owned-acp-prompt.test.ts @@ -12,6 +12,7 @@ import type { RuntimeAcpProcessOwner } from './runtime-acp-backend' import { AgentOwnedAcpPrompt } from './agent-owned-acp-prompt' +import { AgentAcpConnection } from './agent-acp-connection' import { SemanticPromptStore } from './semantic-prompt-store' const temporary: string[] = [] @@ -23,6 +24,35 @@ afterEach(() => { }) describe('AgentOwnedAcpPrompt', () => { + it.each(['dispose', 'exit'])('aborts pending route HTTP requests on transport %s', async action => { + const process = new MemoryProcess() + let client!: Client + const transport = new AgentAcpConnection(process, undefined, factory => { + client = factory() + return {} as ClientSideConnection + }) + const signals: AbortSignal[] = [] + const fetcher = vi.fn(async (_input, init) => new Promise((_resolve, reject) => { + const signal = init!.signal! + signals.push(signal) + signal.addEventListener('abort', () => reject(signal.reason), { once: true }) + })) + vi.stubGlobal('fetch', fetcher) + try { + await client.extNotification!('goodbuddy/modelBridgeReady', { origin: `http://127.0.0.1:1234/${'a'.repeat(43)}` }) + const install = transport.setModelRoute('session-1', 'operation-1', '/broker').catch(error => error) + const release = transport.setModelRoute('session-2', 'operation-2').catch(error => error) + expect(signals.every(signal => !signal.aborted)).toBe(true) + if (action === 'dispose') transport.dispose() + else await process.emitExit() + expect(signals.every(signal => signal.aborted)).toBe(true) + expect(await install).toBeInstanceOf(Error) + expect(await release).toBeInstanceOf(Error) + await expect(transport.setModelRoute('session-1', 'operation-1')).rejects.toThrow() + expect(fetcher).toHaveBeenCalledTimes(2) + } finally { transport.dispose(); vi.unstubAllGlobals() } + }) + it('coalesces identical starts and rejects conflicting starts while initialization is pending', async () => { const root = mkdtempSync(join(tmpdir(), 'goodbuddy-owned-start-')) temporary.push(root) @@ -88,7 +118,7 @@ describe('AgentOwnedAcpPrompt', () => { bindingId: 'binding-1', operationId, requestId: operationId, prompt: [{ type: 'text', text: 'Continue' }] }) - expect(prepareSession).toHaveBeenLastCalledWith('session-1', operationId) + expect(prepareSession).toHaveBeenLastCalledWith('session-1', operationId, expect.any(AbortSignal)) if (index !== 2) { expect(index === 0 ? newSession : resumeSession).toHaveBeenLastCalledWith(expect.objectContaining({ mcpServers: mcpServers() })) } diff --git a/src/agent-daemon/agent-owned-acp-prompt.ts b/src/agent-daemon/agent-owned-acp-prompt.ts index 5b55511b..767a920f 100644 --- a/src/agent-daemon/agent-owned-acp-prompt.ts +++ b/src/agent-daemon/agent-owned-acp-prompt.ts @@ -32,7 +32,7 @@ export type AgentOwnedAcpPromptOptions = { expectedModel?: string process: RuntimeAcpProcessOwner transport?: AgentAcpConnection - prepareSession?: (sessionId: string, operationId: string) => Promise + prepareSession?: (sessionId: string, operationId: string, signal: AbortSignal) => Promise transcript: SemanticPromptStore completePrompt: ( operationId: string, @@ -237,7 +237,7 @@ export class AgentOwnedAcpPrompt { })) } this.#hasMcpServers = mcpServers.length > 0 - await whileStarting(Promise.resolve(this.#options.prepareSession?.(this.#sessionId!, request.operationId))) + await whileStarting(Promise.resolve(this.#options.prepareSession?.(this.#sessionId!, request.operationId, signal))) signal.throwIfAborted() this.#active = { operationId: request.operationId diff --git a/src/agent-daemon/runtime-acp-backend.test.ts b/src/agent-daemon/runtime-acp-backend.test.ts index 35ac8fa5..e7a9fcea 100644 --- a/src/agent-daemon/runtime-acp-backend.test.ts +++ b/src/agent-daemon/runtime-acp-backend.test.ts @@ -518,6 +518,92 @@ function harness(input: { } describe('RuntimeAcpBackend', () => { + it.each(['cancel', 'deadline'])('does not hold peer control while releasing a startup route after %s', async reason => { + const fixture = await ownedHarness(true) + const releaseRoute = deferred() + let finishModel: (() => Promise) | undefined + let releasing = false + try { + await fixture.start() + const process = fixture.processes[0]! + const originalWrite = process.onWrite! + process.onWrite = payload => { + const request = JSON.parse(Buffer.from(payload).toString()) + const reply = (result: unknown) => process.emit('stdout', `${JSON.stringify({ jsonrpc: '2.0', id: request.id, result })}\n`) + if (request.method === 'session/new') void reply({ sessionId: 'starting-session' }) + else if (request.method === 'session/set_config_option' && request.params.sessionId === 'starting-session') { + finishModel = () => reply({ configOptions: [{ id: 'model', name: 'Model', type: 'select', currentValue: request.params.value, options: [] }] }) + } 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 })) + const starting = invoke(fixture, 'runtime/startPrompt', { bindingId: 'starting', operationId: 'starting-request', requestId: 'starting-request', prompt: [{ type: 'text', text: 'test' }] }).catch(error => error) + await vi.advanceTimersByTimeAsync(1) + expect(finishModel).toBeDefined() + vi.mocked(AgentAcpConnection.prototype.setModelRoute).mockImplementation(async (_session, _operation, socket) => { + if (!socket) { releasing = true; await releaseRoute.promise } + }) + let cancelled = reason === 'deadline', peer = false + const cancellation = reason === 'cancel' ? invoke(fixture, 'runtime/escalateCancellation', { + bindingId: 'starting', operationId: 'starting-request', requestId: 'starting-request', sessionId: 'starting-session', reason: 'requested' + }).then(() => { cancelled = true }) : undefined + await vi.advanceTimersByTimeAsync(200) + expect(releasing).toBe(true) + const query = invoke(fixture, 'runtime/getAcpCursors', { bindingId: 'binding-1' }).then(() => { peer = true }) + await vi.advanceTimersByTimeAsync(200) + const observed = { cancelled, peer } + releaseRoute.resolve() + await finishModel!() + finishModel = undefined + await Promise.all([starting, cancellation, query]) + expect(observed).toEqual({ cancelled: true, peer: true }) + expect(process.stops).toHaveLength(0) + expect(fixture.transcript.attach('binding-1', 'request-1', fixture.context.controller.controllerId).state).toBe('running') + expect(fixture.page().events.filter(event => event.kind === 'prompt-terminal')).toHaveLength(0) + } finally { + releaseRoute.resolve(); await finishModel?.(); await fixture.prompts[0]?.() + vi.useRealTimers(); await fixture.cleanup() + } + }) + + it('removes a late startup route after cancellation without revoking its peer route', async () => { + const fixture = await ownedHarness(true) + const install = deferred() + const entered = deferred() + const routes = new Map([['session-1', 'request-1']]) + 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') void process.emit('stdout', `${JSON.stringify({ jsonrpc: '2.0', id: request.id, result: { sessionId: 'starting-session' } })}\n`) + else originalWrite(payload) + } + vi.mocked(AgentAcpConnection.prototype.setModelRoute).mockImplementation(async (session, operation, socket) => { + if (socket) { entered.resolve(); await install.promise; routes.set(session, operation) } + else if (routes.get(session) === operation) routes.delete(session) + }) + const opened = await invoke(fixture, 'runtime/openAcpChannel', { ...fixture.openRequest, bindingId: 'starting' }) as { channelEpoch: string } + await invoke(fixture, 'runtime/preparePrompt', fixture.preparation({ bindingId: 'starting', channelEpoch: opened.channelEpoch, + operationId: 'starting-request', requestId: 'starting-request', deadlineAt: UNBOUNDED_REMOTE_PROMPT_DEADLINE })) + const starting = invoke(fixture, 'runtime/startPrompt', { bindingId: 'starting', operationId: 'starting-request', requestId: 'starting-request', prompt: [{ type: 'text', text: 'test' }] }).catch(error => error) + await entered.promise + await invoke(fixture, 'runtime/escalateCancellation', { bindingId: 'starting', operationId: 'starting-request', requestId: 'starting-request', sessionId: 'starting-session', reason: 'requested' }) + expect(await starting).toBeInstanceOf(Error) + install.resolve() + await new Promise(resolve => setImmediate(resolve)) + expect([...routes]).toEqual([['session-1', 'request-1']]) + expect(process.stops).toHaveLength(0) + const page = fixture.transcript.page({ bindingId: 'starting', operationId: 'starting-request', controllerId: fixture.context.controller.controllerId, afterSequence: '0', limit: 10 }) + expect(page.state).toBe('cancelled') + expect(page.events.filter(event => event.kind === 'prompt-terminal')).toHaveLength(1) + } finally { install.resolve(); await fixture.prompts[0]?.(); await fixture.cleanup() } + }) + it.each(['watchdog', 'dispose'])('interrupts stalled native initialization through %s', async action => { vi.useFakeTimers() const fixture = harness({ agentOwned: true, shareOwnedProcesses: true }) diff --git a/src/agent-daemon/runtime-acp-backend.ts b/src/agent-daemon/runtime-acp-backend.ts index 2ff57ce0..ce6d7be6 100644 --- a/src/agent-daemon/runtime-acp-backend.ts +++ b/src/agent-daemon/runtime-acp-backend.ts @@ -1502,11 +1502,17 @@ export class RuntimeAcpBackend { process: binding.process, ...(binding.sharedProcess ? { transport: binding.sharedProcess.transport, - prepareSession: async (sessionId: string, operationId: string) => { - await binding.sharedProcess!.transport.setModelRoute( - sessionId, operationId, binding.modelBridgeBroker!.socketPath, - binding.imageTool ? remoteImageToolMcpName(binding.request.bindingId) : undefined - ) + prepareSession: async (sessionId: string, operationId: string, signal: AbortSignal) => { + const transport = binding.sharedProcess!.transport + try { + await transport.setModelRoute( + sessionId, operationId, binding.modelBridgeBroker!.socketPath, + binding.imageTool ? remoteImageToolMcpName(binding.request.bindingId) : undefined + ) + } finally { + // Cancellation can release the route before this installation finishes. + if (signal.aborted) await transport.setModelRoute(sessionId, operationId).catch(() => undefined) + } } } : {}), transcript: this.#options.semanticPrompts, @@ -1897,7 +1903,9 @@ export class RuntimeAcpBackend { binding.modelBridgeClient = undefined let brokerError: unknown if (binding.sharedProcess && binding.ownedAcp?.sessionId && binding.activeOperationId) { - await binding.sharedProcess.transport.setModelRoute( + // Broker shutdown below revokes dispatch authority. Native route cleanup + // is bounded by the transport and must not hold the global control queue. + void binding.sharedProcess.transport.setModelRoute( binding.ownedAcp.sessionId, binding.activeOperationId ).catch(() => undefined) } diff --git a/src/main/assistant/assistant-database.test.ts b/src/main/assistant/assistant-database.test.ts index 5c49d814..89fc9538 100644 --- a/src/main/assistant/assistant-database.test.ts +++ b/src/main/assistant/assistant-database.test.ts @@ -320,12 +320,13 @@ it('persists supervision knowledge locators and keeps memory sources distinct', } database.saveSupervisionResult(result) const graph = database.getSupervisionGraph() - expect(graph.sources).toEqual(expect.arrayContaining([ - expect.objectContaining({ source_type: 'knowledge', locator_json: JSON.stringify({ libraryId: 'library-1', documentId: 'document-1', chunkId: 'chunk-1' }) }), - expect.objectContaining({ source_type: 'memory', locator_json: null }) + const sources = (graph.sources as Array<{ id: string }>).map(source => database.getSupervisionSource(source.id)!) + expect(sources).toEqual(expect.arrayContaining([ + expect.objectContaining({ sourceType: 'knowledge', locatorJson: JSON.stringify({ libraryId: 'library-1', documentId: 'document-1', chunkId: 'chunk-1' }) }), + expect.objectContaining({ sourceType: 'memory', locatorJson: null }) ])) - const source = (graph.sources as Array<{ id: string; source_type: string }>).find((item) => item.source_type === 'knowledge')! - expect(database.getSupervisionSource(source.id)).toMatchObject({ sourceType: 'knowledge', locatorJson: JSON.stringify({ libraryId: 'library-1', documentId: 'document-1', chunkId: 'chunk-1' }) }) + const source = sources.find(item => item.sourceType === 'knowledge')! + expect(source).toMatchObject({ content: result.evidence[0]!.content, sourceType: 'knowledge', locatorJson: JSON.stringify({ libraryId: 'library-1', documentId: 'document-1', chunkId: 'chunk-1' }) }) } finally { database.close() } @@ -443,7 +444,8 @@ it.each(['same-project', 'different-project'] as const)('isolates supervision mo expect(graph.events).toHaveLength(1) expect(graph.entities).toHaveLength(2) expect(graph.sources).toHaveLength(1) - expect(graph.sources).toEqual(expect.arrayContaining([expect.objectContaining({ id: sourceId, locator_json: JSON.stringify(results.find((row) => row.id === event.result_id)!.summary === 'First' ? result.evidence[0]!.locator : next.evidence[0]!.locator) })])) + expect(graph.sources).toEqual(expect.arrayContaining([expect.objectContaining({ id: sourceId })])) + expect(database.getSupervisionSource(sourceId)).toMatchObject({ locatorJson: JSON.stringify(results.find((row) => row.id === event.result_id)!.summary === 'First' ? result.evidence[0]!.locator : next.evidence[0]!.locator) }) } expect(inspected.prepare('PRAGMA foreign_key_check').all()).toEqual([]) } finally { inspected.close() } @@ -2936,7 +2938,7 @@ describe('AssistantDatabase', () => { migrated.upgradeStorage(progress, () => false, { migrateNotes: false, reclaimSpace: false }) expect(progress).not.toHaveBeenCalled() const check = new DatabaseSync(path) - expect(check.prepare('PRAGMA user_version').get()!.user_version).toBe(61) + expect(check.prepare('PRAGMA user_version').get()!.user_version).toBe(ASSISTANT_DATABASE_SCHEMA_VERSION) expect(tables.map(table => check.prepare(`SELECT rowid, * FROM ${table} ORDER BY rowid`).all())).toEqual(rows) expect(check.prepare('SELECT type, name, tbl_name, rootpage, sql FROM sqlite_schema ORDER BY name').all()).toEqual( schema.map(row => ['projects', 'conversations'].includes(row.name as string) && row.type === 'table' diff --git a/src/main/assistant/assistant-database.ts b/src/main/assistant/assistant-database.ts index 9385463b..3265fd80 100644 --- a/src/main/assistant/assistant-database.ts +++ b/src/main/assistant/assistant-database.ts @@ -136,7 +136,7 @@ import { SUBAGENT_PROGRESS_STORAGE_SCHEMA_VERSION } from './subagent-progress-storage' -export const ASSISTANT_DATABASE_SCHEMA_VERSION = 61 +export const ASSISTANT_DATABASE_SCHEMA_VERSION = 62 function withoutHistoricalWorkMode(value: unknown): unknown { if (!value || typeof value !== 'object' || Array.isArray(value)) return value @@ -9144,6 +9144,7 @@ export class AssistantDatabase { resultId, runId, result.output.summary, result.output.changeDigest, JSON.stringify(result.output.openItems), JSON.stringify(result.coverage ?? { evidence: result.evidence.length }), timestamp ) + const graph: { entities: Record[]; relations: Record[] } = { entities: [], relations: [] } const saveFacts = (result: StoredSupervisionResult) => { // Model IDs are local to one batch, never persistent cross-run identities. const sourceIds = new Map(result.evidence.map((source) => [source.id, randomUUID()])) @@ -9222,15 +9223,8 @@ export class AssistantDatabase { story.id, persistentId(entityIds, relation.fromEntityId), persistentId(entityIds, relation.toEntityId), relation.relationType), source_reference_ids_json: sourceReferences(relation.sourceReferenceIds) })) - database.prepare(`UPDATE supervision_results SET graph_snapshot_json = json_object( - 'entities', json((SELECT json_group_array(json(value)) FROM ( - SELECT value FROM json_each(supervision_results.graph_snapshot_json, '$.entities') - UNION ALL SELECT value FROM json_each(?) - ))), - 'relations', json((SELECT json_group_array(json(value)) FROM ( - SELECT value FROM json_each(supervision_results.graph_snapshot_json, '$.relations') - UNION ALL SELECT value FROM json_each(?) - )))) WHERE id = ?`).run(JSON.stringify(entities), JSON.stringify(relations), resultId) + for (const entity of entities) graph.entities.push(entity) + for (const relation of relations) graph.relations.push(relation) } if (result.coverage && result.runId) { for (let offset = 0;; offset += 10) { @@ -9242,12 +9236,15 @@ export class AssistantDatabase { // each leaf's full description and references remain independently readable. database.prepare(`UPDATE supervision_results SET graph_snapshot_json = json_object( 'entities', json((SELECT json_group_array(json(value)) FROM ( - SELECT value FROM json_each(supervision_results.graph_snapshot_json, '$.entities') GROUP BY json_extract(value, '$.id')))), + SELECT value FROM json_each(?) GROUP BY json_extract(value, '$.id')))), 'relations', json((SELECT json_group_array(json(value)) FROM ( - SELECT value FROM json_each(supervision_results.graph_snapshot_json, '$.relations') GROUP BY json_extract(value, '$.id'))))) - WHERE id = ?`).run(resultId) + SELECT value FROM json_each(?) GROUP BY json_extract(value, '$.id'))))) + WHERE id = ?`).run(JSON.stringify(graph.entities), JSON.stringify(graph.relations), resultId) this.supervisionReviewStore().commitCheckpoints(result.runId, result.request) - } else saveFacts(result) + } else { + saveFacts(result) + database.prepare('UPDATE supervision_results SET graph_snapshot_json = ? WHERE id = ?').run(JSON.stringify(graph), resultId) + } placeTimelineEvents(database, resultId) supersedeReextractedEvents(database, resultId) if (result.batch) this.saveReviewCheckpoints(result.batch) @@ -9273,6 +9270,36 @@ export class AssistantDatabase { return this.requireDatabase().prepare('SELECT id, summary FROM supervision_results WHERE id = ?').get(id) as { id: string; summary: string } | undefined } + async listSupervisionResultsAsync(limit = 20, target?: SupervisionTarget, resultId?: string): Promise> { + this.requireDatabase() + const reader = this.readonlyQueryReader() + if (reader) return reader.call('supervisionOverview', [limit, target, resultId]) + if (this.databasePath !== ':memory:') throw new Error('Supervision worker is not configured') + return this.listSupervisionResults(limit, target, resultId) + } + + async getSupervisionGraphAsync(input: SupervisionGraphRequest = {}): Promise> { + this.requireDatabase() + const reader = this.readonlyQueryReader() + if (reader) return reader.call('supervisionGraph', [input]) + if (this.databasePath !== ':memory:') throw new Error('Supervision worker is not configured') + return this.getSupervisionGraph(input) + } + + getSupervisionStories(scope: SupervisionRunRequest['scope']) { + const stories = this.supervisionStories() + return { stories: stories.list(scope), experiences: this.supervisionExperiences().list(scope.kind === 'projects' ? scope.projectIds : undefined), + unassigned: stories.unassignedCount(scope), canUndo: stories.canUndo() } + } + + async getSupervisionStoriesAsync(scope: SupervisionRunRequest['scope']): Promise> { + this.requireDatabase() + const reader = this.readonlyQueryReader() + if (reader) return reader.call('supervisionStories', [scope]) + if (this.databasePath !== ':memory:') throw new Error('Supervision worker is not configured') + return this.getSupervisionStories(scope) + } + listSupervisionResults(limit = 20, target?: SupervisionTarget, resultId?: string) { const owner = target?.type === 'conversation' ? this.getConversation(target.conversationId) : target?.type === 'task' ? this.getTask(target.taskId) : undefined @@ -9325,7 +9352,7 @@ export class AssistantDatabase { attention: supervisionAttention(database, scope.kind === 'projects' ? scope.projectIds : undefined, new Date(range.from).toISOString(), new Date(range.to).toISOString()), events: database.prepare('SELECT * FROM supervision_events WHERE result_id = ? ORDER BY occurred_at').all(input.resultId), - sources: database.prepare('SELECT * FROM supervision_sources WHERE result_id = ?').all(input.resultId), + sources: database.prepare('SELECT id, title, occurred_at FROM supervision_sources WHERE result_id = ? ORDER BY rowid').all(input.resultId), eventEntities: database.prepare(`SELECT ee.event_id, ee.entity_id FROM supervision_event_entities ee JOIN supervision_events e ON e.id = ee.event_id WHERE e.result_id = ?`).all(input.resultId), eventSources: database.prepare(`SELECT es.event_id, es.source_id FROM supervision_event_sources es JOIN supervision_events e ON e.id = es.event_id WHERE e.result_id = ?`).all(input.resultId) } @@ -9352,9 +9379,9 @@ export class AssistantDatabase { GROUP BY r.from_entity_id, r.to_entity_id, r.relation_type`).all(id), eventEntities: database.prepare('SELECT event_id, entity_id FROM supervision_event_entities WHERE event_id IN (SELECT id FROM temp.story_events)').all(), eventSources: database.prepare('SELECT event_id, source_id FROM supervision_event_sources WHERE event_id IN (SELECT id FROM temp.story_events)').all(), - sources: database.prepare(`SELECT * FROM supervision_sources WHERE id IN (SELECT source_id FROM supervision_event_sources + sources: database.prepare(`SELECT id, title, occurred_at FROM supervision_sources WHERE id IN (SELECT source_id FROM supervision_event_sources WHERE event_id IN (SELECT id FROM temp.story_events)) OR result_id IN (SELECT r.id FROM supervision_results r - JOIN supervision_runs sr ON sr.id = r.run_id WHERE sr.scope_json = ?)`).all(String((story as { scope_json: string }).scope_json)), + JOIN supervision_runs sr ON sr.id = r.run_id WHERE sr.scope_json = ?) ORDER BY rowid`).all(String((story as { scope_json: string }).scope_json)), attention: this.storyAttention(scope) } } finally { database.exec('DROP TABLE IF EXISTS temp.story_events') } @@ -12334,6 +12361,17 @@ export class AssistantDatabase { database.enableDefensive(true) } } + if (version.user_version < 62) { + database.exec('BEGIN IMMEDIATE') + try { + database.exec(` + CREATE INDEX IF NOT EXISTS supervision_sources_result ON supervision_sources(result_id, occurred_at, source_type, id); + CREATE INDEX IF NOT EXISTS supervision_results_created ON supervision_results(created_at); + PRAGMA user_version = 62; + COMMIT; + `) + } catch (error) { database.exec('ROLLBACK'); throw error } + } } supervisionExperiences(): SupervisionExperienceStore { diff --git a/src/main/assistant/supervision-experiences.ts b/src/main/assistant/supervision-experiences.ts index 6335a1c6..3dd4f3f0 100644 --- a/src/main/assistant/supervision-experiences.ts +++ b/src/main/assistant/supervision-experiences.ts @@ -196,20 +196,32 @@ export class SupervisionExperienceStore { } list(projectIds?: string[]): SupervisionExperience[] { - const rows = this.db.prepare("SELECT * FROM supervision_experiences WHERE status = 'current' ORDER BY created_at, rowid").all() - const links = this.db.prepare(`SELECT x.event_id, x.role, x.note, e.title, e.project_id, COALESCE(e.started_at, e.occurred_at) AS at, - (SELECT s.id FROM supervision_event_stories es JOIN supervision_stories s ON s.id = es.story_id WHERE es.event_id = e.id AND es.is_primary = 1) AS story_id, - (SELECT s.name FROM supervision_event_stories es JOIN supervision_stories s ON s.id = es.story_id WHERE es.event_id = e.id AND es.is_primary = 1) AS story_name + const rows = this.db.prepare(`SELECT * FROM supervision_experiences WHERE status = 'current' + AND id IN (SELECT x.experience_id FROM supervision_experience_events x JOIN supervision_events e ON e.id = x.event_id + WHERE x.role = 'formed' AND e.superseded_by IS NULL) + AND (? IS NULL OR id IN (SELECT x.experience_id FROM supervision_experience_events x JOIN supervision_events e ON e.id = x.event_id + WHERE e.superseded_by IS NULL AND e.project_id != '' AND e.project_id IN (SELECT value FROM json_each(?)))) + ORDER BY created_at, rowid`).all(projectIds ? 1 : null, JSON.stringify(projectIds ?? [])) + if (!rows.length) return [] + // Scope selects experiences, not their links: cross-project context remains complete. + const links = this.db.prepare(`SELECT x.experience_id, x.event_id, x.role, x.note, e.title, e.project_id, + COALESCE(e.started_at, e.occurred_at) AS at, s.id AS story_id, s.name AS story_name FROM supervision_experience_events x JOIN supervision_events e ON e.id = x.event_id - WHERE x.experience_id = ? AND e.superseded_by IS NULL ORDER BY at, e.id`) - return rows.map(row => { - const events = links.all(String(row.id)).map(link => ({ id: String(link.event_id), role: link.role as 'formed' | 'applied', note: String(link.note), + LEFT JOIN supervision_event_stories es ON es.event_id = e.id AND es.is_primary = 1 + LEFT JOIN supervision_stories s ON s.id = es.story_id + WHERE x.experience_id IN (SELECT value FROM json_each(?)) AND e.superseded_by IS NULL + ORDER BY x.experience_id, at, e.id, x.role`).all(JSON.stringify(rows.map(row => row.id))) + const byExperience = new Map() + for (const link of links) { + const id = String(link.experience_id) + const events = byExperience.get(id) ?? [] + events.push({ id: String(link.event_id), role: link.role as 'formed' | 'applied', note: String(link.note), title: String(link.title), projectId: link.project_id ? String(link.project_id) : null, at: String(link.at), - storyId: link.story_id ? String(link.story_id) : null, storyName: link.story_name ? String(link.story_name) : null })) - return { id: String(row.id), statement: String(row.statement), conditions: String(row.conditions), boundaries: String(row.boundaries), - userEdited: row.user_edited === 1, events } - // Experiences are global; a project view keeps those formed or applied in its projects. - }).filter(item => item.events.some(event => event.role === 'formed') && (!projectIds || item.events.some(event => event.projectId && projectIds.includes(event.projectId)))) + storyId: link.story_id ? String(link.story_id) : null, storyName: link.story_name ? String(link.story_name) : null }) + byExperience.set(id, events) + } + return rows.map(row => ({ id: String(row.id), statement: String(row.statement), conditions: String(row.conditions), boundaries: String(row.boundaries), + userEdited: row.user_edited === 1, events: byExperience.get(String(row.id)) ?? [] })) } /** Snapshot of the experiences and links an adjustment changes, for undo. */ diff --git a/src/main/assistant/supervision-history.test.ts b/src/main/assistant/supervision-history.test.ts index 3fc420dc..a7ed7c31 100644 --- a/src/main/assistant/supervision-history.test.ts +++ b/src/main/assistant/supervision-history.test.ts @@ -81,7 +81,7 @@ it('supervision preserves result history and protected identities within scope t expect(read(latest.id).entities.find((entity) => entity.id === confirmedEntity.id)).toMatchObject({ ...confirmedEntity, confirmation_state: 'confirmed' }) expect(read(latest.id).relations[0]).toMatchObject({ id: relationId, reason: 'Original reason', confirmation_state: 'confirmed' }) const storedGraph = db.getSupervisionGraph({ resultId: latest.id }) - const currentSource = (storedGraph.sources as Array<{ id: string; source_type: string }>).find((source) => source.source_type === 'conversation')! + const currentSource = (storedGraph.sources as Array<{ id: string }>).find((source) => db.getSupervisionSource(source.id)!.sourceType === 'conversation')! expect((storedGraph.entities as Array<{ source_reference_ids_json: string }>).every((entity) => entity.source_reference_ids_json === JSON.stringify([currentSource.id]))).toBe(true) expect(read(a.id)).toEqual(original) db.applySupervisionRelationAction({ resultId: latest.id, relationId, action: 'revoke' }) diff --git a/src/main/assistant/supervision-model-retry.test.ts b/src/main/assistant/supervision-model-retry.test.ts index 262f9d82..b21c0dc4 100644 --- a/src/main/assistant/supervision-model-retry.test.ts +++ b/src/main/assistant/supervision-model-retry.test.ts @@ -229,3 +229,94 @@ it('retries an actual interrupted direct-model stream and retains its reported u expect(f.release).toHaveBeenCalledTimes(2) expect(f.dispose).toHaveBeenCalledOnce() }) + +it.each([ + ['openai-chat-completions', false], ['anthropic-messages', false], + ['openai-chat-completions', true], ['anthropic-messages', true] +] as const)( + 'retains reported usage when %s output exceeds capacity without retrying (buffered=%s)', async (protocol, buffered) => { + const text = 'x'.repeat(1024 * 1024 + 1) + const events = protocol === 'anthropic-messages' + ? [{ type: 'message_start', message: { id: 'call', model: 'fake', usage: { input_tokens: 12, output_tokens: 3 } } }, + { type: 'content_block_delta', index: 0, delta: { type: 'text_delta', text } }] + : [{ choices: [{ delta: { content: text } }], usage: { prompt_tokens: 12, completion_tokens: 3 } }] + let body!: ReadableStreamDefaultController + const stream = new ReadableStream({ start(controller) { body = controller } }) + body.enqueue(new TextEncoder().encode(events.map(event => `data: ${JSON.stringify(event)}\n\n`).join(''))) + if (buffered) { + body.enqueue(new TextEncoder().encode(protocol === 'anthropic-messages' ? 'data: {"type":"message_stop"}\n\n' : 'data: [DONE]\n\n')) + body.close() + } + const fetcher = vi.fn(async (_input, init) => { + if (!buffered) init!.signal!.addEventListener('abort', () => body.error(init!.signal!.reason), { once: true }) + return new Response(stream) + }) + const runtime = new ModelAgentRuntime({ baseUrl: 'https://model.invalid', model: 'fake', authentication: 'none', + protocol, toolProvider: noModelTools, fetcher }) + const f = fixture(runtime.run.bind(runtime)) + f.release.mockImplementation(runtime.releaseConversation.bind(runtime)) + f.dispose.mockImplementation(runtime.dispose.bind(runtime)) + await expect(f.start()).rejects.toThrow('1024 KiB') + expect(fetcher).toHaveBeenCalledOnce() + expect(stream.locked).toBe(false) + expect(f.db.getTokenUsageSummary().systemTotals).toMatchObject({ callCount: 1, input: 12, output: 3 }) + expect(f.release).toHaveBeenCalledOnce() + expect(f.dispose).toHaveBeenCalledOnce() + expect(vi.getTimerCount()).toBe(0) + }) + +it('drains usage only after overflow and preserves the cap error over later errors and timeout', async () => { + let reason: unknown + const finalized = vi.fn() + const f = fixture(async function* (input, signal) { + try { + yield { type: 'text', requestId: input.requestId, delta: 'x'.repeat(1024 * 1024 + 1) } + reason = signal.reason + expect(signal.aborted).toBe(true) + await new Promise(resolve => setTimeout(resolve, 200)) + yield { type: 'text', requestId: input.requestId, delta: 'discard trailing text' } + yield { type: 'model-usage', requestId: input.requestId, callId: 'call', runtime: 'model', provider: 'openai', + model: 'fake', inputTokens: 12, outputTokens: 3, cacheReadTokens: 0, cacheWriteTokens: 0 } + yield { type: 'error', requestId: input.requestId, status: 'failed', message: 'terminated' } + yield { type: 'done', requestId: input.requestId } + } finally { finalized() } + }, 0.1) + const pending = f.start().catch(error => error) + await vi.advanceTimersByTimeAsync(250) + expect(await pending).toBe(reason) + expect(reason).toBeInstanceOf(Error) + expect((reason as Error).message).toContain('1024 KiB') + expect(f.runtime.run).toHaveBeenCalledOnce() + expect(finalized).toHaveBeenCalledOnce() + expect(f.db.getTokenUsageSummary().systemTotals).toMatchObject({ callCount: 1, input: 12, output: 3 }) + expect(f.release).toHaveBeenCalledOnce() + expect(f.dispose).toHaveBeenCalledOnce() + expect(vi.getTimerCount()).toBe(0) +}) + +it('bounds overflow accounting drain and ignores events arriving after cleanup', async () => { + let release!: () => void + const gate = new Promise(resolve => { release = resolve }) + const finalized = vi.fn() + const f = fixture(async function* (input) { + try { + yield { type: 'text', requestId: input.requestId, delta: 'x'.repeat(1024 * 1024 + 1) } + await gate + yield { type: 'model-usage', requestId: input.requestId, callId: 'late', runtime: 'model', provider: 'openai', + model: 'fake', inputTokens: 12, outputTokens: 3, cacheReadTokens: 0, cacheWriteTokens: 0 } + } finally { finalized() } + }) + const pending = f.start().catch(error => error) + try { + await vi.advanceTimersByTimeAsync(999) + expect(f.release).not.toHaveBeenCalled() + await vi.advanceTimersByTimeAsync(1) + expect(await pending).toMatchObject({ message: expect.stringContaining('1024 KiB') }) + expect(f.release).toHaveBeenCalledOnce() + expect(f.dispose).toHaveBeenCalledOnce() + await expect(f.pool.run(async () => 'released')).resolves.toBe('released') + } finally { release(); await vi.advanceTimersByTimeAsync(1) } + expect(finalized).toHaveBeenCalledOnce() + expect(f.db.getTokenUsageSummary().systemTotals).toMatchObject({ callCount: 0 }) + expect(vi.getTimerCount()).toBe(0) +}) diff --git a/src/main/assistant/supervision-production.ts b/src/main/assistant/supervision-production.ts index c1b43782..05765842 100644 --- a/src/main/assistant/supervision-production.ts +++ b/src/main/assistant/supervision-production.ts @@ -73,22 +73,37 @@ async function runSupervisionModel(dependencies: SupervisionModelDependencies, r }, timeoutSeconds * 1000) const responseKiB = settings?.supervisionReview?.responseKiB ?? 1024 let output = '', completed = false + let overflowError: Error | undefined + let drainTimeout: ReturnType | undefined + let drainDeadline: Promise | undefined + let events: ReturnType | undefined const requestId = randomUUID() const conversationId = `supervision:${requestId}` try { // Each attempt has its own hidden task so reported usage survives retries. database.createTask({ id: requestId, conversationId, title: request.title, instructions: request.instructions, origin: 'assistant', visible: false }) - for await (const event of runtime.run({ requestId, conversationId, prompt: request.prompt }, - modelSignal, async approval => { await request.authorizeTool(approval.toolName ?? approval.scopeKey); return 'deny' })) { - if (event.type === 'text') { - output += event.delta - if (Buffer.byteLength(output) > responseKiB * 1024) { - controller.abort(new Error(`单次模型响应超过 ${responseKiB} KiB。请在监督者设置中调高响应容量后继续;已保存批次会保留。`)) - modelSignal.throwIfAborted() - } - } + events = runtime.run({ requestId, conversationId, prompt: request.prompt }, + modelSignal, async approval => { await request.authorizeTool(approval.toolName ?? approval.scopeKey); return 'deny' }) + while (true) { + const next = events.next() + const step = await (drainDeadline ? Promise.race([next, drainDeadline]) : next) + if (step.done) break + const event = step.value if (event.type === 'model-usage') persistUsage(event) + if (overflowError) continue + if (event.type === 'text') { + if (Buffer.byteLength(output + event.delta) > responseKiB * 1024) { + overflowError = new Error(`单次模型响应超过 ${responseKiB} KiB。请在监督者设置中调高响应容量后继续;已保存批次会保留。`) + output = '' + controller.abort(overflowError) + // Let the aborted stream emit usage, but never retain further text + // or let an unresponsive iterator hold the supervisor pool forever. + drainDeadline = new Promise((_resolve, reject) => { + drainTimeout = setTimeout(() => reject(overflowError), 1_000) + }) + } else output += event.delta + } if (event.type === 'tool') throw new Error('监督者只允许只读模型摘要,不允许工具调用') if (event.type === 'error') { const error = new Error(event.message) @@ -102,7 +117,7 @@ async function runSupervisionModel(dependencies: SupervisionModelDependencies, r database.updateTaskStatus(requestId, 'completed') return output } catch (error) { - const failure = timeoutError ?? (modelSignal.aborted ? modelSignal.reason : error) + const failure = overflowError ?? timeoutError ?? (modelSignal.aborted ? modelSignal.reason : error) try { database.updateTaskStatus(requestId, modelSignal.aborted ? 'cancelled' : 'failed', failure instanceof Error ? failure.message.slice(0, 2_000) : '监督者模型调用失败') @@ -110,7 +125,10 @@ async function runSupervisionModel(dependencies: SupervisionModelDependencies, r if (attempt > 0 || modelSignal.aborted || !isTransientSupervisionError(failure)) throw failure } finally { clearTimeout(timeout) + clearTimeout(drainTimeout) controller.abort() + const closing = events?.return().catch(() => undefined) + if (!overflowError) await closing await runtime.releaseConversation?.(conversationId) } signal.throwIfAborted() diff --git a/src/main/assistant/supervision-stories.ts b/src/main/assistant/supervision-stories.ts index 541e23bb..4eea074f 100644 --- a/src/main/assistant/supervision-stories.ts +++ b/src/main/assistant/supervision-stories.ts @@ -389,12 +389,22 @@ export class SupervisionStoryStore { AND EXISTS (SELECT 1 FROM supervision_event_stories es JOIN supervision_events e ON e.id = es.event_id WHERE es.story_id = s.id AND e.project_id IN (SELECT value FROM json_each(?))))) ORDER BY s.level, s.name`).all(projects, projects, projects ?? '[]') - const events = this.db.prepare(`SELECT es.event_id, es.is_primary, es.user_set, e.title, e.project_id, + if (!rows.length) return [] + const events = this.db.prepare(`SELECT es.story_id, es.event_id, es.is_primary, es.user_set, e.title, e.project_id, COALESCE(e.started_at, e.occurred_at) AS started_at, COALESCE(e.ended_at, e.occurred_at) AS ended_at - FROM supervision_event_stories es JOIN supervision_events e ON e.id = es.event_id WHERE es.story_id = ? AND e.superseded_by IS NULL ORDER BY started_at, e.id`) + FROM supervision_event_stories es JOIN supervision_events e ON e.id = es.event_id + WHERE es.story_id IN (SELECT value FROM json_each(?)) AND e.superseded_by IS NULL + ORDER BY es.story_id, started_at, e.id`).all(JSON.stringify(rows.map(row => row.id))) + const byStory = new Map() + for (const e of events) { + const id = String(e.story_id) + const linked = byStory.get(id) ?? [] + linked.push({ id: String(e.event_id), title: String(e.title), projectId: e.project_id ? String(e.project_id) : null, + startedAt: String(e.started_at), endedAt: String(e.ended_at), primary: e.is_primary === 1, userSet: e.user_set === 1 }) + byStory.set(id, linked) + } return rows.map(row => { - const linked = events.all(String(row.id)).map(e => ({ id: String(e.event_id), title: String(e.title), projectId: e.project_id ? String(e.project_id) : null, - startedAt: String(e.started_at), endedAt: String(e.ended_at), primary: e.is_primary === 1, userSet: e.user_set === 1 })) + const linked = byStory.get(String(row.id)) ?? [] // A cross story's span comes from the events it links. const spanning = row.level === 'cross' ? linked : linked.filter(e => e.primary) return { id: String(row.id), projectId: row.project_id ? String(row.project_id) : null, projectName: row.project_name ? String(row.project_name) : null, diff --git a/src/main/assistant/supervision-ui-reads.test.ts b/src/main/assistant/supervision-ui-reads.test.ts new file mode 100644 index 00000000..20b97509 --- /dev/null +++ b/src/main/assistant/supervision-ui-reads.test.ts @@ -0,0 +1,232 @@ +// @vitest-environment node +import { randomUUID } from 'node:crypto' +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' +import { DatabaseSync } from 'node:sqlite' +import { build } from 'esbuild' +import { afterAll, afterEach, beforeAll, expect, it, vi } from 'vitest' +import { AssistantDatabase, ASSISTANT_DATABASE_SCHEMA_VERSION } from './assistant-database' +import { getPendingAssistantStorageUpgrade, upgradeAssistantStorage } from './assistant-storage-upgrade' +import { supervisionReviewSettingsSchema } from '../../shared/supervision-review-contracts' +import type { SupervisionSummaryOutput } from '../../shared/supervision-contracts' + +let directory: string, workerPath: string +const cleanups: Array<() => void> = [] +beforeAll(async () => { + directory = await mkdtemp(join(tmpdir(), 'supervision-ui-reads-')) + workerPath = join(directory, 'worker.cjs') + await build({ entryPoints: [resolve('src/main/readonly-query-worker.ts')], outfile: workerPath, + bundle: true, platform: 'node', format: 'cjs', logLevel: 'silent' }) +}, 60_000) +afterEach(() => { vi.restoreAllMocks(); for (const close of cleanups.splice(0)) close() }) +afterAll(async () => { await rm(directory, { recursive: true, force: true, maxRetries: 10 }) }) + +const at = '2026-10-01T12:00:00.000Z' +const request = { trigger: 'manual' as const, scope: { kind: 'global' as const }, timeRange: { from: at, to: at } } +const empty: SupervisionSummaryOutput = { summary: 'Review', changeDigest: '', openItems: [], events: [], entities: [], entityChanges: [], relations: [] } + +function fixture() { + const path = join(directory, `${randomUUID()}.sqlite`) + const db = new AssistantDatabase(path) + db.initialize(directory) + const sql = (db as unknown as { database: DatabaseSync }).database + cleanups.push(() => db.close()) + const project = db.listProjects()[0]! + const other = db.createProject({ name: 'Other', description: '', rootPath: directory }) + db.saveSupervisionResult({ request, + evidence: Array.from({ length: 6 }, (_, i) => ({ id: `s${i}`, sourceType: 'conversation' as const, sourceId: 'conversation', + title: `Source ${i}`, content: `Full source ${i} ${'body'.repeat(2000)}`, occurredAt: at, + locator: { projectId: i < 3 ? project.id : other.id } })), + output: { ...empty, events: Array.from({ length: 6 }, (_, i) => ({ title: `Event ${i}`, description: 'Description', occurredAt: at, + eventType: 'decision' as const, sourceReferenceIds: [`s${i}`], entityIds: [] })) } }) + const result = db.listSupervisionResults()[0]! + const events = sql.prepare('SELECT id, project_id FROM supervision_events ORDER BY title').all() + for (const [i, event] of events.entries()) { + sql.prepare(`INSERT INTO supervision_stories (id, project_id, level, name, created_at, updated_at) + VALUES (?, ?, 'feature', ?, ?, ?)`).run(`story${i}`, String(event.project_id), `Story ${i}`, at, at) + sql.prepare('INSERT INTO supervision_event_stories (event_id, story_id, is_primary) VALUES (?, ?, 1)').run(String(event.id), `story${i}`) + sql.prepare('INSERT INTO supervision_experiences (id, statement, created_at, updated_at) VALUES (?, ?, ?, ?)').run(`experience${i}`, `Experience ${i}`, at, at) + sql.prepare("INSERT INTO supervision_experience_events (experience_id, event_id, role) VALUES (?, ?, 'formed')").run(`experience${i}`, String(event.id)) + } + // A cross-project experience must retain all links, not only the selected project's links. + sql.prepare("INSERT INTO supervision_experience_events (experience_id, event_id, role) VALUES ('experience0', ?, 'applied')").run(String(events[3]!.id)) + return { db, sql, path, project, other, result, events } +} + +it('returns source headers only in both graph paths while retaining full source detail and source order', () => { + const f = fixture() + const expected = f.sql.prepare('SELECT id, title, occurred_at FROM supervision_sources ORDER BY rowid').all() + for (const input of [{ resultId: f.result.id }, { storyLineId: f.result.storyLineId! }]) { + expect(f.db.getSupervisionGraph(input).sources).toEqual(expected) + } + for (const source of expected) expect(f.db.getSupervisionSource(String(source.id))?.content).toBe(`Full source ${String(source.title).slice(-1)} ${'body'.repeat(2000)}`) +}) + +it('reads story memberships and scoped experiences in a constant number of SQL statements', () => { + const f = fixture() + let queries = 0 + const prepare = f.sql.prepare.bind(f.sql) + vi.spyOn(f.sql, 'prepare').mockImplementation(sql => { + const statement = prepare(sql) + const all = statement.all.bind(statement) + vi.spyOn(statement, 'all').mockImplementation((...args) => { queries++; return all(...args) }) + return statement + }) + const stories = f.db.supervisionStories().list({ kind: 'global' }) + expect(stories.map(story => [story.name, story.events.map(event => event.title)])).toEqual( + Array.from({ length: 6 }, (_, i) => [`Story ${i}`, [`Event ${i}`]])) + expect(queries).toBe(2) + queries = 0 + const experiences = f.db.supervisionExperiences().list([f.other.id]) + expect(experiences.map(item => item.statement)).toEqual(['Experience 0', 'Experience 3', 'Experience 4', 'Experience 5']) + expect(experiences[0]!.events.map(event => [event.title, event.role, event.storyName])).toEqual( + [0, 3].sort((a, b) => String(f.events[a]!.id).localeCompare(String(f.events[b]!.id))).map(i => [`Event ${i}`, i ? 'applied' : 'formed', `Story ${i}`])) + expect(queries).toBe(2) + queries = 0 + expect(f.db.supervisionExperiences().list(['unrelated'])).toEqual([]) + expect(queries).toBe(1) +}) + +it('serves UI reads on the real worker and observes writes committed before the next read', async () => { + const f = fixture() + const overview = f.db.listSupervisionResults() + const graph = f.db.getSupervisionGraph({ resultId: f.result.id }) + const stories = { stories: f.db.supervisionStories().list(request.scope), experiences: f.db.supervisionExperiences().list(), + unassigned: f.db.supervisionStories().unassignedCount(request.scope), canUndo: false } + f.db.enableReadonlyWorker(workerPath) + const syncOverview = vi.spyOn(f.db, 'listSupervisionResults').mockImplementation(() => { throw new Error('Main overview') }) + const syncGraph = vi.spyOn(f.db, 'getSupervisionGraph').mockImplementation(() => { throw new Error('Main graph') }) + const syncStories = vi.spyOn(f.db, 'supervisionStories').mockImplementation(() => { throw new Error('Main stories') }) + expect(await f.db.listSupervisionResultsAsync()).toEqual(overview) + expect(await f.db.getSupervisionGraphAsync({ resultId: f.result.id })).toEqual(graph) + expect(await f.db.getSupervisionStoriesAsync(request.scope)).toEqual(stories) + f.sql.prepare('UPDATE supervision_results SET summary = ? WHERE id = ?').run('Committed', f.result.id) + expect((await f.db.listSupervisionResultsAsync())[0]!.summary).toBe('Committed') + await expect(f.db.getSupervisionGraphAsync({ resultId: f.result.id, storyLineId: 'wrong' })).rejects.toThrow('不匹配') + await f.db.readonlyWorkerForTest!.terminateWorkerForTest() + await expect(f.db.listSupervisionResultsAsync()).rejects.toThrow('backing off') + await expect(f.db.getSupervisionGraphAsync({ resultId: f.result.id })).rejects.toThrow('backing off') + await expect(f.db.getSupervisionStoriesAsync(request.scope)).rejects.toThrow('backing off') + expect(syncOverview).not.toHaveBeenCalled() + expect(syncGraph).not.toHaveBeenCalled() + expect(syncStories).not.toHaveBeenCalled() +}) + +it('rejects missing production worker configuration instead of performing a synchronous UI read', async () => { + const f = fixture() + await expect(f.db.listSupervisionResultsAsync()).rejects.toThrow('worker is not configured') + await expect(f.db.getSupervisionGraphAsync({ resultId: f.result.id })).rejects.toThrow('worker is not configured') + await expect(f.db.getSupervisionStoriesAsync(request.scope)).rejects.toThrow('worker is not configured') + f.db.enableReadonlyWorker(join(directory, 'missing.cjs')) + await expect(f.db.listSupervisionResultsAsync()).rejects.toThrow() +}) + +it('keeps a multi-statement UI read on one WAL snapshot while another connection commits', () => { + const f = fixture() + const reader = new AssistantDatabase(f.path) + reader.openReadOnly() + cleanups.push(() => reader.close()) + reader.readSnapshot(() => { + expect(reader.listSupervisionResults()[0]!.summary).toBe('Review') + f.sql.prepare('UPDATE supervision_results SET summary = ? WHERE id = ?').run('New summary', f.result.id) + f.sql.prepare("UPDATE supervision_stories SET name = 'Renamed' WHERE id = 'story0'").run() + expect(reader.listSupervisionResults()[0]!.summary).toBe('Review') + expect(reader.getSupervisionStories(request.scope).stories[0]!.name).toBe('Story 0') + }) + expect(reader.listSupervisionResults()[0]!.summary).toBe('New summary') + expect(reader.getSupervisionStories(request.scope).stories.some(story => story.name === 'Renamed')).toBe(true) +}) + +it('upgrades schema 61 indexes without changing rows, rowids, source order or reclaiming free pages', () => { + const f = fixture() + const tables = f.sql.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%' ORDER BY name").all() + const contents = () => tables.map(row => [row.name, f.sql.prepare(`SELECT rowid, * FROM "${row.name}" ORDER BY rowid`).all()]) + const before = contents() + const sources = f.db.getSupervisionGraph({ resultId: f.result.id }).sources + f.sql.exec(`DROP INDEX IF EXISTS supervision_sources_result; + DROP INDEX IF EXISTS supervision_results_created; + CREATE TABLE disposable (body BLOB); INSERT INTO disposable VALUES (zeroblob(1048576)); DROP TABLE disposable; + PRAGMA user_version = 61`) + expect(f.sql.prepare('PRAGMA freelist_count').get()!.freelist_count).toBeGreaterThan(0) + f.db.close() + expect(getPendingAssistantStorageUpgrade(f.path)).toEqual({ migrateNotes: false, reclaimSpace: false }) + const progress = vi.fn() + upgradeAssistantStorage(f.path, progress) + const reopened = new AssistantDatabase(f.path) + reopened.openReadOnly() + cleanups.push(() => reopened.close()) + const sql = (reopened as unknown as { database: DatabaseSync }).database + expect(tables.map(row => [row.name, sql.prepare(`SELECT rowid, * FROM "${row.name}" ORDER BY rowid`).all()])).toEqual(before) + expect(sql.prepare('PRAGMA user_version').get()!.user_version).toBe(ASSISTANT_DATABASE_SCHEMA_VERSION) + expect(sql.prepare("SELECT name FROM sqlite_master WHERE name IN ('supervision_sources_result', 'supervision_results_created') ORDER BY name").all()) + .toEqual([{ name: 'supervision_results_created' }, { name: 'supervision_sources_result' }]) + expect(reopened.getSupervisionGraph({ resultId: f.result.id }).sources).toEqual(sources) + expect(sql.prepare('PRAGMA integrity_check').get()).toEqual({ integrity_check: 'ok' }) + expect(sql.prepare('PRAGMA foreign_key_check').all()).toEqual([]) + expect(progress.mock.calls.some(([value]) => value.stage === 'compacting' || value.stage === 'converting')).toBe(false) + expect(getPendingAssistantStorageUpgrade(f.path)).toBeUndefined() + upgradeAssistantStorage(f.path, progress) +}) + +it('publishes one final snapshot for multiple leaves and rolls back results and checkpoints together', () => { + const f = fixture() + const runId = f.db.startSupervisionRun(request) + const store = f.db.supervisionReviewStore() + f.db.saveLocalConversations([{ header: { id: 'conversation', projectId: f.project.id, title: 'Synthetic', updatedAt: Date.parse(at) }, + messages: [0, 1].map(i => ({ id: `source${i}`, role: 'user', state: 'complete', content: 'body', createdAt: Date.parse(at) })) }]) + store.initialize(runId, { request, config: { ...supervisionReviewSettingsSchema.parse({}), version: 1, concurrency: 1, timeoutSeconds: 30 }, phase: 'saving' }) + for (let i = 0; i < 2; i++) { + const source = f.sql.prepare('SELECT source, revision FROM supervision_review_sources WHERE run_id = ? AND source = ?').get(runId, `message:source${i}`)! + store.save(runId, f.project.id, 'conversation', [{ id: 'source', sourceType: 'conversation', sourceId: 'conversation', title: `Leaf ${i}`, content: 'body', occurredAt: at, + locator: { source: String(source.source), revision: String(source.revision), start: 0, end: 4, projectId: f.project.id } }], + { ...empty, entities: [{ id: 'entity', label: `Entity ${i}`, description: 'Original', sourceReferenceIds: ['source'] }] }) + } + f.sql.exec(`CREATE TRIGGER snapshot_once BEFORE UPDATE OF graph_snapshot_json ON supervision_results + WHEN OLD.graph_snapshot_json != '{"entities":[],"relations":[]}' BEGIN SELECT RAISE(ABORT, 'Repeated graph snapshot'); END; + CREATE TRIGGER fail_checkpoints BEFORE INSERT ON review_checkpoints BEGIN SELECT RAISE(ABORT, 'Checkpoint failed'); END`) + const publication = { request, runId, coverage: store.progress(runId), evidence: [], output: empty } + expect(() => f.db.saveSupervisionResult(publication)).toThrow('Checkpoint failed') + expect(f.db.listSupervisionResults()).toHaveLength(1) + expect(f.sql.prepare('SELECT status FROM supervision_runs WHERE id = ?').get(runId)!.status).toBe('running') + expect(f.sql.prepare('SELECT * FROM review_checkpoints').all()).toEqual([]) + f.sql.exec('DROP TRIGGER fail_checkpoints') + f.db.saveSupervisionResult(publication) + const latest = f.db.listSupervisionResults()[0]! + const graph = f.db.getSupervisionGraph({ resultId: latest.id }) + expect(graph.entities).toHaveLength(2) + expect(graph.sources).toHaveLength(2) + expect(f.sql.prepare('SELECT COUNT(*) AS n FROM review_checkpoints').get()!.n).toBe(2) + expect(f.sql.prepare('PRAGMA foreign_key_check').all()).toEqual([]) +}) + +it('retains first-leaf snapshot values and source references when persisted identities recur across leaves', () => { + const f = fixture() + f.db.saveSupervisionResult({ request, evidence: [], output: { ...empty, + entities: ['A', 'B'].map(id => ({ id, label: id, description: 'Original', sourceReferenceIds: [] })), + relations: [{ fromEntityId: 'A', toEntityId: 'B', relationType: 'related', reason: 'Original', sourceReferenceIds: [] }] } }) + const candidates = f.db.listSupervisionCandidates(request) + const a = candidates.find(item => item.label === 'A')!, b = candidates.find(item => item.label === 'B')! + f.db.applySupervisionEntityAction({ entityId: a.id, action: 'confirm' }) + const runId = f.db.startSupervisionRun(request) + const store = f.db.supervisionReviewStore() + f.db.saveLocalConversations([{ header: { id: 'conversation', projectId: f.project.id, title: 'Synthetic', updatedAt: Date.parse(at) }, + messages: [0, 1].map(i => ({ id: `source${i}`, role: 'user', state: 'complete', content: 'body', createdAt: Date.parse(at) })) }]) + store.initialize(runId, { request, config: { ...supervisionReviewSettingsSchema.parse({}), version: 1, concurrency: 1, timeoutSeconds: 30 }, phase: 'saving' }) + for (let i = 0; i < 2; i++) { + const source = f.sql.prepare('SELECT source, revision FROM supervision_review_sources WHERE run_id = ? AND source = ?').get(runId, `message:source${i}`)! + store.save(runId, f.project.id, 'conversation', [{ id: 'source', sourceType: 'conversation', sourceId: 'conversation', title: `Leaf ${i}`, content: 'body', occurredAt: at, + locator: { source: String(source.source), revision: String(source.revision), start: 0, end: 4, projectId: f.project.id } }], { ...empty, + entities: [a, b].map(item => ({ id: item.label, persistedId: item.id, label: item.label, description: `Leaf ${i}`, sourceReferenceIds: ['source'] })), + relations: [{ fromEntityId: 'A', toEntityId: 'B', relationType: 'related', reason: `Leaf ${i}`, sourceReferenceIds: ['source'] }] }) + } + f.db.saveSupervisionResult({ request, runId, candidates, coverage: store.progress(runId), evidence: [], output: empty }) + const result = f.db.listSupervisionResults()[0]! + const graph = f.db.getSupervisionGraph({ resultId: result.id }) + const sourceId = (graph.sources as Array<{ id: string; title: string }>).find(source => source.title === 'Leaf 0')!.id + expect(graph.entities).toEqual([a, b].sort((x, y) => x.id < y.id ? -1 : 1).map(item => expect.objectContaining({ id: item.id, + description: item.id === a.id ? 'Original' : 'Leaf 0', confirmation_state: item.id === a.id ? 'confirmed' : 'automatic', + source_reference_ids_json: JSON.stringify([sourceId]) }))) + expect(graph.relations).toEqual([expect.objectContaining({ reason: 'Leaf 0', source_reference_ids_json: JSON.stringify([sourceId]) })]) + expect(f.sql.prepare('SELECT description FROM supervision_entities WHERE id = ?').get(b.id)!.description).toBe('Leaf 1') +}) diff --git a/src/main/channels/channel-manager.test.ts b/src/main/channels/channel-manager.test.ts index 60199193..6324249d 100644 --- a/src/main/channels/channel-manager.test.ts +++ b/src/main/channels/channel-manager.test.ts @@ -203,6 +203,12 @@ describe('ChannelManager', () => { }) status({ state: 'running' }) deliveryFailure(new Error('send failed')) + expect((await manager.snapshot()).telegram.status).toEqual({ state: 'running', lastError: 'Telegram sending failed: send failed' }) + status({ state: 'running' }) + expect((await manager.snapshot()).telegram.status.lastError).toBe('Telegram sending failed: send failed') + deliveryFailure(new Error('status-test-secret token=synthetic-secret failed')) + expect(JSON.stringify((await manager.snapshot()).telegram.status)).not.toMatch(/status-test-secret|synthetic-secret/) + deliverySuccess() expect((await manager.snapshot()).telegram.status).toEqual({ state: 'running' }) await manager.stopAll() }) diff --git a/src/main/channels/channel-manager.ts b/src/main/channels/channel-manager.ts index f2de15e9..71e1ee07 100644 --- a/src/main/channels/channel-manager.ts +++ b/src/main/channels/channel-manager.ts @@ -488,12 +488,16 @@ export class ChannelManager { settings: ResolvedChannelSettings, generation?: symbol ): Promise<{ service: ManagedChannelService; driver: TestableChannelDriver }> { + let connectionStatus: ChannelRuntimeStatus = { state: 'starting' } + let deliveryError: string | undefined const onStatus = (status: ChannelRuntimeStatus): void => { if (generation === undefined || this.serviceGenerations.get(settings.channel) !== generation) return + connectionStatus = status + const lastError = status.lastError ?? deliveryError this.setStatus(settings.channel, { state: status.state, - ...(status.lastError ? { - lastError: redactManagerError(status.lastError, [ + ...(lastError ? { + lastError: redactManagerError(lastError, [ settings.channel === 'weixin' ? settings.token : settings.secret ]) } : {}) @@ -506,7 +510,11 @@ export class ChannelManager { dedupStore: this.dedupStore, outbox: this.outbox, onDeliveryFailure: (error) => { - if (settings.channel === 'telegram') return + if (settings.channel === 'telegram') { + deliveryError = `Telegram sending failed: ${redactManagerError(error, [settings.secret])}` + onStatus(connectionStatus) + return + } onStatus({ state: 'error', lastError: redactManagerError(error, [ @@ -517,7 +525,11 @@ export class ChannelManager { }) }, onDeliverySuccess: () => { - if (settings.channel === 'telegram') return + if (settings.channel === 'telegram') { + deliveryError = undefined + onStatus(connectionStatus) + return + } onStatus({ state: 'running' }) } }) diff --git a/src/main/channels/telegram-channel-driver.test.ts b/src/main/channels/telegram-channel-driver.test.ts index dbba3289..03838ee5 100644 --- a/src/main/channels/telegram-channel-driver.test.ts +++ b/src/main/channels/telegram-channel-driver.test.ts @@ -318,6 +318,18 @@ describe('TelegramChannelDriver', () => { expect(onStatus.mock.calls.at(-1)?.[0]).toEqual({ state: 'stopped' }) }) + it('rechecks a transient webhook failure before polling after identifying the Bot', async () => { + const { driver, fetch, calls, onStatus } = setup() + fetch.mockResolvedValueOnce(ok({ id: 42, is_bot: true })) + .mockResolvedValueOnce(error(503)) + await driver.start(vi.fn()) + await vi.waitFor(() => expect(onStatus).toHaveBeenCalledWith(expect.objectContaining({ state: 'starting', lastError: expect.stringContaining('503') }))) + expect(calls.some(call => call.method === 'getUpdates')).toBe(false) + await vi.waitFor(() => expect(calls.some(call => call.method === 'getUpdates')).toBe(true), { timeout: 2500 }) + expect(fetch.mock.calls.map(([input]) => String(input).split('/').at(-1))) + .toEqual(['getMe', 'getWebhookInfo', 'getWebhookInfo', 'getUpdates']) + }) + it('caller cancellation releases an identity waiter without cancelling startup', async () => { const { driver, fetch, onStatus } = setup() let validationSignal: AbortSignal | undefined diff --git a/src/main/channels/telegram-channel-driver.ts b/src/main/channels/telegram-channel-driver.ts index a3f11e48..13718aa4 100644 --- a/src/main/channels/telegram-channel-driver.ts +++ b/src/main/channels/telegram-channel-driver.ts @@ -95,7 +95,11 @@ export class TelegramChannelDriver implements ChannelDriver { } testConnection(): Promise { - return this.track(this.validate(this.lifecycle.signal)) + return this.track((async () => { + const identity = await this.identify(this.lifecycle.signal) + await this.checkWebhook(this.lifecycle.signal) + return identity + })()) } updateAllowedSenderIds(ids: readonly string[]): void { @@ -184,23 +188,32 @@ export class TelegramChannelDriver implements ChannelDriver { } } - private async validate(signal: AbortSignal): Promise { + private async identify(signal: AbortSignal): Promise { const me = await this.request<{ id: number; is_bot: boolean; username?: string }>('getMe', {}, signal) if (!positiveId(me?.id) || me.is_bot !== true) throw new Error('Telegram returned an invalid Bot identity.') + return { botId: String(me.id), ...(me.username ? { botUsername: me.username } : {}) } + } + + private async checkWebhook(signal: AbortSignal): Promise { const webhook = await this.request<{ url: string }>('getWebhookInfo', {}, signal) if (typeof webhook?.url !== 'string') throw new Error('Telegram returned invalid webhook information.') if (webhook.url) throw new TelegramError(409) - return { botId: String(me.id), ...(me.username ? { botUsername: me.username } : {}) } } private async poll(handler: ChannelInboundHandler, signal: AbortSignal): Promise { let failures = 0 + let pollingReady = false while (!signal.aborted) { const started = Date.now() try { if (!this.identity) { - this.identity = await this.validate(signal) + // Outgoing replies need the Bot identity, not permission to long-poll. + this.identity = await this.identify(signal) checkSignal(signal) + } + if (!pollingReady) { + await this.checkWebhook(signal) + pollingReady = true for (const notify of this.readinessWaiters) notify() failures = 0 this.status({ state: 'running' }) @@ -317,8 +330,10 @@ export class TelegramChannelDriver implements ChannelDriver { } finally { clearTimeout(timer) } - if (failure.code !== 429 || !retryRateLimit || attempt >= 3) throw failure + if (failure.code !== 429 || !retryRateLimit) throw failure + // The outbox may retry as soon as this call rejects, including the final attempt. await wait(failure.retryAfter * 1000, signal) + if (attempt >= 3) throw failure } } diff --git a/src/main/channels/telegram-delivery.test.ts b/src/main/channels/telegram-delivery.test.ts new file mode 100644 index 00000000..5c1da2dc --- /dev/null +++ b/src/main/channels/telegram-delivery.test.ts @@ -0,0 +1,155 @@ +// @vitest-environment node +import { createServer } from 'node:http' +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { performance } from 'node:perf_hooks' +import { describe, expect, it, vi } from 'vitest' +import { AssistantDatabase } from '../assistant/assistant-database' +import { ChannelManager } from './channel-manager' +import { ChannelSettingsStore } from './channel-settings-store' +import { SqliteChannelOutbox } from './sqlite-channel-state' +import { TelegramChannelDriver } from './telegram-channel-driver' +import type { ChannelRuntimeStatusChange } from '../../shared/channel-settings-contracts' + +async function fixture() { + const root = await mkdtemp(join(tmpdir(), 'goodbuddy-telegram-delivery-')) + const database = new AssistantDatabase(':memory:') + database.initialize(root) + const outbox = new SqliteChannelOutbox(database) + const settings = new ChannelSettingsStore(join(root, 'channels.json'), { + isAvailable: () => true, encrypt: value => Buffer.from(value), decrypt: value => value.toString() + }, {}) + const secret = 'synthetic-telegram-delivery-token' + await settings.apply({ telegram: { enabled: true, secret: { action: 'replace', value: secret }, + allowedSenderIds: ['7'], allowGroupMessages: false } }) + const state = { webhook: '', pollingCode: 0, polls: 0, + updates: [] as object[], sends: [] as { text: string; time: number }[], + sendErrors: [] as { code: number; retryAfter?: number }[] } + const server = createServer(async (request, response) => { + let body = '' + for await (const chunk of request) body += String(chunk) + const method = request.url!.slice(1) + let result: unknown + let failure: { code: number; retryAfter?: number } | undefined + if (method === 'getMe') result = { id: 42, is_bot: true } + else if (method === 'getWebhookInfo') result = { url: state.webhook } + else if (method === 'getUpdates') { + state.polls++ + if (state.pollingCode) failure = { code: state.pollingCode } + else result = state.updates.splice(0) + } else if (method === 'sendMessage') { + state.sends.push({ text: JSON.parse(body).text, time: performance.now() }) + failure = state.sendErrors.shift() + result = { message_id: state.sends.length } + } else if (method === 'sendChatAction') result = true + else throw new Error('Unexpected fixture method') + response.setHeader('content-type', 'application/json') + response.statusCode = failure?.code ?? 200 + response.end(JSON.stringify(failure + ? { ok: false, error_code: failure.code, parameters: { retry_after: failure.retryAfter }, + description: `token=${secret} https://api.telegram.org/bot${secret}/sendMessage` } + : { ok: true, result })) + }) + await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)) + const address = server.address() as { port: number } + const execute = vi.fn(async () => ({ status: 'completed', output: 'synthetic result' })) + const changes: ChannelRuntimeStatusChange[] = [] + const manager = new ChannelManager(settings, execute, { + outbox, onStatusChanged: change => changes.push(change), + createDriver: (resolved, onStatus) => new TelegramChannelDriver({ + secret: resolved.channel === 'telegram' ? resolved.secret! : '', + allowedSenderIds: resolved.allowedSenderIds, onStatus, + fetch: (input, init) => fetch(`http://127.0.0.1:${address.port}/${String(input).split('/').at(-1)}`, init) + }) + }) + return { state, outbox, manager, execute, changes, secret, + enqueue: (eventId: string, bot = '42') => outbox.enqueue({ channel: 'telegram', eventId, + conversationId: `${bot}:7`, recipientId: '7', status: 'completed', output: eventId }), + inbound: (id: number) => state.updates.push({ update_id: id, message: { message_id: id, + date: 1234, from: { id: 7, is_bot: false }, chat: { id: 7, type: 'private' }, text: 'request' } }), + async close() { + await manager.stopAll() + server.closeAllConnections() + await new Promise(resolve => server.close(() => resolve())) + database.close() + await rm(root, { recursive: true, force: true }) + } + } +} + +describe('Telegram delivery through loopback HTTP and SQLite', () => { + it('delivers identity-matched pending replies during a startup webhook conflict without sending another Bot results', async () => { + const f = await fixture() + try { + f.state.webhook = 'https://example.invalid/webhook' + f.enqueue('same-bot') + f.enqueue('other-bot', '41') + await f.manager.initialize() + await vi.waitFor(async () => expect((await f.manager.snapshot()).telegram.status).toMatchObject({ + state: 'error', lastError: expect.stringContaining('polling conflict') + })) + await vi.waitFor(() => expect(f.outbox.listUndelivered().every(entry => entry.state === 'terminal')).toBe(true)) + expect(f.state.sends.map(send => send.text)).toEqual(['same-bot']) + expect(f.outbox.listUndelivered().map(entry => [entry.message.eventId, entry.state])).toEqual([['other-bot', 'terminal']]) + expect(f.state.polls).toBe(0) + f.state.webhook = '' + await f.manager.reload('telegram') + await vi.waitFor(async () => expect((await f.manager.snapshot()).telegram.status.state).toBe('running')) + expect(f.state.sends.map(send => send.text)).toEqual(['same-bot']) + expect(f.execute).not.toHaveBeenCalled() + } finally { await f.close() } + }) + + it('surfaces sanitized sending failures without changing polling state or clearing them on healthy polls', async () => { + const f = await fixture() + try { + f.state.sendErrors.push({ code: 403 }) + f.inbound(1) + await f.manager.initialize() + await vi.waitFor(() => expect(f.outbox.listUndelivered()[0]).toMatchObject({ state: 'terminal', attempts: 1 })) + await vi.waitFor(() => expect(f.state.polls).toBeGreaterThanOrEqual(2)) + expect((await f.manager.snapshot()).telegram.status).toEqual({ state: 'running', + lastError: expect.stringMatching(/sending failed.*forbidden/i) }) + expect(JSON.stringify(f.changes)).not.toContain(f.secret) + expect(JSON.stringify(f.changes)).not.toContain('api.telegram.org') + f.inbound(2) + await vi.waitFor(() => expect(f.state.sends).toHaveLength(2)) + await vi.waitFor(async () => expect((await f.manager.snapshot()).telegram.status).toEqual({ state: 'running' })) + expect(f.execute).toHaveBeenCalledTimes(2) + } finally { await f.close() } + }) + + it('honors the latest retry_after when an exhausted driver attempt passes to the outbox', async () => { + const f = await fixture() + try { + f.state.sendErrors.push({ code: 429, retryAfter: 1 }, { code: 429, retryAfter: 1 }, + { code: 429, retryAfter: 1 }, { code: 429, retryAfter: 3 }) + f.inbound(1) + await f.manager.initialize() + await vi.waitFor(() => expect(f.state.sends).toHaveLength(5), { timeout: 10_000 }) + expect(f.state.sends[4]!.time - f.state.sends[3]!.time).toBeGreaterThanOrEqual(2995) + await vi.waitFor(() => expect(f.outbox.listUndelivered()).toEqual([])) + expect(f.state.sends.every(send => send.text === 'synthetic result')).toBe(true) + expect(f.execute).toHaveBeenCalledOnce() + } finally { await f.close() } + }, 15_000) + + it('cancels the final rate-limit wait without consuming or duplicating the pending reply', async () => { + const f = await fixture() + try { + f.state.sendErrors.push({ code: 429, retryAfter: 1 }, { code: 429, retryAfter: 1 }, + { code: 429, retryAfter: 1 }, { code: 429, retryAfter: 86400 }) + f.enqueue('cancelled-wait') + await f.manager.initialize() + await vi.waitFor(() => expect(f.state.sends).toHaveLength(4), { timeout: 5000 }) + await f.manager.stopAll() + expect(f.outbox.listUndelivered()[0]).toMatchObject({ state: 'pending', attempts: 0 }) + expect(f.state.sends).toHaveLength(4) + await f.manager.initialize() + await vi.waitFor(() => expect(f.outbox.listUndelivered()).toEqual([])) + expect(f.state.sends).toHaveLength(5) + expect(f.execute).not.toHaveBeenCalled() + } finally { await f.close() } + }, 10_000) +}) diff --git a/src/main/ipc.test.ts b/src/main/ipc.test.ts index f3db9f9d..c2c301c9 100644 --- a/src/main/ipc.test.ts +++ b/src/main/ipc.test.ts @@ -13,6 +13,7 @@ import { } from 'node:fs/promises' import { tmpdir } from 'node:os' import { delimiter, dirname, join } from 'node:path' +import { execFileSync } from 'node:child_process' import { ipcChannels } from '../shared/ipc-channels' import type { AssistantProject, @@ -6456,6 +6457,50 @@ describe('registerIpcHandlers agent terminal state', () => { } finally { await harness.dispose(); database.close() } }) + it('routes Supervisor overview, graph and stories IPC through the real readonly worker without Main fallback', async () => { + const directory = await mkdtemp(join(tmpdir(), 'supervision-ipc-worker-')) + const worker = join(directory, 'worker.cjs') + execFileSync(process.execPath, ['-e', `require('esbuild').buildSync(${JSON.stringify({ + entryPoints: [join(process.cwd(), 'src/main/readonly-query-worker.ts')], outfile: worker, + bundle: true, platform: 'node', format: 'cjs', logLevel: 'silent' + })})`], { env: { ...process.env, ELECTRON_RUN_AS_NODE: '1' } }) + const database = new AssistantDatabase(join(directory, 'assistant.sqlite')) + database.initialize(directory) + const at = '2026-10-01T12:00:00.000Z' + database.saveSupervisionResult({ request: { trigger: 'manual', scope: { kind: 'global' }, timeRange: { from: at, to: at } }, + evidence: [{ id: 'source', sourceType: 'conversation', sourceId: 'conversation', title: 'Source', content: 'Full body', occurredAt: at }], + output: { summary: 'Review', changeDigest: '', openItems: [], events: [], entities: [], entityChanges: [], relations: [] } }) + const overview = database.listSupervisionResults() + const resultId = overview[0]!.id + const sourceId = overview[0]!.sourceId! + const harness = createHarness({}, undefined, undefined, false, undefined, undefined, undefined, + false, undefined, undefined, undefined, undefined, database) + try { + harness.getApplicationSettings.mockResolvedValue({ heartbeatEnabled: true }) + const event = trustedEvent(harness.webContents) + const requests = [[ipcChannels.supervisionOverview, {}], [ipcChannels.supervisionGraph, { resultId }], + [ipcChannels.supervisionStories, { scope: { kind: 'global' } }]] as const + for (const [channel, input] of requests) await expect(electronMocks.handlers.get(channel)!(event, input)).rejects.toThrow('worker is not configured') + database.enableReadonlyWorker(worker) + const reads = [vi.spyOn(database, 'listSupervisionResults'), vi.spyOn(database, 'getSupervisionGraph'), vi.spyOn(database, 'supervisionStories')] + await expect(electronMocks.handlers.get(ipcChannels.supervisionOverview)!(event, {})).resolves.toEqual(overview) + await expect(electronMocks.handlers.get(ipcChannels.supervisionGraph)!(event, { resultId })).resolves.toMatchObject({ + sources: [{ id: sourceId, title: 'Source', occurred_at: at }] }) + await expect(electronMocks.handlers.get(ipcChannels.supervisionStories)!(event, { scope: { kind: 'global' } })).resolves.toEqual({ + stories: [], experiences: [], unassigned: 0, canUndo: false }) + expect(electronMocks.handlers.get(ipcChannels.supervisionSource)!(event, { sourceId })).toMatchObject({ content: 'Full body' }) + await database.readonlyWorkerForTest!.terminateWorkerForTest() + for (const [channel, input] of requests) await expect(electronMocks.handlers.get(channel)!(event, input)).rejects.toThrow('backing off') + for (const read of reads) expect(read).not.toHaveBeenCalled() + harness.getApplicationSettings.mockResolvedValue({ heartbeatEnabled: false }) + await expect(electronMocks.handlers.get(ipcChannels.supervisionStories)!(event, { scope: { kind: 'global' } })).resolves.toEqual({ + stories: [], experiences: [], unassigned: 0, canUndo: false }) + } finally { + await harness.dispose(); database.close() + await rm(directory, { recursive: true, force: true, maxRetries: 10 }) + } + }, 60_000) + it('scopes supervision continuation context to source-linked graph evidence', async () => { const database = new AssistantDatabase(':memory:') database.initialize(process.cwd()) @@ -6650,12 +6695,12 @@ describe('registerIpcHandlers agent terminal state', () => { await expect(activity(trustedEvent(harness.webContents), { configId: 'unrelated-plan' })).resolves.toEqual([]) await expect(activity(trustedEvent(harness.webContents), { configId: '' })).rejects.toThrow() await expect(activity({ sender: {} }, {})).rejects.toThrow() - expect(electronMocks.handlers.get(ipcChannels.supervisionOverview)!(trustedEvent(harness.webContents), { resultId: overview[0]!.id })).toEqual(overview) + await expect(electronMocks.handlers.get(ipcChannels.supervisionOverview)!(trustedEvent(harness.webContents), { resultId: overview[0]!.id })).resolves.toEqual(overview) expect(database.getSupervisionSource(overview[0]!.sourceId)?.sourceId).toBe(conversationId) const graph = await electronMocks.handlers.get(ipcChannels.supervisionGraph)!(trustedEvent(harness.webContents), { resultId: overview[0]!.id, storyLineId: overview[0]!.storyLineId }) as { sources: unknown[] } expect(graph.sources).toHaveLength(evidence.length) expect(graph).toHaveProperty('attention', [{ start: from, turns: 2, characters: 14 }]) - expect(() => electronMocks.handlers.get(ipcChannels.supervisionGraph)!(trustedEvent(harness.webContents), { resultId: overview[0]!.id, storyLineId: 'wrong-story' })).toThrow('不匹配') + await expect(electronMocks.handlers.get(ipcChannels.supervisionGraph)!(trustedEvent(harness.webContents), { resultId: overview[0]!.id, storyLineId: 'wrong-story' })).rejects.toThrow('不匹配') expect(() => electronMocks.handlers.get(ipcChannels.supervisionContinueContext)!(trustedEvent(harness.webContents), { resultId: 'wrong-result', sourceId: overview[0]!.sourceId })).toThrow('不匹配') const legacy = database.buildHeartbeatInput({ scope, lookbackHours: 1 }, new Date(to)) expect(legacy.tasks.some(task => task.id === oldTask.id)).toBe(false) @@ -6678,14 +6723,19 @@ describe('registerIpcHandlers agent terminal state', () => { database.saveLocalConversations([{ header: { id: crypto.randomUUID(), projectId: database.listProjects()[0]!.id, title: 'Evidence', updatedAt: Date.now() }, messages: [{ id: crypto.randomUUID(), role: 'user', state: 'complete', content: 'Review this decision', createdAt: Date.now() }] }]) let first = true + let providerFailures = ending === 'provider' ? 2 : 0 const releaseConversation = vi.fn(async () => undefined) if (ending === 'cleanup') releaseConversation.mockRejectedValueOnce(new Error('Release failed')) const run = vi.fn(async function* (request: AgentExecutionRequest, signal: AbortSignal) { yield { type: 'text' as const, requestId: request.requestId, delta: JSON.stringify({ summary: 'Review', changeDigest: '', openItems: [], events: [], entities: [], entityChanges: [], relations: [] }) } + if (providerFailures > 0) { + providerFailures-- + first = false + throw new Error('Provider HTTP 429: retry later') + } if (first) { first = false if (ending === 'incomplete') return - if (ending === 'provider') throw new Error('Provider HTTP 429: retry later') if (ending !== 'cleanup') await new Promise(resolve => signal.addEventListener('abort', () => resolve(), { once: true })) if (ending === 'throw') throw new DOMException('This operation was aborted', 'AbortError') } @@ -6708,10 +6758,10 @@ describe('registerIpcHandlers agent terminal state', () => { await rejected expect(database.listSupervisionResults()).toEqual([]) expect(database.listSupervisionActivity()).toEqual([expect.objectContaining({ status: 'failed', error })]) - expect(releaseConversation).toHaveBeenCalledOnce() + expect(releaseConversation).toHaveBeenCalledTimes(ending === 'provider' ? 2 : 1) await invoke() expect(database.listSupervisionResults()).toHaveLength(1) - expect(releaseConversation).toHaveBeenCalledTimes(2) + expect(releaseConversation).toHaveBeenCalledTimes(ending === 'provider' ? 3 : 2) } finally { await harness.dispose(); database.close(); vi.useRealTimers() } }) diff --git a/src/main/ipc.ts b/src/main/ipc.ts index edfe42df..afa6e524 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -7071,7 +7071,7 @@ export function registerIpcHandlers( registerHandler(ipcChannels.supervisionOverview, (event, input: unknown) => { assertTrustedSender(event, window) const request = supervisionOverviewRequestSchema.parse(input ?? {}) - return assistantDatabase.listSupervisionResults(20, request.target, request.resultId) + return assistantDatabase.listSupervisionResultsAsync(20, request.target, request.resultId) }) registerHandler(ipcChannels.supervisionRun, async (event, input: unknown) => { assertTrustedSender(event, window) @@ -7083,7 +7083,7 @@ export function registerIpcHandlers( }) registerHandler(ipcChannels.supervisionGraph, (event, input: unknown) => { assertTrustedSender(event, window) - return assistantDatabase.getSupervisionGraph(supervisionGraphRequestSchema.parse(input ?? {})) + return assistantDatabase.getSupervisionGraphAsync(supervisionGraphRequestSchema.parse(input ?? {})) }) registerHandler(ipcChannels.supervisionSource, (event, input: unknown) => { assertTrustedSender(event, window) @@ -7216,8 +7216,7 @@ export function registerIpcHandlers( assertTrustedSender(event, window) const { scope } = supervisionStoryListSchema.parse(input) if ((await applicationSettingsStore?.get())?.heartbeatEnabled !== true) return { stories: [], experiences: [], unassigned: 0, canUndo: false } - const stories = assistantDatabase.supervisionStories() - return { stories: stories.list(scope), experiences: assistantDatabase.supervisionExperiences().list(scope.kind === 'projects' ? scope.projectIds : undefined), unassigned: stories.unassignedCount(scope), canUndo: stories.canUndo() } + return assistantDatabase.getSupervisionStoriesAsync(scope) }) registerHandler(ipcChannels.supervisionStoryAction, async (event, input: unknown) => { assertTrustedSender(event, window) diff --git a/src/main/readonly-query-worker.ts b/src/main/readonly-query-worker.ts index 2ca01b28..06f10e88 100644 --- a/src/main/readonly-query-worker.ts +++ b/src/main/readonly-query-worker.ts @@ -66,6 +66,10 @@ if (kind === 'checkpoint') { handlers = { readStoryGraph: ([name, input, projectId], signal) => database.readStoryGraph( name as Parameters[0], input, projectId as string | undefined, signal), + supervisionOverview: ([limit, target, resultId]) => database.readSnapshot(() => database.listSupervisionResults( + limit as number, target as Parameters[1], resultId as string | undefined)), + supervisionGraph: ([input]) => database.readSnapshot(() => database.getSupervisionGraph(input as Parameters[0])), + supervisionStories: ([scope]) => database.readSnapshot(() => database.getSupervisionStories(scope as ReviewState['request']['scope'])), searchConversations: ([query]) => database.searchConversations(query as string), // Several statements each: read them from one snapshot. listConversationSummaries: ([detailIds]) => database.readSnapshot(() => diff --git a/src/renderer/src/App.test.tsx b/src/renderer/src/App.test.tsx index b83aab02..80096c1b 100644 --- a/src/renderer/src/App.test.tsx +++ b/src/renderer/src/App.test.tsx @@ -14110,6 +14110,25 @@ describe("App", () => { expect(screen.queryByLabelText("切换助手工作栏")).toBeInTheDocument(); }); + it("does not reload artifacts on first or warm Supervisor entry", async () => { + render(); + await waitFor(() => { + expect(api.artifacts.list).toHaveBeenCalled(); + expect(api.heartbeats.history).toHaveBeenCalled(); + }); + const artifactReads = vi.mocked(api.artifacts.list).mock.calls.length; + const historyReads = vi.mocked(api.heartbeats.history).mock.calls.length; + fireEvent.click(await screen.findByRole("button", { name: "监督者" })); + await screen.findByRole("heading", { name: "监督者" }); + await waitFor(() => expect(api.heartbeats.history).toHaveBeenCalledTimes(historyReads + 1)); + expect(api.artifacts.list).toHaveBeenCalledTimes(artifactReads); + const navigation = within(screen.getByRole("navigation", { name: "主导航" })); + fireEvent.click(navigation.getByRole("button", { name: "对话" })); + fireEvent.click(navigation.getByRole("button", { name: "监督者" })); + await waitFor(() => expect(api.heartbeats.history).toHaveBeenCalledTimes(historyReads + 2)); + expect(api.artifacts.list).toHaveBeenCalledTimes(artifactReads); + }); + it("counts only pending supervisor suggestions in the navigation badge", async () => { vi.mocked(api.supervision.suggestions).mockResolvedValue([ { id: "00000000-0000-4000-8000-000000000901", resultId: null, heartbeatRunId: null, scope: { kind: "global" }, diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index d1c3c724..9fff568b 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -171,7 +171,6 @@ import { useTaskStatusRows, } from "./task-selectors"; import { - refreshArtifacts, refreshTasks, useArtifactHydration, useInitialArtifactSync, @@ -3878,8 +3877,8 @@ function App(): React.JSX.Element { }, [loadHeartbeats, projects.length]); const refreshHeartbeatCenter = useCallback(async (): Promise => { - await Promise.all([refreshArtifacts(taskStore), refreshHeartbeats()]); - }, [refreshHeartbeats, taskStore]); + await refreshHeartbeats(); + }, [refreshHeartbeats]); const retryHeartbeatLoad = useCallback(async (): Promise => { setHeartbeatLoading(true); diff --git a/src/renderer/src/HeartbeatCenter.test.tsx b/src/renderer/src/HeartbeatCenter.test.tsx index 11a7e71c..4d598431 100644 --- a/src/renderer/src/HeartbeatCenter.test.tsx +++ b/src/renderer/src/HeartbeatCenter.test.tsx @@ -152,7 +152,9 @@ function createProps( it('graph navigation reopens the graph tab on repeated requests and preserves normal tab changes', async () => { const graph = vi.fn(async () => ({ storyLine: null, events: [], entities: [], relations: [], sources: [], eventEntities: [], eventSources: [] })) - window.goodbuddy = { supervision: { execution: async () => ({ active: false }), overview: async () => [], graph } } as never + const result = { id: 'A', storyLineId: 'story-A', sourceId: null, summary: 'Saved review', changeDigest: '', + createdAt: '2026-09-22T00:00:00Z', scope: { kind: 'global' }, openItems: [], timeRange: { from: '2026-09-01T00:00:00Z', to: '2026-09-22T00:00:00Z' } } + window.goodbuddy = { supervision: { execution: async () => ({ active: false }), overview: async () => [result], graph } } as never const props = createProps({ graphNavigation: { resultId: 'A' } }) const view = renderComponent() const tab = screen.getByRole('tab', { name: '故事线图谱' }) @@ -236,7 +238,7 @@ describe('HeartbeatCenter', () => { expect(within(panel).getByRole('button', { name: t('center.actions.refresh') })).toBeVisible() } if (key === 'supervisor.graph') { - const graphNavigation = within(panel).getByRole('tablist', { name: t('supervisor.selection') }) + const graphNavigation = await within(panel).findByRole('tablist', { name: t('supervisor.selection') }) expect(screen.getAllByRole('tablist')).toEqual([navigation, graphNavigation]) expect(within(panel).getAllByRole('tablist')).toEqual([graphNavigation]) const graphTabs = ['event', 'entity', 'relation'].map((kind) => @@ -292,11 +294,14 @@ describe('HeartbeatCenter', () => { expect(await screen.findByText('Older review body')).toBeVisible() expect(screen.getByRole('tab', { name: '工作回顾' })).toHaveAttribute('aria-selected', 'true') expect(overview).toHaveBeenCalledWith({ resultId: 'old' }) - expect(graph).toHaveBeenLastCalledWith({ resultId: 'old', storyLineId: 'story' }) + expect(graph).not.toHaveBeenCalled() fireEvent.click(screen.getByRole('tab', { name: '活动记录' })) fireEvent.click(await screen.findByRole('button', { name: '在图谱中查看' })) expect(screen.getByRole('tab', { name: '故事线图谱' })).toHaveAttribute('aria-selected', 'true') - await waitFor(() => expect(graph).toHaveBeenCalledTimes(2)) + await waitFor(() => { + expect(graph).toHaveBeenCalledOnce() + expect(graph).toHaveBeenLastCalledWith({ resultId: 'old', storyLineId: 'story' }) + }) }) it.each(['zh-CN', 'en-US'])('edits the supervision timeout and concurrency and preserves failed edits (%s)', async (language) => { diff --git a/src/renderer/src/SupervisionStories.test.tsx b/src/renderer/src/SupervisionStories.test.tsx new file mode 100644 index 00000000..6d8f5d59 --- /dev/null +++ b/src/renderer/src/SupervisionStories.test.tsx @@ -0,0 +1,73 @@ +import { act, cleanup, renderHook, waitFor } from '@testing-library/react' +import { afterEach, expect, it, vi } from 'vitest' +import type { SupervisionStoryView } from '../../shared/supervision-story-contracts' +import { useSupervisionStories } from './SupervisionStories' + +const scopeA = { kind: 'projects' as const, projectIds: ['11111111-1111-4111-8111-111111111111'] } +const scopeB = { kind: 'projects' as const, projectIds: ['22222222-2222-4222-8222-222222222222'] } +const rename = { action: 'rename' as const, storyId: '11111111-1111-4111-8111-111111111111', name: 'Renamed story' } +const snapshot = (name: string): SupervisionStoryView => ({ stories: [{ id: rename.storyId, name, projectId: null, + projectName: null, parentId: null, level: 'feature', description: '', state: 'active', stateEventId: null, + userEdited: false, events: [], startedAt: null, endedAt: null }], experiences: [], unassigned: 0, canUndo: false }) + +afterEach(() => { cleanup(); vi.restoreAllMocks(); window.goodbuddy = {} as never }) + +it.each(['scope', 'refresh revision'] as const)('does not let a retained mutation reload invalidate the new %s read', async change => { + const initial = snapshot('Initial'), current = snapshot('Current'), stale = snapshot('Stale mutation reload') + let finishMutation!: () => void + let finishCurrent!: (value: SupervisionStoryView) => void + const storyAction = vi.fn(() => new Promise(resolve => { finishMutation = resolve })) + const stories = vi.fn().mockResolvedValueOnce(initial) + .mockImplementationOnce(() => new Promise(resolve => { finishCurrent = resolve })) + .mockResolvedValue(stale) + window.goodbuddy = { supervision: { stories, storyAction } } as never + const revision = {} + const view = renderHook(({ scope, revision }) => useSupervisionStories(scope, true, revision), + { initialProps: { scope: scopeA, revision } }) + await waitFor(() => expect(view.result.current.view).toBe(initial)) + let mutation!: Promise + act(() => { mutation = view.result.current.act(rename) }) + view.rerender({ scope: change === 'scope' ? scopeB : scopeA, revision: change === 'scope' ? revision : {} }) + await waitFor(() => expect(stories).toHaveBeenCalledTimes(2)) + // The old action finishes while the replacement read is still pending. + await act(async () => { finishMutation(); await mutation }) + await act(async () => finishCurrent(current)) + await waitFor(() => expect(view.result.current.view).toBe(current)) + expect(stories).toHaveBeenCalledTimes(2) + expect(view.result.current.error).toBeUndefined() + expect(view.result.current.pending).toBe(false) +}) + +it('rejects a retained reload before sending a request after same-scope refresh or unmount', async () => { + const current = snapshot('Current') + const stories = vi.fn().mockResolvedValue(current) + window.goodbuddy = { supervision: { stories } } as never + const view = renderHook(({ revision }) => useSupervisionStories(scopeA, true, revision), { initialProps: { revision: 1 } }) + await waitFor(() => expect(view.result.current.view).toBe(current)) + const obsoleteReload = view.result.current.reload + view.rerender({ revision: 2 }) + await waitFor(() => expect(stories).toHaveBeenCalledTimes(2)) + await act(async () => obsoleteReload()) + expect(stories).toHaveBeenCalledTimes(2) + const unmountedReload = view.result.current.reload + view.unmount() + await unmountedReload() + expect(stories).toHaveBeenCalledTimes(2) +}) + +it('still reloads an owned mutation and reuses its refreshed snapshot on tab return', async () => { + const initial = snapshot('Initial'), updated = snapshot('Renamed story') + const stories = vi.fn().mockResolvedValueOnce(initial).mockResolvedValue(updated) + const storyAction = vi.fn().mockResolvedValue(undefined) + window.goodbuddy = { supervision: { stories, storyAction } } as never + const view = renderHook(({ active }) => useSupervisionStories(scopeA, active, 1), { initialProps: { active: true } }) + await waitFor(() => expect(view.result.current.view).toBe(initial)) + await act(async () => { expect(await view.result.current.act(rename)).toBe(true) }) + expect(view.result.current.view).toBe(updated) + expect(stories).toHaveBeenCalledTimes(2) + expect(storyAction).toHaveBeenCalledWith(rename) + view.rerender({ active: false }) + view.rerender({ active: true }) + expect(view.result.current.view).toBe(updated) + expect(stories).toHaveBeenCalledTimes(2) +}) diff --git a/src/renderer/src/SupervisionStories.tsx b/src/renderer/src/SupervisionStories.tsx index 7eddf6c0..d6274fbc 100644 --- a/src/renderer/src/SupervisionStories.tsx +++ b/src/renderer/src/SupervisionStories.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useRef, useState } from 'react' +import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import type { SupervisionRunRequest } from '../../shared/supervision-contracts' import type { SupervisionExperience, SupervisionStory, SupervisionStoryAction, SupervisionStoryView } from '../../shared/supervision-story-contracts' @@ -12,20 +12,32 @@ export function useSupervisionStories(scope: SupervisionRunRequest['scope'] | un const [view, setView] = useState<{ key: string; data: SupervisionStoryView }>({ key: '', data: empty }) const [error, setError] = useState() const [pending, setPending] = useState(false) - const latestKey = useRef(key) - useEffect(() => { latestKey.current = key }, [key]) + const latest = useRef<{ key: string; revision: unknown } | undefined>(undefined) + const loaded = useRef<{ key: string; revision: unknown } | undefined>(undefined) + const generation = useRef(0) + useLayoutEffect(() => { + latest.current = { key, revision } + return () => { latest.current = undefined } + }, [key, revision]) const load = useCallback(async () => { - if (!api?.stories || !key) return + const owner = latest.current + // A completed mutation may still hold the previous scope/refresh callback. + if (!api?.stories || !key || !owner || owner.key !== key || owner.revision !== revision) return + const request = ++generation.current try { const data = await api.stories({ scope: JSON.parse(key) as SupervisionRunRequest['scope'] }) - if (latestKey.current === key) { setView({ key, data }); setError(undefined) } - } catch (reason) { if (latestKey.current === key) setError(reason instanceof Error ? reason.message : String(reason)) } - }, [api, key]) + if (latest.current === owner && request === generation.current) { + loaded.current = { key, revision } + setView({ key, data }); setError(undefined) + } + } catch (reason) { if (latest.current === owner && request === generation.current) setError(reason instanceof Error ? reason.message : String(reason)) } + }, [api, key, revision]) useEffect(() => { - if (!active) return + if (!active || (loaded.current?.key === key && loaded.current.revision === revision)) return const task = window.setTimeout(() => void load(), 0) - return () => window.clearTimeout(task) - }, [active, load, revision]) + const invalidate = () => { generation.current++ } + return () => { window.clearTimeout(task); invalidate() } + }, [active, key, load, revision]) const act = async (action: SupervisionStoryAction) => { if (!api?.storyAction || pending) return false setPending(true) diff --git a/src/renderer/src/SupervisionStoryDigest.tsx b/src/renderer/src/SupervisionStoryDigest.tsx index 78d8a755..cef5bd2f 100644 --- a/src/renderer/src/SupervisionStoryDigest.tsx +++ b/src/renderer/src/SupervisionStoryDigest.tsx @@ -1,7 +1,6 @@ import { useState } from 'react' import { useTranslation } from 'react-i18next' -import type { SupervisionStoryView } from '../../shared/supervision-story-contracts' -import { storyDigest, type StoryDigestEntry } from './supervision-story-digest' +import type { StoryDigest, StoryDigestEntry } from './supervision-story-digest' type Focus = { kind: 'story' | 'experience'; id: string } const shown = 6 @@ -11,15 +10,13 @@ const shown = 6 * started, finished or stayed quiet, and which experiences formed or were used. Reads stored * stories only; each item opens the story or experience in the graph. */ -export function SupervisionStoryDigest({ view, range, date, onOpen }: { - view: SupervisionStoryView - range: { from: string; to: string } +export function SupervisionStoryDigest({ digest, date, onOpen }: { + digest: StoryDigest date: (value: string) => string onOpen: (focus: Focus) => void }): React.JSX.Element { const { t } = useTranslation('heartbeat') const [expanded, setExpanded] = useState>({}) - const digest = storyDigest(view, range) const total = digest.advanced.length + digest.started.length + digest.concluded.length + digest.experiences.length const limit = (key: string, items: T[]) => expanded[key] ? items : items.slice(0, shown) const more = (key: string, count: number) => count > shown && !expanded[key] diff --git a/src/renderer/src/SupervisorSelectionList.tsx b/src/renderer/src/SupervisorSelectionList.tsx new file mode 100644 index 00000000..16ef98a8 --- /dev/null +++ b/src/renderer/src/SupervisorSelectionList.tsx @@ -0,0 +1,82 @@ +import { useLayoutEffect, useMemo, useRef, useState } from 'react' +import { flushSync } from 'react-dom' +import { useTranslation } from 'react-i18next' +import type { SupervisionGraphView } from '../../shared/supervision-contracts' +import { useListWindow } from './use-list-window' + +type Kind = 'event' | 'entity' | 'relation' + +export function SupervisorSelectionList({ kind, events, entities, relations, entityMap, selectedId, onSelect, date, shortDate, entityTone, id, labelledBy }: { + kind: Kind + events: SupervisionGraphView['events'] + entities: SupervisionGraphView['entities'] + relations: SupervisionGraphView['relations'] + entityMap: Map + selectedId?: string + onSelect: (selection: { kind: Kind; id: string }) => void + date: (value: string) => string + shortDate: (value: string) => string + entityTone: (id: string) => number + id: string + labelledBy: string +}) { + const { t } = useTranslation('heartbeat') + const ref = useRef(null) + const [keyboardId, setKeyboardId] = useState() + const records = kind === 'event' ? events : kind === 'entity' ? entities : relations + const ids = useMemo(() => records.map(record => record.id), [records]) + // Native Tab entry from either side must reach the logical endpoints, even when scrolled away. + const list = useListWindow({ ids, scope: kind, scrollRef: ref, keepIds: [ids[0], ids.at(-1), selectedId, keyboardId], enabled: true, estimatedRowHeight: kind === 'event' ? 64 : 44 }) + const { onScroll } = list + + useLayoutEffect(() => { + const panel = ref.current + if (!panel || !selectedId) return + const row = Array.from(panel.querySelectorAll('[data-list-window-row]')).find(item => item.dataset.listWindowRow === selectedId) + if (!row) return + const bounds = panel.getBoundingClientRect(), rect = row.getBoundingClientRect() + if (rect.top < bounds.top) panel.scrollTop += rect.top - bounds.top + else if (rect.bottom > bounds.bottom) panel.scrollTop += rect.bottom - bounds.bottom + onScroll() + }, [selectedId, onScroll]) + + return
{ + if (!['ArrowDown', 'ArrowUp', 'Home', 'End', 'Tab'].includes(event.key) || !ids.length) return + const row = (event.target as HTMLElement).closest('[data-list-window-row]') + const index = row ? ids.indexOf(row.dataset.listWindowRow!) : -1 + const next = event.key === 'Home' ? 0 : event.key === 'End' ? ids.length - 1 + : index + (event.key === 'ArrowUp' || (event.key === 'Tab' && event.shiftKey) ? -1 : 1) + if (event.key === 'Tab' && (index < 0 || next < 0 || next >= ids.length)) return + event.preventDefault() + event.stopPropagation() + const nextId = ids[Math.max(0, Math.min(ids.length - 1, next))]! + // Materialize the logical next row before native focus, including across spacers. + flushSync(() => setKeyboardId(nextId)) + const target = Array.from(ref.current?.querySelectorAll('[data-list-window-row]') ?? []).find(item => item.dataset.listWindowRow === nextId) + target?.querySelector('button')?.focus() + target?.scrollIntoView?.({ block: 'nearest' }) + list.onScroll() + }}> + {!ids.length &&

{t('supervisor.listEmpty')}

} + {list.segments.map(segment => { + if (segment.kind === 'spacer') return