diff --git a/AGENTS.md b/AGENTS.md index 6a5e38e7..9479dfe7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -294,7 +294,7 @@ Repository-wide release invariants: ## Validation -Run all validators after source changes: +Run all validators after source changes when function/fix done: ```text npm test diff --git a/docs/architecture/runtime-storage.md b/docs/architecture/runtime-storage.md new file mode 100644 index 00000000..cd1114c6 --- /dev/null +++ b/docs/architecture/runtime-storage.md @@ -0,0 +1,44 @@ +# Runtime Storage And Background Reads + +SQLite owns persisted task, event and conversation evidence. The desktop business +connection is long-lived. WAL permits readers alongside a writer; synchronous SQL +still blocks the thread that executes it. Memory caches are disposable derived data. + +Startup storage upgrades run in `assistant-storage-worker` before the business +connection opens. Runtime readers must not invoke initialization, migrations or +recovery. `SubagentProgressStorage` caches event encoding state, not task statistics. + +## Runtime Read Design + +Execution statistics use one lazily started worker with a read-only connection. +The worker performs the existing evidence query inside a short read transaction. +Main supplies the current reply lease IDs and receives only scalar summaries. +There is no worker pool or duplicate persisted summary. The worker is terminated +when its owning database closes; errors reject reads, without a synchronous Main +fallback. In-memory test databases use the synchronous query because independent +connections cannot access the same private in-memory database. + +Derived caches must specify scope, capacity, lifetime and invalidation. Statistics +keep at most eight scopes for 30 seconds. SQLite `data_version` detects other +connections' commits; `total_changes()` detects writes on the owning connection. +Reads inside an existing transaction clear and bypass the cache, so rolled-back +values cannot escape. Closing the connection clears its cache. Live leases bypass +the cache because elapsed time changes without a database write. + +Read transactions end before replies are posted. Long-lived read transactions +would prevent WAL checkpoints from reclaiming old pages. Each reader has its own +SQLite page cache; adding workers increases memory usage and requires evidence of +parallel query demand. New consumers should reuse the same lifecycle and read-only +rules, without introducing a general query protocol before a second use case exists. + +## Renderer Scheduling + +Hidden task panels use React `Activity` to preserve state while scheduling hidden +render work below visible updates. Panel factories run inside that boundary. +Terminal and browser panels retain their existing mounted effects and sessions. +Statistics refresh only when the task center and document are visible. Results +must remain scoped and late responses from a previous selection must be discarded. + +The [task statistics design](../features/task-and-job/technical-design.md) owns the +query contract and evidence rules. Tests and timings demonstrate only their fixture +conditions; they do not establish a latency guarantee for arbitrary retained history. diff --git a/docs/features/conversation-supervision/README.md b/docs/features/conversation-supervision/README.md index 8a395b79..b4b4b299 100644 --- a/docs/features/conversation-supervision/README.md +++ b/docs/features/conversation-supervision/README.md @@ -11,6 +11,7 @@ | [逻辑设计](./logic-design.md) | 整体架构图、图谱对象关系图、状态规则与需求映射 | | [UI 设计](./ui-design.md) | 应用导航、工作回顾、侧栏反馈及页面状态 | | [User Stories](./user-stories.md) | 监督者与故事线图谱的用户操作和验收场景 | +| [技术设计](./technical-design.md) | Main/Renderer 分层、数据模型、IPC、安全边界和实施顺序 | | [故事线图谱设计](../story-graph/prd.md) | 图谱对象、环绕时间轴与实体交互 | -统一应用的技术设计与实施进度尚未制定;逻辑待定项见[逻辑设计](./logic-design.md)。现有心跳行为见[智能心跳](../smart-heartbeat/README.md),原型范围见[Story Graph 演示](../story-graph/README.md)。 +统一应用尚未接入产品,实施状态仍以代码和后续进度文档为准。逻辑待定项见[逻辑设计](./logic-design.md),实施边界与阶段见[技术设计](./technical-design.md)。现有心跳行为见[智能心跳](../smart-heartbeat/README.md),原型范围见[Story Graph 演示](../story-graph/README.md)。 diff --git a/docs/features/conversation-supervision/technical-design.md b/docs/features/conversation-supervision/technical-design.md new file mode 100644 index 00000000..48fdaeda --- /dev/null +++ b/docs/features/conversation-supervision/technical-design.md @@ -0,0 +1,215 @@ +# 监督者技术设计 + +## 文档信息 + +| 项目 | 内容 | +| --- | --- | +| 状态 | 目标技术设计,尚未实现 | +| 日期 | 2026-09-20 | +| 产品设计 | [监督者应用产品设计](./supervisor-prd.md) | +| 行为规则 | [监督者逻辑设计](./logic-design.md) | +| 界面设计 | [监督者 UI 设计](./ui-design.md) | +| 实施进度 | 尚未建立,首个实现阶段完成后补充 | + +本文回答如何在现有 GoodBuddy 桌面端中实现监督者。它不改变产品范围,也不把模拟 Demo 当作生产数据模型。 + +## 1. 总体方案 + +监督者作为 Main 进程中的一个领域服务,复用现有智能心跳的计划、运行领取、租约、重试和有界模型调用。图谱是监督者整理结果的查询视图,知识库继续拥有资料正文、索引和检索。 + +```mermaid +flowchart LR + UI["Renderer\n监督者页面 / 图谱 / 侧栏"] + IPC["Preload + IPC\n类型化输入输出"] + SVC["SupervisorService\n范围、运行、结果、用户确认"] + HB["HeartbeatService\n计划、领取、租约、重试"] + EVIDENCE["EvidenceCollector\n按范围读取有界证据"] + MODEL["BoundedSummarizer\n只读 JSON 输出"] + STORE["AssistantDatabase\nSQLite 事务与查询"] + KB["KnowledgeService\n本地资料、引用、检索"] + CONV["会话 / 笔记 / 任务"] + + UI --> IPC --> SVC + HB --> SVC + SVC --> EVIDENCE + EVIDENCE --> CONV + EVIDENCE --> KB + SVC --> MODEL + SVC --> STORE + SVC --> UI + SVC -->|确认后补充本地条目| KB +``` + +职责边界如下: + +| 模块 | 负责 | 不负责 | +| --- | --- | --- | +| `HeartbeatService` | 计划、到期运行、租约、重试、已有报告兼容 | 图谱实体合并、用户确认、知识库写入编排 | +| `SupervisorService` | 触发统一、证据收集、整理结果、图谱查询和修订 | 自己实现调度器、复制来源正文 | +| `EvidenceCollector` | 按冻结范围读取会话、笔记、任务、知识库引用 | 越过范围读取全部数据、调用工具 | +| `SupervisorSummarizer` | 以有界输入调用文本模型并解析结构化输出 | 执行工具、直接修改知识库 | +| `AssistantDatabase` | 事务、外键、分页、状态和来源定位持久化 | 生成模型内容 | +| `KnowledgeService` | 资料和本地知识条目的维护、检索及引用 | 管理监督者事件和关系 | +| Renderer | 展示和发起用户操作 | 访问 SQLite、决定权限或拼接 SQL | + +## 2. 运行路径 + +手动回顾和心跳回顾必须经过同一个 `SupervisorService.run()`,只在触发来源上区分。这样可以保证两者使用相同的范围校验、输入预算、输出契约、结果状态和来源处理。 + +```mermaid +sequenceDiagram + participant T as 手动/智能心跳 + participant S as SupervisorService + participant D as AssistantDatabase + participant C as EvidenceCollector + participant M as 文本模型 + participant R as Renderer + + T->>S: run(scope, timeRange, trigger) + S->>D: 创建 run,冻结范围与时间 + S->>C: collect(frozenScope) + C-->>S: 有界证据 + source references + S->>M: 结构化整理请求,禁止工具 + M-->>S: validated result + S->>D: 事务写入 result、events、entities、relations、references + D-->>S: completed / no-change / partial + S-->>R: 状态和定位信息 +``` + +运行状态与内容确认状态分开保存: + +- 运行状态:`pending`、`claimed`、`completed`、`no_change`、`partial`、`failed`、`cancelled`。 +- 内容状态:来源事实、自动归纳、待核对、用户确认、用户修订、已移除关系。 +- 运行成功只表示模型结果已按契约保存,不表示所有实体和关系已经被用户确认。 +- 失败或取消保留上次成功结果;部分结果必须带覆盖范围和遗漏原因。 + +## 3. 数据模型 + +首版使用现有 `AssistantDatabase` 和同一 SQLite 数据库。新增表保持领域对象独立,但不复制会话、笔记或知识库正文。 + +```mermaid +erDiagram + supervision_runs ||--o{ supervision_results : produces + supervision_results ||--o{ story_events : contains + story_lines ||--o{ story_events : organizes + story_lines }o--o{ knowledge_entities : references + story_events }o--o{ knowledge_entities : changes + knowledge_entities ||--o{ knowledge_relations : source + knowledge_entities ||--o{ knowledge_relations : target + supervision_results ||--o{ source_references : cites + story_events ||--o{ source_references : cites + knowledge_entities ||--o{ source_references : cites + knowledge_relations ||--o{ source_references : cites +``` + +建议的最小持久化对象: + +| 对象 | 关键字段 | 说明 | +| --- | --- | --- | +| `supervision_runs` | `id`, `trigger`, `scope_json`, `time_range_json`, `status`, `error`, `started_at`, `completed_at` | 一次手动或心跳整理;保存冻结范围,不从当前配置回推历史 | +| `supervision_results` | `id`, `run_id`, `summary`, `change_digest`, `coverage_json` | 工作回顾、监督反馈和图谱共同读取的结果版本 | +| `story_lines` | `id`, `scope_json`, `title`, `created_at`, `updated_at` | 用户选择的工作故事线,不等同于 Project | +| `story_events` | `id`, `story_line_id`, `result_id`, `occurred_at`, `title`, `description`, `event_type`, `confidence` | 时间轴上的事件;同日聚合只在查询或 UI 层处理 | +| `knowledge_entities` | `id`, `canonical_label`, `description`, `state`, `confirmation_state`, `updated_at` | 可被多条故事线引用的持续认识,不与知识库文档一一对应 | +| `entity_changes` | `event_id`, `entity_id`, `change_type`, `description`, `confirmation_state` | 表达提出、补充、验证和修订,避免覆盖实体正文 | +| `knowledge_relations` | `id`, `from_entity_id`, `to_entity_id`, `relation_type`, `reason`, `confirmation_state` | 关系独立拥有状态和来源;移除关系不删除来源 | +| `source_references` | `id`, `owner_type`, `owner_id`, `source_type`, `source_id`, `locator_json`, `availability` | 只保存来源标识和有界定位,不复制正文 | + +外键和索引要求: + +- 所有监督者对象使用 UUID;实体、来源和关系通过稳定 ID 关联,不能只按名称合并。 +- `supervision_runs` 按 `created_at`、`scope` 查询;事件按 `story_line_id, occurred_at` 查询。 +- 关系对 `(from_entity_id, to_entity_id, relation_type)` 建唯一约束,允许同一实体对存在不同关系类型。 +- 删除故事线只删除其组织关系,不删除共享实体、来源或知识库资料;删除知识库资料后来源标为不可用。 +- 整理结果、实体变化、关系变化和来源引用在同一 SQLite 事务中提交,提交失败全部回滚。 +- 早期不建立完整内容快照或独立向量索引;历史能力只依赖已保存的整理结果和原来源可用性。 + +模型输出使用版本化共享契约,至少包含 `events`、`entities`、`entityChanges`、`relations`、`summary`、`openItems` 和每项的 `sourceReferenceIds`。Main 必须做数量、字符、枚举、ID 所属范围和引用存在性校验,未知字段拒绝或按契约版本处理,不能直接把模型 JSON 写入数据库。 + +## 4. Main、IPC 与 Renderer + +新增能力沿用现有边界:Renderer 只传业务输入,Main 重新校验项目、故事线、时间范围、来源和权限。 + +建议的 IPC 分组: + +| 通道 | 用途 | +| --- | --- | +| `supervision:overview` | 工作回顾、运行状态、未解决事项和最近结果 | +| `supervision:run` | 手动开始一次整理,返回运行 ID | +| `supervision:cancel` | 取消当前运行 | +| `supervision:graph` | 按故事线、时间区间和回放时刻分页读取图谱 | +| `supervision:object` | 读取事件、实体、关系及来源详情 | +| `supervision:confirm` | 确认、修订或移除自动关系和实体变化 | +| `supervision:continue-context` | 生成继续讨论前的上下文预览 | +| `supervision:knowledge-preview` | 预览新建或补充本地知识条目 | +| `supervision:knowledge-commit` | 用户确认后调用知识库服务写入 | + +IPC 返回图谱查询结果时使用分页和有界字段;详情中的来源正文复用现有会话、笔记和知识库读取路径。不要把完整图谱一次性发送给 Renderer,也不要把模型原始输出暴露为可执行内容。 + +侧栏反馈通过已有应用状态刷新机制读取结果。首版可以在手动运行完成后主动刷新,心跳完成后发布一个不含私人正文的变更通知,Renderer 再按权限读取摘要;不新增系统通知正文通道。 + +## 5. 安全与一致性 + +- 监督者模型调用固定为 `ask` 语义,输入来自 `EvidenceCollector` 的有界快照,工具授权始终拒绝。 +- `Global`、Project、多项目和固定 Conversation/Task/Experiment 目标在 Main 冻结;模型不能生成或改变范围。 +- 每个来源引用必须属于本次冻结范围,跨范围引用、伪造项目 ID 和不存在的来源 ID 直接使结果失败。 +- 心跳运行沿用现有 `heartbeat_runs` 的领取、租约、重试和取消机制;监督者运行 ID 与心跳运行 ID 建立一对一关联,不复制另一套调度状态。 +- 同一配置同时触发手动和定时运行时,沿用现有幂等键和运行领取规则;已完成结果不能重复写入。 +- 用户确认或修订后的实体、关系不被后续整理静默覆盖;新证据以补充或冲突待核对状态写入。 +- 外部知识库只读。图谱可以引用保存到本地的外部片段和定位信息,不调用远端写入、不承诺打开远端原文。 +- 来源正文仍由原应用拥有。监督者删除关系、故事线或运行记录时不得删除会话、笔记、任务或知识库资料。 + +## 6. 实施顺序 + +### 阶段 A:先把现有心跳变成统一运行入口 + +- 抽出 `HeartbeatSummarizer` 的有界输入和结构化输出校验,使 `SupervisorService` 可以复用。 +- 增加手动运行的统一服务入口,但先只生成现有回顾结果,不改变心跳页面行为。 +- 为运行状态、范围冻结、失败保留旧结果补充 Main 单元测试。 + +### 阶段 B:接入证据引用和阶段回顾 + +- 实现 `EvidenceCollector`,先接入会话、任务和已有心跳输入;再接入魔法笔记与本地知识库引用。 +- 建立 `supervision_runs`、`supervision_results` 和 `source_references`,让回顾结果可定位来源。 +- 在现有智能心跳页面增加监督者工作回顾入口;不先改应用名称和导航 ID。 + +### 阶段 C:实现最小故事线图谱 + +- 建立事件、实体、实体变化和关系表及查询服务。 +- 先完成平铺环绕时间轴、节点详情、来源展开、回放和列表模式;圆形视图继续作为后续布局。 +- 同一实体通过稳定 ID 连接多个事件,禁止按每次事件复制实体。 +- 用真实整理结果替换 Demo 的模拟数据,并保留 Demo 作为视觉回归 fixture。 + +### 阶段 D:接入用户确认和知识库双向定位 + +- 增加关系确认、修订、撤销和实体拆分的最小交互。 +- 从知识库资料定位到图谱,从图谱返回本地资料及引用位置。 +- 新建或补充本地条目必须经过预览和确认;外部知识库保持只读。 + +### 阶段 E:侧栏反馈与增量整理 + +- 将结果摘要投影到跟随/固定目标侧栏。 +- 心跳只负责触发,监督者根据新证据决定有无变化和是否反馈。 +- 在运行去重、用户修订保护和来源失效提示稳定后,再评估事件唤醒与自动阶段识别。 + +## 7. 验证策略 + +首版每层验证一条可追踪链路: + +1. `EvidenceCollector` 测试范围裁剪、字符预算、来源定位和不可用来源。 +2. `SupervisorService` 测试模型输出解析、引用完整性、重复运行、取消、失败和部分结果。 +3. `AssistantDatabase` 测试迁移、事务回滚、外键、分页、关系移除不删来源。 +4. IPC 测试不可信输入、越权 Project/Source ID、确认状态和知识库写入前置预览。 +5. Renderer 测试从反馈进入图谱、点击实体高亮多个时间点、回放不显示未来结果,以及窄屏列表访问。 +6. 使用一组固定脱敏 fixture 验证“监督反馈 → 实体 → 多个事件 → 来源 → 继续讨论”的完整路径。 + +首个生产验收不要求模型自动发现所有关系。必须先证明来源可追溯、失败可解释、用户修订不会丢失,以及图谱与工作回顾读取同一结果。 + +## 8. 暂不实现的内容 + +- 独立的图数据库、向量图谱或新的知识库正文存储。 +- 让监督者拥有通用 Agent 工具执行能力。 +- 自动把所有聊天消息变成事件或节点。 +- 无依据的实体自动合并、旧观点自动失效和静默覆盖用户确认。 +- 应用市场建设作为监督者开发前置依赖。 +- 在没有增量和去重证据前实现事件触发、自动阶段识别和全量后台重算。 diff --git a/docs/features/smart-heartbeat/README.md b/docs/features/smart-heartbeat/README.md index ee41a99e..6b39f60c 100644 --- a/docs/features/smart-heartbeat/README.md +++ b/docs/features/smart-heartbeat/README.md @@ -2,7 +2,7 @@ Smart Heartbeat 已提供按计划或手动执行的有界回顾,生成报告及后续建议。条件或事件唤醒仍需后续设计。 -2026-09-16 确定的目标定位:现有入口计划升级为“监督者”,智能心跳作为其定期唤醒机制,驱动检查与整理;应用内部提供工作回顾和故事线图谱。定义见[监督者应用产品设计](../conversation-supervision/supervisor-prd.md)。本轮仅更新文档,现有计划、范围与执行能力未改变,不能将 Agent 定位解释为已有自主工具执行能力。 +2026-09-16 确定的目标定位:现有入口计划升级为“监督者”,智能心跳作为其定期唤醒机制,驱动检查与整理;应用内部提供工作回顾和故事线图谱。定义见[监督者应用产品设计](../conversation-supervision/supervisor-prd.md),实施方案见[监督者技术设计](../conversation-supervision/technical-design.md)。本轮仅更新文档,现有计划、范围与执行能力未改变,不能将 Agent 定位解释为已有自主工具执行能力。 ## 文档导航 diff --git a/docs/features/task-and-job/README.md b/docs/features/task-and-job/README.md index 0123047d..703a0532 100644 --- a/docs/features/task-and-job/README.md +++ b/docs/features/task-and-job/README.md @@ -11,6 +11,8 @@ 5. [Job 与 Subjob PRD](./job-and-subjob-prd.md):Task 内部串行、并行和委派执行。 6. [技术设计](./technical-design.md):统计查询契约、请求证据与不完整数据边界,以及立即创建的事务和入队规则。 +统计读取与共享存储、worker 生命周期和 Renderer 调度的通用约束见[运行时存储架构](../../architecture/runtime-storage.md)。 + 正式功能范围固定为当前项目:顶部显示当前会话消息总数和 Agent 累计回复时间,下方列出本项目全部会话的任务。创建默认立即执行并关联当前会话,可选择本项目其他会话、新会话或定时周期;产品与验收规则见 Task Center PRD 和 Scheduled Task PRD。 ## 阅读顺序 diff --git a/docs/features/task-and-job/technical-design.md b/docs/features/task-and-job/technical-design.md index dc23d121..4e1140af 100644 --- a/docs/features/task-and-job/technical-design.md +++ b/docs/features/task-and-job/technical-design.md @@ -1,5 +1,25 @@ # Execution Statistics Query +## Runtime Read Path + +WAL、连接所有权、内存缓存和后台读取的通用规则见 +[运行时存储架构](../../architecture/runtime-storage.md)。 + +`tasks:execution-stats` 在 Main 校验来源、范围和当前回复租约,然后经 +`AssistantDatabase.getExecutionStatsAsync` 调用独立的 `execution-stats-worker`。 +worker 使用只读连接和短读事务执行下文查询,不运行数据库初始化或任务恢复。 +查询错误返回调用方,关闭数据库时终止 worker 并拒绝未完成请求。 + +worker 内按项目或会话范围缓存结果,最多 8 项、30 秒;每次读取先检查 SQLite 连接版本。 +活动回复绕过缓存,事务内读取清空并绕过缓存,关闭连接清空缓存。写入提交后的下一次读取 +会重新计算,覆盖本地消息、远程事件、任务删除和恢复。统计数据仍以查询快照时刻为准。 + +Renderer 保留可见时 5 秒刷新以及 revision、focus、visibility 触发刷新;失败的范围显示 +不可用,旧范围的迟到结果丢弃。重复缓存结果保留对象引用,更新通过 `startTransition` +提交,避免无变化的轮询反复更新 App 的任务时长映射。任务面板使用 React `Activity` +保留状态并降低隐藏时的渲染优先级;面板工厂在该边界内部调用。浏览器和终端沿用原有挂载 +与 effect 生命周期。 + ## Contract `window.goodbuddy.tasks.getExecutionStats(input)` invokes `tasks:execution-stats`. @@ -61,10 +81,10 @@ Message counts used to detect missing history stay in Main; no bodies are return `completed_at` as an execution endpoint. Missing or invalid timing also marks the request incomplete; unknown time is not replaced with zero-length certainty. - A non-aborted reply lease confirms current execution: an open local interval - with a reliable start contributes through `asOf` and is not marked incomplete - just because it is active. Manual context compression leases are excluded. + with a reliable start contributes through `asOf` and remains complete while active. + Manual context compression leases are excluded. Without a live lease, stop at the latest evidence and mark an open interval - incomplete. Renderer must requery rather than extrapolate beyond `asOf`. + incomplete. Renderer must query again after `asOf`; it does not extrapolate. - Remote-recoverable records, tasks in persisted SSH project execution spaces, and events with remote operation provenance are incomplete and contribute no duration: their receipt timestamps cannot establish @@ -100,7 +120,7 @@ terminal events. Only status payloads use JSON extraction. Request membership an remote provenance inspect scalar event columns; last-event and pre-interruption timestamps use `(task_id, id)` index seeks. This preserves a tool/delta event as the last reliable boundary without loading its body. Grouped assistant-message -counts still read task metadata from message rows rather than from every event. +counts read task metadata from message rows; event rows are not scanned for that count. It adds no tables, columns, migration, or persisted timing totals. Stable task lifecycle timestamps are never duration endpoints. diff --git a/electron.vite.config.ts b/electron.vite.config.ts index 6ed0e401..c717398e 100644 --- a/electron.vite.config.ts +++ b/electron.vite.config.ts @@ -113,6 +113,7 @@ export default defineConfig({ input: { index: resolve('src/main/index.ts'), 'assistant-storage-worker': resolve('src/main/assistant-storage-worker.ts'), + 'execution-stats-worker': resolve('src/main/execution-stats-worker.ts'), 'wechat-sidecar': resolve( 'src/main/channels/wechat-sidecar.ts' ), diff --git a/package-lock.json b/package-lock.json index 3f578b5b..3bf19c33 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "goodbuddy", - "version": "0.13.9", + "version": "0.13.10", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "goodbuddy", - "version": "0.13.9", + "version": "0.13.10", "license": "0BSD", "dependencies": { "@agentclientprotocol/sdk": "0.25.1", diff --git a/package.json b/package.json index ac77648a..9b3fc438 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "goodbuddy", - "version": "0.13.9", + "version": "0.13.10", "private": true, "description": "Secure desktop AI workspace with controlled Agent Runtimes", "desktopName": "GoodBuddy", diff --git a/resources/release-notes.json b/resources/release-notes.json index 8e73b315..eb48efad 100644 --- a/resources/release-notes.json +++ b/resources/release-notes.json @@ -1,6 +1,42 @@ { "formatVersion": 1, "releases": [ + { + "version": "0.13.10", + "releasedAt": "2026-09-21", + "notes": { + "zh-CN": { + "highlights": [], + "features": [ + "**任务统计使用后台只读读取**:执行统计通过独立只读 worker 查询 WAL 数据,避免较重的统计 SQL 占用 Electron 主进程;统计缓存有明确的容量、有效期和失效边界。", + "**任务中心切换更顺畅**:隐藏任务面板保留运行状态并降低后台渲染优先级,切换任务中心、工作区、浏览器和成果页时减少重复渲染。" + ], + "fixes": [ + "**修复 Agent 回复期间任务中心阶段性卡顿**:减少任务事件写入、统计刷新和 tab 切换渲染叠加造成的 UI 阻塞。", + "**完善运行时存储边界**:明确 WAL、主数据库连接、启动迁移 worker、统计 worker 和 Renderer 的职责,避免把启动迁移缓存误当作运行时统计缓存。", + "**删除对话确认更简洁**:移除重复的底部警告文案,保留明确的取消和永久删除操作。" + ], + "notices": [ + "本次仅更新 Desktop,Agent 保持 **0.13.2**,无需更新 Host 环境。" + ] + }, + "en-US": { + "highlights": [], + "features": [ + "**Run execution statistics through a background read-only path**: Execution statistics now query WAL data through a dedicated read-only worker, keeping heavier aggregation SQL off the Electron Main thread with explicit cache capacity, lifetime, and invalidation rules.", + "**Smoother Task Center switching**: Hidden task panels preserve their running state while receiving lower-priority background rendering, reducing repeated work when switching between Task Center, Workspace, Browser, and Results." + ], + "fixes": [ + "**Reduce intermittent Task Center stalls during Agent replies**: Reduced UI contention caused by task-event persistence, statistics refreshes, and tab-switch rendering occurring at the same time.", + "**Clarify runtime storage boundaries**: Documented the separate responsibilities of WAL, the business database connection, the startup migration worker, the statistics worker, and Renderer scheduling.", + "**Simplify conversation deletion confirmation**: Removed the duplicate bottom warning text while keeping clear Cancel and Permanently Delete actions." + ], + "notices": [ + "This release updates Desktop only. Agent remains at **0.13.2**; no Host update is required." + ] + } + } + }, { "version": "0.13.9", "releasedAt": "2026-09-21", diff --git a/src/main/assistant/assistant-database.ts b/src/main/assistant/assistant-database.ts index ed59c555..d380da8d 100644 --- a/src/main/assistant/assistant-database.ts +++ b/src/main/assistant/assistant-database.ts @@ -3,6 +3,7 @@ import { magicNoteCanvasAnalysisText } from '../../shared/magic-note-canvas-text import { knowledgeReferenceKey } from '../../shared/knowledge-reference' import { appendConversationQuestionBlock } from '../../shared/conversation-question-blocks' import { DatabaseSync } from 'node:sqlite' +import { ExecutionStatsReader } from './execution-stats-reader' import { statSync } from 'node:fs' import { MagicNoteStorage } from '../magic-notes/magic-note-storage' import type { AssistantStorageProgress } from '../../shared/assistant-storage-contracts' @@ -1786,13 +1787,20 @@ function deleteProjectRecords( } export class AssistantDatabase { + private static readonly executionStatsCacheTtlMs = 30_000 + private static readonly executionStatsCacheLimit = 8 private database?: DatabaseSync + private executionStatsReader?: ExecutionStatsReader private readonly noteStorage: MagicNoteStorage private readonly dirtyMagicNotes = new Set() private readonly pendingMagicNoteCleanup = new Set() private subagentProgress?: SubagentProgressStorage private channelEventWrites = 0 private channelOutboxWrites = 0 + private readonly executionStatsCache = new Map< + string, + { value: ExecutionStats; cachedAt: number; dataVersion: number; totalChanges: number } + >() constructor( private readonly databasePath: string, @@ -2196,6 +2204,9 @@ export class AssistantDatabase { } close(): void { + this.executionStatsReader?.close() + this.executionStatsReader = undefined + this.executionStatsCache.clear() this.database?.close() this.database = undefined this.subagentProgress = undefined @@ -4520,11 +4531,82 @@ export class AssistantDatabase { activeRequestIds: ReadonlySet = new Set() ): ExecutionStats { const scope = executionStatsInputSchema.parse(input) + const cacheKey = 'conversationId' in scope + ? `conversation:${scope.conversationId}` + : `project:${scope.projectId}` + const database = this.requireDatabase() + // Never retain values read from a transaction that its caller can roll back. + if (database.isTransaction || activeRequestIds.size > 0) { + this.executionStatsCache.clear() + return this.readExecutionStatsSnapshot(scope, activeRequestIds) + } + const dataVersion = Number((database.prepare('PRAGMA data_version').get() as { data_version: number }).data_version) + const totalChanges = Number((database.prepare('SELECT total_changes() AS total_changes').get() as { total_changes: number }).total_changes) + const cached = activeRequestIds.size === 0 + ? this.executionStatsCache.get(cacheKey) + : undefined + if (cached && cached.dataVersion === dataVersion && cached.totalChanges === totalChanges && + Date.now() - cached.cachedAt < AssistantDatabase.executionStatsCacheTtlMs) { + this.executionStatsCache.delete(cacheKey) + this.executionStatsCache.set(cacheKey, cached) + return structuredClone(cached.value) + } + const result = this.readExecutionStatsSnapshot(scope, activeRequestIds) + this.executionStatsCache.delete(cacheKey) + this.executionStatsCache.set(cacheKey, { + value: structuredClone(result), cachedAt: Date.now(), dataVersion, totalChanges + }) + while (this.executionStatsCache.size > AssistantDatabase.executionStatsCacheLimit) { + this.executionStatsCache.delete(this.executionStatsCache.keys().next().value!) + } + return result + } + + getExecutionStatsAsync( + input: ExecutionStatsInput, + activeRequestIds: ReadonlySet, + workerPath: string + ): Promise { + this.requireDatabase() + const scope = executionStatsInputSchema.parse(input) + if (this.databasePath === ':memory:') { + return Promise.resolve(this.getExecutionStats(scope, activeRequestIds)) + } + this.executionStatsReader ??= new ExecutionStatsReader(this.databasePath, workerPath) + return this.executionStatsReader.read(scope, activeRequestIds) + } + + openReadOnly(): void { + if (this.database) throw new Error('Database already open') + this.database = new DatabaseSync(this.databasePath, { readOnly: true, timeout: 5_000 }) + } + + private readExecutionStatsSnapshot( + scope: ExecutionStatsInput, + activeRequestIds: ReadonlySet + ): ExecutionStats { + const database = this.requireDatabase() + if (database.isTransaction) return this.computeExecutionStats(scope, activeRequestIds) + database.exec('BEGIN') + try { + const result = this.computeExecutionStats(scope, activeRequestIds) + database.exec('COMMIT') + return result + } catch (error) { + database.exec('ROLLBACK') + throw error + } + } + + private computeExecutionStats( + scope: ExecutionStatsInput, + activeRequestIds: ReadonlySet + ): ExecutionStats { + const database = this.requireDatabase() const result: ExecutionStats = { durationMs: 0, requestCount: 0, incompleteRequestCount: 0, activeRequestCount: 0, asOf: Date.now(), taskDurations: [] } - const database = this.requireDatabase() const scopeId = 'conversationId' in scope ? scope.conversationId : scope.projectId const scopeColumn = 'conversationId' in scope ? 'conversation_id' : 'project_id' const durations = new Map() diff --git a/src/main/assistant/assistant-execution-stats.test.ts b/src/main/assistant/assistant-execution-stats.test.ts index a6c26081..41031c53 100644 --- a/src/main/assistant/assistant-execution-stats.test.ts +++ b/src/main/assistant/assistant-execution-stats.test.ts @@ -67,6 +67,58 @@ describe('execution duration evidence', () => { expect(database.getExecutionStats({ conversationId: randomUUID() })).toMatchObject({ durationMs: 0, requestCount: 0 }) }) + it('invalidates cached scopes for local messages, remote events and external deletion', () => { + expect(database.getExecutionStats({ conversationId }).requestCount).toBe(0) + database.saveLocalConversations([{ + header: { id: conversationId, projectId, title: 'History', updatedAt: epoch }, + messages: [{ id: randomUUID(), role: 'assistant', content: 'Retained', state: 'complete', createdAt: epoch }] + }]) + expect(database.getExecutionStats({ conversationId }).requestCount).toBe(1) + const id = request() + database.getExecutionStats({ projectId }) + database.appendRemoteTaskEventOnce({ taskId: id, bindingId: randomUUID(), operationId: randomUUID(), semanticSequence: '1', eventIndex: 0, kind: 'done', payload: { requestId: id, type: 'done' } }) + expect(database.getExecutionStats({ projectId }).incompleteRequestCount).toBe(1) + const raw = new DatabaseSync(join(directory, 'assistant.sqlite')) + try { + raw.prepare('DELETE FROM messages WHERE conversation_id = ?').run(conversationId) + raw.prepare('DELETE FROM tasks WHERE id = ?').run(id) + expect(database.getExecutionStats({ conversationId }).requestCount).toBe(0) + expect(database.getExecutionStats({ projectId }).requestCount).toBe(0) + } finally { raw.close() } + }) + + it('does not cache transaction snapshots across rollback, or serve a closed connection', () => { + const connection = (database as unknown as { database: DatabaseSync }).database + const id = request() + expect(database.getExecutionStats({ conversationId }).requestCount).toBe(0) + connection.exec('BEGIN') + text(id) + expect(database.getExecutionStats({ conversationId }).requestCount).toBe(1) + connection.exec('ROLLBACK') + expect(database.getExecutionStats({ conversationId }).requestCount).toBe(0) + database.close() + expect(() => database.getExecutionStats({ conversationId })).toThrow() + database.initialize(directory) + expect(database.getExecutionStats({ conversationId }).requestCount).toBe(0) + }) + + it('bounds cache scopes, expires idle entries and isolates returned objects', () => { + const prepare = vi.spyOn(DatabaseSync.prototype, 'prepare') + const reads = (): number => prepare.mock.calls.filter(([sql]) => sql.includes('WITH scoped AS MATERIALIZED')).length + try { + const first = database.getExecutionStats({ projectId }) + first.requestCount = 999 + expect(database.getExecutionStats({ projectId }).requestCount).toBe(0) + expect(reads()).toBe(1) + for (let index = 0; index < 8; index++) database.getExecutionStats({ conversationId: randomUUID() }) + database.getExecutionStats({ projectId }) + expect(reads()).toBe(10) + at(31) + database.getExecutionStats({ projectId }) + expect(reads()).toBe(11) + } finally { prepare.mockRestore() } + }) + it('excludes stable schedules, nested experts, and status-only maintenance but includes scheduled reply requests', () => { const schedule = database.createSchedule({ projectId, title: 'Schedule', prompt: 'Reply', workMode: 'ask', recurrence: 'daily', nextRunAt: new Date(epoch).toISOString() }) database.updateTaskStatus(schedule.taskId, 'running') diff --git a/src/main/assistant/execution-stats-reader.test.ts b/src/main/assistant/execution-stats-reader.test.ts new file mode 100644 index 00000000..e4807e9f --- /dev/null +++ b/src/main/assistant/execution-stats-reader.test.ts @@ -0,0 +1,68 @@ +// @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, beforeAll, expect, it } from 'vitest' +import { AssistantDatabase } from './assistant-database' +import { ExecutionStatsReader } from './execution-stats-reader' + +let directory: string +let workerPath: string +beforeAll(async () => { + directory = await mkdtemp(join(tmpdir(), 'goodbuddy-statistics-reader-')) + workerPath = process.env.GOODBUDDY_STATS_WORKER_PATH ?? join(directory, 'worker.cjs') + if (!process.env.GOODBUDDY_STATS_WORKER_PATH) { + await build({ entryPoints: [resolve('src/main/execution-stats-worker.ts')], outfile: workerPath, + bundle: true, platform: 'node', format: 'cjs', logLevel: 'silent' }) + } +}) +afterAll(async () => { await rm(directory, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 }) }) + +it('reads committed WAL evidence off-thread without recovering live tasks and refreshes external writes', async () => { + const path = join(directory, 'assistant.sqlite') + const database = new AssistantDatabase(path) + database.initialize(directory) + const projectId = database.listProjects()[0]!.id + const conversationId = randomUUID() + const id = randomUUID() + database.createTask({ id, projectId, conversationId, title: 'Live', instructions: 'Reply', workMode: 'ask', visible: false }) + database.appendTaskEvent(id, 'text', { requestId: id, type: 'text', delta: 'Reply' }) + const sql = new DatabaseSync(path) + try { + const status = database.getTask(id).status + const [conversation, project] = await Promise.all([ + database.getExecutionStatsAsync({ conversationId }, new Set([id]), workerPath), + database.getExecutionStatsAsync({ projectId }, new Set([id]), workerPath) + ]) + expect(conversation).toMatchObject({ requestCount: 1, activeRequestCount: 1, incompleteRequestCount: 0 }) + expect(project.requestCount).toBe(1) + expect(database.getTask(id).status).toBe(status) + database.updateTaskStatus(id, 'completed') + expect(await database.getExecutionStatsAsync({ conversationId }, new Set(), workerPath)).toMatchObject({ requestCount: 1, activeRequestCount: 0 }) + sql.exec('BEGIN IMMEDIATE') + sql.prepare('DELETE FROM tasks WHERE id = ?').run(id) + expect((await database.getExecutionStatsAsync({ conversationId }, new Set(), workerPath)).requestCount).toBe(1) + sql.exec('ROLLBACK') + expect((await database.getExecutionStatsAsync({ conversationId }, new Set(), workerPath)).requestCount).toBe(1) + sql.prepare('DELETE FROM tasks WHERE id = ?').run(id) + expect((await database.getExecutionStatsAsync({ conversationId }, new Set(), workerPath)).requestCount).toBe(0) + // A returned result must not leave a read transaction pinning the WAL. + expect(sql.prepare('PRAGMA wal_checkpoint(TRUNCATE)').get()?.busy).toBe(0) + const pending = database.getExecutionStatsAsync({ projectId }, new Set(), workerPath) + const rejection = expect(pending).rejects.toThrow('closed') + database.close() + await rejection + } finally { sql.close(); database.close() } +}) + +it('rejects worker startup errors and permits the next read to retry', async () => { + const reader = new ExecutionStatsReader(join(directory, 'missing.sqlite'), workerPath) + try { + for (let attempt = 0; attempt < 2; attempt++) { + await expect(reader.read({ projectId: randomUUID() }, new Set())).rejects.toThrow() + } + } finally { reader.close() } +}) diff --git a/src/main/assistant/execution-stats-reader.ts b/src/main/assistant/execution-stats-reader.ts new file mode 100644 index 00000000..5bcdd9a0 --- /dev/null +++ b/src/main/assistant/execution-stats-reader.ts @@ -0,0 +1,52 @@ +import { Worker } from 'node:worker_threads' +import type { ExecutionStats, ExecutionStatsInput } from '../../shared/assistant-contracts' + +export class ExecutionStatsReader { + private worker?: Worker + private nextId = 0 + private readonly pending = new Map void + reject: (error: Error) => void + }>() + + constructor(private readonly databasePath: string, private readonly workerPath: string) {} + + read(scope: ExecutionStatsInput, activeRequestIds: ReadonlySet): Promise { + if (!this.worker) { + const worker = new Worker(this.workerPath, { workerData: { databasePath: this.databasePath } }) + this.worker = worker + worker.on('message', (message: { id: number; result?: ExecutionStats; error?: string }) => { + const request = this.pending.get(message.id) + if (!request) return + this.pending.delete(message.id) + if (message.result) request.resolve(message.result) + else request.reject(new Error(message.error ?? 'Statistics query failed')) + }) + const failed = (error: Error): void => { + if (this.worker !== worker) return + this.worker = undefined + for (const request of this.pending.values()) request.reject(error) + this.pending.clear() + void worker.terminate() + } + worker.once('error', failed) + worker.once('exit', (code) => failed(new Error(`Statistics worker exited (${code})`))) + worker.unref() + } + // Bound outstanding work when a window repeatedly changes scope during a slow read. + if (this.pending.size >= 16) return Promise.reject(new Error('Statistics reader is busy')) + const id = ++this.nextId + return new Promise((resolve, reject) => { + this.pending.set(id, { resolve, reject }) + this.worker!.postMessage({ id, scope, activeRequestIds: [...activeRequestIds] }) + }) + } + + close(): void { + const worker = this.worker + this.worker = undefined + for (const request of this.pending.values()) request.reject(new Error('Statistics reader closed')) + this.pending.clear() + if (worker) void worker.terminate() + } +} diff --git a/src/main/execution-stats-worker.ts b/src/main/execution-stats-worker.ts new file mode 100644 index 00000000..ca74c3ce --- /dev/null +++ b/src/main/execution-stats-worker.ts @@ -0,0 +1,17 @@ +import { parentPort, workerData } from 'node:worker_threads' +import type { ExecutionStatsInput } from '../shared/assistant-contracts' +import { AssistantDatabase } from './assistant/assistant-database' + +const database = new AssistantDatabase((workerData as { databasePath: string }).databasePath) +database.openReadOnly() +parentPort!.on('message', (message: { + id: number; scope: ExecutionStatsInput; activeRequestIds: string[] +}) => { + try { + const result = database.getExecutionStats(message.scope, new Set(message.activeRequestIds)) + parentPort!.postMessage({ id: message.id, result }) + } catch (error) { + parentPort!.postMessage({ id: message.id, error: error instanceof Error ? error.message : String(error) }) + } +}) +parentPort!.on('close', () => database.close()) diff --git a/src/main/ipc.test.ts b/src/main/ipc.test.ts index 35d31051..ce3ea1d1 100644 --- a/src/main/ipc.test.ts +++ b/src/main/ipc.test.ts @@ -1324,6 +1324,7 @@ describe('registerIpcHandlers model download source routing', () => { vi.mock('electron', () => ({ nativeImage: { createFromBuffer: (buffer: Buffer) => ({ isEmpty: () => false, toPNG: () => buffer }) }, app: { + getAppPath: vi.fn(() => process.cwd()), getName: vi.fn(() => 'GoodBuddy'), getVersion: vi.fn(() => '0.1.0') }, @@ -4382,7 +4383,7 @@ describe('registerIpcHandlers token usage', () => { queueDueSchedules: vi.fn(() => []), listConversationQueueItems: vi.fn(() => []), listPendingConversationQueueIds: vi.fn(() => []), - getExecutionStats: vi.fn(() => summary) + getExecutionStatsAsync: vi.fn(async () => summary) } const webContents = { mainFrame: { url: 'file:///goodbuddy/index.html' }, @@ -4400,13 +4401,13 @@ describe('registerIpcHandlers token usage', () => { const event = { sender: webContents, senderFrame: webContents.mainFrame } const id = '00000000-0000-4000-8000-000000000301' for (const scope of [{ conversationId: id }, { projectId: id }]) { - expect(handler(event, scope)).toBe(summary) - expect(assistantDatabase.getExecutionStats).toHaveBeenLastCalledWith(scope, new Set()) + await expect(handler(event, scope)).resolves.toEqual(summary) + expect(assistantDatabase.getExecutionStatsAsync).toHaveBeenLastCalledWith(scope, new Set(), expect.stringContaining('execution-stats-worker.js')) } expect(() => handler(event, {})).toThrow() expect(() => handler(event, { projectId: id, conversationId: id })).toThrow() expect(() => handler({ sender: {}, senderFrame: webContents.mainFrame }, { projectId: id })).toThrow() - expect(assistantDatabase.getExecutionStats).toHaveBeenCalledTimes(2) + expect(assistantDatabase.getExecutionStatsAsync).toHaveBeenCalledTimes(2) } finally { await dispose() } diff --git a/src/main/ipc.ts b/src/main/ipc.ts index 729f7cb2..0b741c88 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -7504,9 +7504,10 @@ export function registerIpcHandlers( }) registerHandler(ipcChannels.tasksExecutionStats, (event, input: unknown) => { assertTrustedSender(event, window) - return assistantDatabase.getExecutionStats( + return assistantDatabase.getExecutionStatsAsync( executionStatsInputSchema.parse(input), - new Set([...activeRequests].filter(([, lease]) => lease.isReply && !lease.controller.signal.aborted).map(([id]) => id)) + new Set([...activeRequests].filter(([, lease]) => lease.isReply && !lease.controller.signal.aborted).map(([id]) => id)), + join(app.getAppPath(), 'out/main/execution-stats-worker.js') ) }) registerHandler(ipcChannels.tasksSetStatus, (event, input: unknown) => { diff --git a/src/renderer/src/App.test.tsx b/src/renderer/src/App.test.tsx index 5f78b37e..94af7abd 100644 --- a/src/renderer/src/App.test.tsx +++ b/src/renderer/src/App.test.tsx @@ -4372,11 +4372,7 @@ describe("App", () => { const dialog = screen.getByRole("alertdialog", { name: "确认永久删除对话 新对话", }); - expect(dialog).toHaveTextContent("将永久删除此会话的全部内容"); - expect(dialog).toHaveTextContent( - "如果此会话有正在运行的任务,也会同时停止", - ); - expect(dialog).toHaveTextContent("此操作不可恢复"); + expect(dialog).not.toHaveTextContent("将永久删除此会话的全部内容"); const cancel = screen.getByRole("button", { name: "取消删除对话 新对话", }); diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 1ba76754..abe2558f 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -9373,7 +9373,6 @@ function App(): React.JSX.Element { } disabled={deletingConversationId === conversation.id} icon={