From 51166136157a646530e1ad9f6a0242c1c6390e95 Mon Sep 17 00:00:00 2001 From: mesalogo Date: Sat, 26 Sep 2026 15:55:50 +0800 Subject: [PATCH] fix: keep supervisor controls usable and prevent overlapping reviews Allow only one active review across manual starts, automatic runs and resume. Add cancellation that holds admission through runtime cleanup, preserves saved batches and prevents cancelled runs from resuming. Keep review controls usable during execution, show a dismissible progress notice and remove redundant filter labels. Include focused regressions and lifecycle documentation. Release note: Supervision reviews can now be cancelled from Activity records. Starting another review shows the active review instead of duplicating work, while scope, time and refresh controls remain available. --- .../conversation-supervision/logic-design.md | 8 +- .../conversation-supervision/progress.md | 16 +- .../review-scheduling-design.md | 2 + .../technical-design.md | 16 +- .../conversation-supervision/ui-design.md | 8 +- .../conversation-supervision/user-stories.md | 11 +- src/main/assistant/assistant-database.ts | 4 +- .../assistant/supervision-review-store.ts | 12 +- src/main/assistant/supervision-review.test.ts | 160 ++++++++++++++++++ src/main/assistant/supervisor-service.ts | 58 +++++-- src/main/ipc.test.ts | 48 ++++++ src/main/ipc.ts | 9 + src/preload/index.ts | 2 + src/preload/supervision-activity.test.ts | 4 + src/renderer/src/App.test.tsx | 2 + src/renderer/src/HeartbeatCenter.test.tsx | 6 +- src/renderer/src/SupervisionReviewStages.tsx | 6 +- src/renderer/src/SupervisorActivity.test.tsx | 106 +++++++++++- src/renderer/src/SupervisorActivity.tsx | 75 ++++++-- src/renderer/src/SupervisorWorkspace.test.tsx | 60 ++++++- src/renderer/src/SupervisorWorkspace.tsx | 72 ++++++-- .../src/i18n/locales/en-US/heartbeat.ts | 5 +- .../src/i18n/locales/zh-CN/heartbeat.ts | 5 +- src/renderer/src/supervisor-workspace.css | 11 +- src/shared/contracts.ts | 2 + src/shared/ipc-channels.ts | 2 + src/shared/supervision-contracts.ts | 4 +- src/shared/supervision-review-contracts.ts | 5 + tests/support/supervisor-layout-driver.mjs | 23 ++- .../support/supervisor-layout-regression.tsx | 3 +- 30 files changed, 653 insertions(+), 92 deletions(-) diff --git a/docs/features/conversation-supervision/logic-design.md b/docs/features/conversation-supervision/logic-design.md index 7b1b058f..8f12bf48 100644 --- a/docs/features/conversation-supervision/logic-design.md +++ b/docs/features/conversation-supervision/logic-design.md @@ -8,7 +8,7 @@ 报告、监督叶子与导航整理共用监督专用 FIFO 并发池,普通聊天不入池;监督服务外层 run 仍串行。报告在排队前冻结超时,获槽后领取,实际模型请求前校验并刷新租约;报告提交或无变化后先释放槽位,再执行监督。监督超时在 run 创建时保存,节点获槽并解析 Runtime 后计时。阶段超时、租约、传输时限、后台检查与活动轮询各自独立,取值及生效规则见[当前设置合同](./review-scheduling-design.md#当前设置合同)。 -完整回顾的生产接线已增加分页来源清单、逐批输出/offset 事务和导航节点。预算耗尽显示 `paused`,模型或保存失败显示 `failed`;两者均保留此前成功批次,可继续同一 run。存在未读来源、覆盖空洞或未成功导航时不能标 `completed`。手动位置与自动 checkpoint 分开,自动位置只在最终发布事务推进。来源变更目前要求重新回顾,模型配置跨重启固定等差异见[生产接线与剩余边界](./review-scheduling-design.md#0-生产接线与剩余边界)。真实模型验证覆盖 27 条消息的完整会话,较大样本失败单独记录。 +完整回顾的生产接线已增加分页来源清单、逐批输出/offset 事务和导航节点。用户主动暂停后显示 `paused`,模型或保存失败显示 `failed`;两者均保留此前成功批次,可继续同一 run。回顾不按累计运行时长自动暂停。存在未读来源、覆盖空洞或未成功导航时不能标 `completed`。手动位置与自动 checkpoint 分开,自动位置只在最终发布事务推进。来源变更目前要求重新回顾,模型配置跨重启固定等差异见[生产接线与剩余边界](./review-scheduling-design.md#0-生产接线与剩余边界)。真实模型验证覆盖 27 条消息的完整会话,较大样本失败单独记录。 ```mermaid flowchart TB @@ -127,7 +127,7 @@ flowchart LR ## 状态与处理规则 -下表包含目标规则;当前活动执行状态以本节后的“活动记录”为准,取消及部分完成尚未接入。 +下表包含目标规则;当前活动执行状态以本节后的“活动记录”为准,取消已接入,部分完成状态尚未接入。 | 规则 | 条件或状态 | 处理与用户可见结果 | 需求与场景 | | --- | --- | --- | --- | @@ -152,7 +152,9 @@ flowchart LR 旧监督成功记录继续显示;旧心跳与监督之间未保存关联时分别呈现,不用时间接近猜测关系。旧心跳未保存的执行范围显示不可用,不从当前计划反推。此前未保存的手动运行与失败无法恢复。心跳报告沿用计划的历史保留和删除规则;监督结果独立保留,原心跳被清理后仍可读取监督记录。活动不是永久审计库。 -当前活动没有 `cancelled` 或 `partial` 状态,也不提供取消按钮。实体和关系修改只有当前状态及结果内容,没有按时间追加的人工修改记录。 +同一时刻只允许一个监督回顾执行,包括停止清理阶段;不同 scope 也共用此限制。手动新建或继续遇到占用时立即提示,不排队;自动回顾等待占用释放,再检查未完成运行和增量来源。暂停或失败后可以新建回顾,也可继续有效的原 run。 + +取消立即持久化 `cancelled`,但执行与清理尚未停止时 UI 显示“正在取消”,此时仍不能开始另一个回顾。停止后显示“已取消”,保留上次成功结果和本次已保存批次,不发布部分结果、不推进自动 checkpoint。已取消运行不能手动继续,自动检查也不恢复它;后续自动检查可新建 run 处理尚未提交的来源。关联心跳活动显示监督取消状态,已保存的报告保持原状态。界面入口及提示关闭规则见[UI 设计](./ui-design.md#活动)。当前没有 `partial` 状态;实体和关系修改仍没有按时间追加的人工修改记录。 收集、叶子整理、导航合并和发布分别展示。阶段推进只依据服务记录,最终完成只依据结果事务;“六批已保存、剩余零来源”仍可处于合并失败或发布失败。继续保留成功叶子和导航节点,缺失阶段不得靠错误文本或来源数猜测。导航摘要允许省略事实数组,叶子缺少事实数组仍失败;导航不得生成替代叶子事实的非空数组。阶段及输出合同见[生产调度](./review-scheduling-design.md#已接入的调度与存储)。 diff --git a/docs/features/conversation-supervision/progress.md b/docs/features/conversation-supervision/progress.md index c1d860d9..e1d4aca3 100644 --- a/docs/features/conversation-supervision/progress.md +++ b/docs/features/conversation-supervision/progress.md @@ -1,11 +1,25 @@ # 监督者实施进度 -日期:2026-09-24。 +日期:2026-09-26。 当前记录以已验证生产行为为准。监督者尚未覆盖全部 user stories。 ## 已验证 +### 2026-09-26 单次执行与取消集成复核 + +对应 FR-S4、FR-S6、FR-S10、US-S24、US-S26。复核当前 Main、SQLite、IPC、Preload 与 Renderer diff,确认手动新建/继续遇到占用立即拒绝,自动回顾等待;取消立即落库,执行位置保留至并行批次及 Runtime 清理结束。UI 在此期间显示正在取消,X 仅关闭提示;已取消运行不能继续,也不会被自动恢复。实现合同见[单次执行与取消](./technical-design.md#单次执行与取消),界面见[UI 设计](./ui-design.md)。 + +发现并修复一个可达问题:活动页等待整个 `resume` 请求结束才释放操作锁,继续运行后的暂停和取消按钮因此一直禁用。现在发出继续请求后释放提交锁,独立处理完成/失败,Main 仍保证唯一执行。新增回归在修复前因暂停按钮禁用而失败,修复后验证继续中的取消、停止提示,以及原继续请求返回时不清除后续取消操作的状态。 + +本轮验证: + +- `npx vitest run src/renderer/src/SupervisorActivity.test.tsx src/renderer/src/SupervisorWorkspace.test.tsx`:38 项通过,包含上述新增回归、取消清理期间的状态、重新进入页面、手动重复提示及 X 不取消。 +- `npx vitest run src/main/assistant/supervision-review.test.ts src/preload/supervision-activity.test.ts src/main/ipc.test.ts -t "reserves before setup|persists cancellation|allows a new review after|cancels navigation|projects cancellation|wakes waiting automatic|production supervision IPC cancels|supervision"`:27 项通过、144 项按名称跳过;其中 Preload 文件未命中过滤,另用 `npx vitest run src/preload/supervision-activity.test.ts` 单独验证,1 项通过。 +- `npx eslint src/renderer/src/SupervisorActivity.tsx src/renderer/src/SupervisorActivity.test.tsx` 通过;本轮文件的 `git diff --check` 通过。中文新增内容按 `deai-writing` 清单人工审校,文档链接目标已核对。 + +上述测试使用隔离 SQLite、生产 handler/工厂及可控 Runtime、Renderer API 替身;本轮未运行 Electron 端到端、全量测试或类型检查,真实模型调用 0 次。用户提供的后端与前端实现代理测试通过记录作为先前证据,不并入本轮计数。远程不受影响的依据是 `ipc.ts` 生产工厂调用 `resolveRequestRuntime({ workMode: 'ask' })`,无项目、Runtime 选择或执行空间,解析直接返回桌面默认 Runtime;不进入 SSH/Agent 路径,未修改远程协议或取消实现。未运行远程实测,未改动其他工作的 Runtime/package 文件,未提交。 + ### 2026-09-26 移除回顾自动时间预算暂停 对应 FR-S4、FR-S6、FR-S10。生产服务移除提取循环和导航合并中的整次执行期限判断,持续处理至完成;已保存运行中的 `executionSeconds` 继续兼容读取,但不再控制暂停。设置页移除该控件,中英文帮助和活动配置改为持续处理说明。单次模型超时、共享并发、逐批保存、主动暂停及失败续跑保持原路径,具体合同见[生产接线](./review-scheduling-design.md#0-生产接线与剩余边界)。主动暂停仍中断在途请求,仅保留此前已保存批次。 diff --git a/docs/features/conversation-supervision/review-scheduling-design.md b/docs/features/conversation-supervision/review-scheduling-design.md index 952adb97..4c672024 100644 --- a/docs/features/conversation-supervision/review-scheduling-design.md +++ b/docs/features/conversation-supervision/review-scheduling-design.md @@ -28,6 +28,8 @@ schema 47 增加运行配置、来源清单、成功批次、导航节点四张 `supervision:pause` 将父 signal 传入排队及在途模型;取消后的输出不提交。`supervision:resume` 恢复已保存 run;`supervision:batches` 默认每页 10 批、最多 20 批,返回完整叶子及来源。活动返回已保存批次、码点数、剩余来源、当前阶段、排队或执行中的模型任务数及已保存配置。提取和导航汇总持续运行至完成,不按累计运行时长暂停;用户暂停会中断在途请求,仅保留已保存批次。完成判定不依赖摘要措辞。 +2026-09-26 已接入整次回顾 single-flight、`supervision:cancel` 及实时 `supervision:execution`。手动重复请求不排队,自动请求等待;已取消运行从自动未完成查询中排除,也不能手动继续。持久取消与实际停止分开呈现,准入保留至 Runtime 清理结束。完整生命周期见[单次执行与取消](./technical-design.md#单次执行与取消),第 1 节关于 `pending` 队列和缺少取消入口的描述仅为改造前基线。 + 当前阶段以 `phase` 写入既有 `supervision_review_runs.state_json`,取值为 `collecting / extracting / summarizing / saving`;无新表或迁移。它记录服务最后进入的阶段,失败和重启后保留;完成仍由 `supervision_runs.status` 的发布事务决定。活动的 `navigationNodes` 从已保存导航表计数,`inFlight` 仍来自进程内计数。旧运行没有 phase 时不回填推测值。清单和最终发布为同步事务,短阶段不保证被轮询看到,清单事务失败也不会留下部分阶段记录。批次接口只有成功记录,没有逐个失败/在途任务或项目、会话聚合状态;界面仅按分页返回的真实归属组织详情。 ### 当前设置合同 diff --git a/docs/features/conversation-supervision/technical-design.md b/docs/features/conversation-supervision/technical-design.md index 0cdebfcf..47aeb367 100644 --- a/docs/features/conversation-supervision/technical-design.md +++ b/docs/features/conversation-supervision/technical-design.md @@ -43,7 +43,7 @@ schema 43 在结果上增加 `graph_snapshot_json`,保存当次实体名称、 监督整理超时由 run 创建时冻结;共享模型池仍采用实时设置上限。`supervision:pause` 取消该 run 的排队和在途工作,`supervision:resume` 继续已保存批次;监督整理输出在流中按 `supervisionReview.responseKiB` 检查响应容量,默认 1024 KiB,可在设置页调整为 100 至 16384 KiB。该容量每次请求读取当前设置,继续旧运行也采用新值;超限保留成功批次并提示调高后继续。生成摘要及事实内容不设独立短字符限制,监督旧摘要读取保留全文,具体边界见[生产接线](./review-scheduling-design.md#0-生产接线与剩余边界)。以下报告领取和租约规则继续适用。 -手动和自动监督均在桌面 Main 调用生产工厂,Runtime 解析请求只有 `workMode: ask`,不携带 SSH 项目或执行空间。摘要校验、合并和持久化在 Main 完成;本次取消内容长度限制不改变 `gbagent`、远端 Runtime 启动或桌面到 Agent 协议。 +手动和自动监督均在桌面 Main 调用生产工厂,Runtime 解析请求只有 `workMode: ask`,不携带项目、Runtime 选择或执行空间。`resolveRequestRuntime` 因而返回桌面默认 Runtime,不进入 SSH 项目或 `selectedRuntimes.getRuntime` 分支;回顾 scope 只筛选证据。摘要校验、合并和持久化在 Main 完成,single-flight、取消及 UI 控制不改变 `gbagent`、远端 Runtime 启动或桌面到 Agent 协议。 `ApplicationSettingsStore` 持久化报告和整理模型超时,均为 30..600 整数秒,默认 240。报告排队前冻结,监督创建 run 时冻结;计时从获槽及 Runtime 解析后开始,覆盖模型执行及其内部重试。流结束校验 signal 和完成事件,finally 释放临时会话,心跳报告保持原有 100KB 上限,监督整理使用上文的可调响应容量。采集、排队、保存和清理不计入模型超时。 @@ -55,6 +55,14 @@ schema 47 的四张批次相关表使用外键随监督运行清理,未完成 当前设置及冻结范围见[当前设置合同](./review-scheduling-design.md#当前设置合同)。未实现的高级参数继续保留在目标清单,不显示为可保存控件;没有兼容原型数据库的读取器。 +### 单次执行与取消 + +对应 FR-S4、FR-S6、FR-S10;状态规则见[活动记录](./logic-design.md#活动记录)。同一个 `SupervisorService` 在异步配置读取前占用唯一执行位置,`run` 和 `resume` 共用准入。已有执行或停止清理时,手动新建及继续立即返回 `SUPERVISION_REVIEW_BUSY`;`trigger: heartbeat` 等待当前 Promise 结束,醒来后重新检查占用。等待中的自动请求尚未创建监督 run,不占模型池;心跳报告在进入下游监督前已释放自己的槽位。 + +`supervision:execution` 返回进程内 `{ active, runId?, stopping? }`,配置读取期间可有 `active: true` 而尚无 runId。`supervision:cancel({ runId })` 先在既有 `supervision_runs` 写入 `cancelled`、清除错误并保存取消时间,再向父 controller 传播 abort;重复取消保留原时间。该接口等待执行 Promise 结束,包含并行批次的 `allSettled` 和 Runtime `releaseConversation`,之后才释放准入。暂停只请求 abort,执行停止后保存 `paused`;取消优先于暂停。迟到模型输出不得保存叶子、导航或完整结果,成功批次保留,自动 checkpoint 不推进。发布是同步 SQLite 事务,已完成或无变化的运行拒绝取消,不撤销已发布结果;本次无 schema 迁移。 + +Preload 暴露类型化 `execution`、`cancel`,取消输入使用 UUID schema 并校验可信 sender。活动 UI 用实时执行状态覆盖持久记录的停止显示;工作回顾挂载期间每两秒读取执行状态,发起前再次查询,Main 仍作最终准入判断。`resume` IPC 直到运行结束才返回,Renderer 发出请求后释放提交锁并独立处理结果,确保继续中的回顾仍可暂停或取消。状态条 X 仅修改本地显示状态。 + ## 自动增量收集 本节的 100 来源/2,000 码点批次查询现用于原心跳报告及旧服务测试;生产监督使用 schema 47 清单及批次位置,自动 checkpoint 只在完整发布时提交。已有 schema 46 指纹算法和 checkpoint 仍复用,不重置用户已处理位置。 @@ -154,7 +162,7 @@ sequenceDiagram 运行状态与内容确认状态分开保存: -- 当前监督运行状态:`running`、`paused`、`completed`、`failed`、`no_change`。心跳及合并活动的映射见[活动记录](./logic-design.md#活动记录)。 +- 当前监督运行状态:`running`、`paused`、`cancelled`、`completed`、`failed`、`no_change`。心跳及合并活动的映射见[活动记录](./logic-design.md#活动记录)。 - 内容状态:来源事实、自动归纳、待核对、用户确认、用户修订、已移除关系。 - 运行成功只表示模型结果已按契约保存,不表示所有实体和关系已经被用户确认。 - 失败保留上次完整结果和本次已保存批次;暂停不生成完整结果,继续入口恢复同一 run。 @@ -213,6 +221,8 @@ erDiagram | `supervision:overview` | 工作回顾、运行状态、未解决事项和最近结果 | | `supervision:run` | 手动开始一次整理,完成后返回包含 runId 的结果 | | `supervision:activity` | 有界读取真实执行和阶段状态 | +| `supervision:execution` | 读取唯一执行位置及停止清理状态 | +| `supervision:pause` / `supervision:cancel` / `supervision:resume` | 暂停、终止或继续指定回顾;生命周期见上文 | | `supervision:graph` | 按故事线、时间区间和回放时刻分页读取图谱 | | `supervision:object` | 读取事件、实体、关系及来源详情 | | `supervision:confirm` | 确认、修订或移除自动关系和实体变化 | @@ -229,7 +239,7 @@ IPC 返回图谱查询结果时使用分页和有界字段;详情中的来源 - 监督者模型调用固定为 `ask` 语义,输入来自 `EvidenceCollector` 的有界快照,工具授权始终拒绝。 - `Global`、Project、多项目和固定 Conversation/Task/Experiment 目标在 Main 冻结;模型不能生成或改变范围。 - 每个来源引用必须属于本次冻结范围,跨范围引用、伪造项目 ID 和不存在的来源 ID 直接使结果失败。 -- 心跳运行沿用现有 `heartbeat_runs` 的领取、租约和重试机制;新增监督运行按心跳运行 ID 关联,活动据此合并阶段。没有新增取消协议。 +- 心跳运行沿用现有 `heartbeat_runs` 的领取、租约和重试机制;监督运行按心跳运行 ID 关联,活动据此合并阶段。监督取消不撤销已保存的心跳报告,合并活动优先显示监督已取消状态。 - 同一配置同时触发手动和定时运行时,沿用现有幂等键和运行领取规则;已完成结果不能重复写入。 - 用户确认或修订后的实体、关系不被后续整理静默覆盖;新证据以补充或冲突待核对状态写入。 - 外部知识库只读。图谱可以引用保存到本地的外部片段和定位信息,不调用远端写入、不承诺打开远端原文。 diff --git a/docs/features/conversation-supervision/ui-design.md b/docs/features/conversation-supervision/ui-design.md index 4f9ee583..8f104fd8 100644 --- a/docs/features/conversation-supervision/ui-design.md +++ b/docs/features/conversation-supervision/ui-design.md @@ -10,7 +10,7 @@ 工作回顾只读取统一监督结果。顶部紧凑排列范围、时间、发起与刷新控件,不重复显示“新回顾”标题和来源说明,不新增 Modal。历史结果选择与图谱入口位于正文之前,即使只有一条结果也显示选择器。新回顾工具栏、状态、历史选择和结果面板铺满监督者页面壳层的可用内容宽度,与页签左右对齐,不另设阅读宽度上限;结果内部按面板内边距排版,不再单独居中窄正文。正文使用“监督结果”标题,常显生成日期、真实范围和覆盖区间,历史结果不标为最新。工作总结按原有空行分段并保留换行;本次变化和未解决事项直接使用已有字段,不补写结论或截断正文。390px 下新请求字段与操作各占两列,结果元数据可换行。 -新回顾运行中保留已显示结果及其日期、范围,禁用重复提交,并显示简短状态和活动记录入口;该入口清除计划筛选并聚焦活动页签。工作回顾不读取自动监督报告,也不显示自动报告空态、记忆与行动建议或第二行全局范围与刷新。无监督结果时,空态独占工具栏下方整行,标题与手动回顾入口水平居中;自动报告是否为空不影响此判断。运行中和暂停后使用独立状态条,提示当前状态及活动记录入口。 +新回顾运行中保留已显示结果及其日期、范围,范围、时间、发起和刷新控件保持可用;重复点击发起只重新显示当前运行提示,不排入新回顾。状态条提供活动记录入口,清除计划筛选并聚焦活动页签;右侧 X 只关闭提示,不暂停或取消执行,同一次运行的轮询不重新打开已关闭提示。范围、时间和活动计划筛选保留可访问名称,不再重复显示字段标题。工作回顾不读取自动监督报告,也不显示自动报告空态、记忆与行动建议或第二行全局范围与刷新。无监督结果时,空态独占工具栏下方整行,标题与手动回顾入口水平居中;自动报告是否为空不影响此判断。运行中和暂停后使用独立状态条,提示当前状态及活动记录入口。 ## 活动 @@ -22,7 +22,7 @@ 自动监督页依次展示范围与“刷新自动监督”、当前状态及计划列表、运行指标、报告趋势、待确认记忆与行动建议、“自动监督报告”历史、心跳报告阶段审计。刷新统一更新此页数据。报告与运行历史每次追加 20 条,报告可展开全部摘要和洞察,切换页签保留展开与分页位置;不再单独复制最近报告。建议保留确认、忽略、带入对话、标记完成及长内容展开。活动的计划筛选不改变自动监督的统计范围。当前状态下复用计划列表的唯一创建按钮,无计划时只显示列表的未配置说明,不叠加第二个计划空态或创建入口;点击直接打开现有 Modal。报告为空时明确写“暂无自动监督报告”,与工作回顾的结果状态区分。 -加载、空记录和读取失败分开显示;读取失败保留已显示记录并提供就地重试。活动页面激活时,存在运行中记录则在上次读取结束 2 秒后刷新,否则 10 秒后刷新;每次最多一个请求。切换顶层页签、离开监督者或关闭应用后停止定时器,迟到响应不更新界面。错误后等待用户重试,不持续重发失败查询。 +加载、空记录和读取失败分开显示;读取失败保留已显示记录并提供就地重试。活动页面激活时,同轮读取活动和实时执行状态;有活动执行(包括停止清理)或运行中记录则在上次读取结束 2 秒后刷新,否则 10 秒后刷新,不重叠轮询。切换顶层页签、离开监督者或关闭应用后停止活动定时器,迟到响应不更新界面。错误后等待用户重试,不持续重发失败查询。 活动列表与工具栏铺满监督者页面壳层的可用内容宽度,与页签左右对齐,不另设仪表盘宽度上限。范围、计划筛选、清除筛选和刷新紧邻排列,空间不足时换行。列表使用共享表面、边框、状态颜色和按钮。每条运行首先显示类型、状态、开始时间和范围;恢复与事实操作紧随其后,再展示“收集来源 / 整理批次 / 合并摘要 / 发布回顾”。阶段使用编号圆点和连接线,宽窗口四项等宽,窄于 720px 时两列;当前及已完成阶段突出显示,不为每个阶段铺设独立色块卡片。阶段读取已保存位置和真实运行状态,剩余来源为零不表示发布成功。旧运行没有阶段字段时显示“未记录”,不猜测失败位置;已提交来源清单可确认收集结束。无变化运行不显示新结果跳转,后续无需执行的阶段单独标注。 @@ -30,6 +30,8 @@ “已保留事实与来源”展开后才查询既有批次接口,每页 10 批,按本页持久记录组成项目、会话、批次层级。项目使用可用名称,会话优先使用保存来源的标题;只有已保存批次标记“已保存”,不创建未落库的待执行、在途或失败叶子,也不把本页数量当作项目总量。批次摘要收起时最多两行,展开后保留完整摘要、事件、实体、变化、关系及原文片段。来源显示消息 ID、码点范围及版本;长原文局部滚动。原生 details/summary 保留键盘展开和可见焦点,轮询不主动展开用户收起的会话。加载、空批次和读取失败分开显示,失败可重试;换页失败不显示上一页批次,切换 run 后旧响应失效。暂停状态为“未完成,可继续”,手动回顾主动暂停后在工作回顾页提示去活动继续,不显示完成通知。 +活动页运行中同时提供“暂停回顾”和“取消回顾”,继续后的运行也可操作。停止期间禁用两项操作,并在运行标题和当前阶段显示“正在暂停”或“正在取消”;即使持久记录已经为已取消,只要执行尚未停止就继续显示“正在取消”。重新进入活动页通过实时执行状态恢复该提示。停止后显示“已取消”,保留事实详情,不显示继续按钮或失败恢复提示;任一回顾执行或停止清理期间,其他记录的继续按钮禁用。 + ## 自动监督设置 2026-09-23 当前已实现“模型超时与并发”表单:心跳报告与监督者整理分别设置,均为 30 至 600 的整数秒,默认 240;监督模型并发数为 1 至 4 的整数,默认 1。保存真实字段 `heartbeatReportTimeoutSeconds`、`supervisorOrganizeTimeoutSeconds`、`supervisorModelConcurrency`;无效输入不能提交,设置加载/保存中或锁定时禁用,失败保留草稿并提供重试。超时以阶段读取设置的时点为准,不改变已冻结的值;并发数影响后续派发,降低上限不打断在途请求。 @@ -68,7 +70,7 @@ Modal 按[共享表单 Modal 规则](../../../UI-DESIGN.md#615-表单-modal)复 选择历史结果后,图谱读取该结果的成员和内容,标题显示其回顾区间和生成时间。切换时清空旧图谱和详情并显示加载状态,迟到请求不得替换新选择。确认、修订和移除反馈更新当前所选结果,后续自动回顾不改写旧结果。 -手动“回顾当前进展”生成中禁用重复提交。该操作新建回顾,活动页“继续”恢复原 run;两者不得混淆。失败保留上次结果、本次范围和成功批次。手动操作不推进自动位置,删除报告也不重置自动位置。 +手动“回顾当前进展”新建回顾,活动页“继续”恢复原 run;运行中再次发起按上文显示提示,停止清理结束后才能开始新回顾。失败保留上次结果、本次范围和成功批次;已取消回顾不能继续。手动操作不推进自动位置,删除报告也不重置自动位置。 ## 会话侧栏 diff --git a/docs/features/conversation-supervision/user-stories.md b/docs/features/conversation-supervision/user-stories.md index 667bfce8..75f19008 100644 --- a/docs/features/conversation-supervision/user-stories.md +++ b/docs/features/conversation-supervision/user-stories.md @@ -155,7 +155,7 @@ ### US-S24 手动回顾当前进展 -**状态:待实现;当前智能心跳已有“立即运行一次心跳”。** +**状态:已接入工作回顾;执行状态与取消规则见[活动记录](./logic-design.md#活动记录)。** 作为工作使用者,我希望主动检查当前范围,而不必等待下一次计划运行。 @@ -163,14 +163,15 @@ | Given | When | Then | | --- | --- | --- | -| 范围有效且有来源 | 点击“回顾当前进展” | 页面显示整理中状态、进度说明和取消操作 | +| 范围有效且有来源 | 点击“回顾当前进展” | 页面显示整理中状态和活动入口,活动页提供暂停与取消 | | 整理完成并有新变化 | 返回结果 | 工作回顾、监督反馈和图谱引用同一次结果 | | 没有有效新变化 | 整理完成 | 显示检查时间和“暂无新进展”,不重复推送旧内容 | -| 用户点击取消 | 整理仍在运行 | 显示已取消,保留上一次成功结果 | +| 用户点击取消 | 整理仍在运行 | 清理期间显示正在取消,停止后显示已取消,保留上一次成功结果和成功批次 | +| 回顾正在执行或停止清理 | 再次点击手动发起 | 显示当前运行提示,不新建或排队;关闭提示不取消执行 | **界面行为** -- 运行按钮在整理期间禁用,取消后可以重试。 +- 运行按钮在整理期间保持可用,重复点击按上表提示;取消停止后可以新建,已取消运行不能继续。 - “无新进展”与“失败”使用不同状态和文案。 - 结果必须显示本次冻结的范围,不能因用户后来切换项目而改变解释。 @@ -537,7 +538,7 @@ | 已保存批次跨多个项目和会话 | 展开事实并翻页 | 按本页真实归属分层,标明本页数量;原文可读,失败叶子不伪造为已保存 | | 原始错误是多行校验 JSON | 展开错误详情 | 默认折叠,展开后有界滚动,完整文本可选择复制;恢复操作和阶段仍在其前面 | -取消和人工修改时间序列审计不属于当前已实现范围;实际状态及保留规则见[活动记录](./logic-design.md#活动记录)。 +继续后的运行仍可暂停或取消;任一运行执行或停止清理期间,其他运行不能继续。重新进入页面仍显示实际停止状态。人工修改时间序列审计不属于当前已实现范围;实际状态及保留规则见[活动记录](./logic-design.md#活动记录)。 ### US-S27 自动增量与历史重看 diff --git a/src/main/assistant/assistant-database.ts b/src/main/assistant/assistant-database.ts index 5333f45d..7ed0f179 100644 --- a/src/main/assistant/assistant-database.ts +++ b/src/main/assistant/assistant-database.ts @@ -8543,13 +8543,15 @@ export class AssistantDatabase { UNION ALL SELECT h.id, 'heartbeat', h.trigger, CASE WHEN h.status = 'claimed' THEN 'running' + WHEN s.status = 'cancelled' THEN 'cancelled' WHEN h.status = 'failed' OR s.status = 'failed' OR h.projection_status = 'failed' THEN 'failed' WHEN s.status = 'running' OR (h.status IN ('completed', 'no_change') AND h.projection_status = 'running') THEN 'running' WHEN s.status = 'paused' THEN 'paused' WHEN s.status = 'completed' THEN 'completed' ELSE h.status END, COALESCE(s.scope_json, h.activity_scope_json), COALESCE(h.started_at, h.created_at), - CASE WHEN s.status = 'running' OR h.status = 'claimed' OR h.projection_status = 'running' THEN NULL + CASE WHEN s.status = 'cancelled' THEN s.completed_at + WHEN s.status = 'running' OR h.status = 'claimed' OR h.projection_status = 'running' THEN NULL ELSE COALESCE(s.completed_at, h.projection_completed_at, h.completed_at) END, s.time_range_json, COALESCE(s.error, h.projection_error, h.error), COALESCE(r.summary, e.summary), r.id, h.status, s.status, s.id diff --git a/src/main/assistant/supervision-review-store.ts b/src/main/assistant/supervision-review-store.ts index 57b428a7..ebd809d5 100644 --- a/src/main/assistant/supervision-review-store.ts +++ b/src/main/assistant/supervision-review-store.ts @@ -121,10 +121,12 @@ export class SupervisionReviewStore { } resume(runId: string): void { + const status = this.db.prepare('SELECT status FROM supervision_runs WHERE id = ?').get(runId)?.status + if (status === 'cancelled') throw new Error('SUPERVISION_REVIEW_CANCELLED: 此回顾已取消,不能继续。请开始新的回顾。') if (this.load(runId).restartRequired) throw new Error('Review source changed or was removed; start a new review') const changed = this.db.prepare(`UPDATE supervision_runs SET status = 'running', error = NULL, completed_at = NULL WHERE id = ? AND status IN ('paused', 'failed', 'running')`).run(runId) - if (!changed.changes) throw new Error('Review is already complete or unavailable') + if (!changed.changes) throw new Error('SUPERVISION_REVIEW_NOT_RESUMABLE: 此回顾已完成或不存在,请开始新的回顾。') // A changed/deleted source cannot be silently counted as covered. Saved facts remain accessible. let after = '' for (;;) { @@ -144,6 +146,14 @@ export class SupervisionReviewStore { .run(reason, new Date().toISOString(), runId) } + cancel(runId: string): void { + const changed = this.db.prepare(`UPDATE supervision_runs SET status = 'cancelled', error = NULL, + completed_at = CASE WHEN status = 'cancelled' THEN completed_at ELSE ? END + WHERE id = ? AND status IN ('running', 'paused', 'failed', 'cancelled')`) + .run(new Date().toISOString(), runId) + if (!changed.changes) throw new Error('SUPERVISION_REVIEW_NOT_CANCELLABLE: 此回顾已完成或不存在,无需取消。') + } + setPhase(runId: string, phase: NonNullable): void { this.db.prepare("UPDATE supervision_review_runs SET state_json = json_set(state_json, '$.phase', ?) WHERE run_id = ?").run(phase, runId) } diff --git a/src/main/assistant/supervision-review.test.ts b/src/main/assistant/supervision-review.test.ts index 11a99cd4..5ce21a5a 100644 --- a/src/main/assistant/supervision-review.test.ts +++ b/src/main/assistant/supervision-review.test.ts @@ -8,6 +8,7 @@ import { AssistantDatabase } from './assistant-database' import { SupervisorService, type SupervisorSummarizerRequest } from './supervisor-service' import type { SupervisionRunRequest } from '../../shared/supervision-contracts' import type { ReviewConfiguration } from './supervision-review-store' +import { HeartbeatService } from './heartbeat-service' const cleanups: Array<() => Promise> = [] afterEach(async () => { vi.useRealTimers(); for (const cleanup of cleanups.splice(0)) await cleanup() }) @@ -17,6 +18,165 @@ const request: SupervisionRunRequest = { trigger: 'manual', scope: { kind: 'glob } } const empty = { summary: 'Navigation', changeDigest: '', openItems: [], events: [], entities: [], entityChanges: [], relations: [] } +function deferred() { + let resolve!: () => void + const promise = new Promise(done => { resolve = done }) + return { promise, resolve } +} + +it('reserves before setup, rejects manual and resume overlap, and lets automatic work wait for settlement', async () => { + const f = await fixture([['Evidence']]) + const service = f.service() + const entered = deferred(), finish = deferred() + f.summarize.mockImplementationOnce(async () => { entered.resolve(); await finish.promise; return empty }) + const first = service.run(request) + expect(service.execution()).toMatchObject({ active: true }) + await expect(service.run(request)).rejects.toThrow('SUPERVISION_REVIEW_BUSY') + await entered.promise + const runId = service.execution().runId! + await expect(service.resume(runId)).rejects.toThrow('SUPERVISION_REVIEW_BUSY') + const automatic = service.run({ ...request, trigger: 'heartbeat' }) + const nextAutomatic = service.run({ ...request, trigger: 'heartbeat' }) + expect(f.db.listSupervisionActivity()).toHaveLength(1) + finish.resolve() + await expect(first).resolves.toMatchObject({ status: 'completed' }) + await expect(automatic).resolves.toMatchObject({ status: 'completed' }) + await expect(nextAutomatic).resolves.toMatchObject({ status: 'no_change' }) + expect(service.execution().active).toBe(false) + expect(f.db.listSupervisionActivity()).toHaveLength(3) + expect(f.summarize).toHaveBeenCalledTimes(2) +}) + +it('persists cancellation immediately but holds the slot until every in-flight batch settles', async () => { + const f = await fixture([['First'], ['Second']]) + const service = f.service() + const entered = deferred(), finish = deferred() + const signals: AbortSignal[] = [] + f.summarize.mockImplementation(async input => { + signals.push(input.signal!) + if (signals.length === 2) entered.resolve() + await finish.promise + return empty // Simulate a provider that returns a late success after abort. + }) + const pending = service.run({ ...request, trigger: 'heartbeat' }) + await entered.promise + const runId = service.execution().runId! + let cancelled = false + const cancellation = service.cancel(runId).then(() => { cancelled = true }) + expect(f.db.listSupervisionActivity()[0]).toMatchObject({ status: 'cancelled', supervisionStatus: 'cancelled', resultId: null }) + expect(signals.every(signal => signal.aborted)).toBe(true) + expect(service.execution()).toEqual({ active: true, runId, stopping: 'cancelled' }) + await expect(service.run(request)).rejects.toThrow('SUPERVISION_REVIEW_BUSY') + await expect(service.resume(runId)).rejects.toThrow('SUPERVISION_REVIEW_BUSY') + expect(cancelled).toBe(false) + service.pause(runId) + finish.resolve() + await cancellation + await expect(pending).resolves.toMatchObject({ status: 'cancelled' }) + expect(service.execution().active).toBe(false) + expect(f.db.supervisionReviewStore().batches(runId)).toEqual([]) + expect(f.sql.prepare('SELECT COUNT(*) AS n FROM review_checkpoints').get()!.n).toBe(0) + expect(f.db.listSupervisionResults()).toEqual([]) + await expect(service.resume(runId)).rejects.toThrow('SUPERVISION_REVIEW_CANCELLED') + expect(f.db.listSupervisionActivity()[0]!.status).toBe('cancelled') + await service.cancel(runId) + f.summarize.mockResolvedValue(empty) + const next = await service.run({ ...request, trigger: 'heartbeat' }) + expect(next.runId).not.toBe(runId) + expect(next.status).toBe('completed') +}) + +it.each(['paused', 'failed'] as const)('allows a new review after %s and can cancel retained work without publishing checkpoints', async status => { + const f = await fixture([['x'.repeat(2500)]], { concurrency: 1 }) + const service = f.service() + f.summarize.mockResolvedValueOnce(empty).mockImplementationOnce(async () => { + if (status === 'failed') throw new Error('Provider failed') + service.pause(service.execution().runId!) + return empty + }) + const first = service.run({ ...request, trigger: 'heartbeat' }) + if (status === 'failed') await expect(first).rejects.toThrow('Provider failed') + else await expect(first).resolves.toMatchObject({ status }) + const runId = f.db.listSupervisionActivity()[0]!.id + expect(f.db.supervisionReviewStore().batches(runId)).toHaveLength(1) + await expect(service.run(request)).resolves.toMatchObject({ status: 'completed' }) + await service.cancel(runId) + await expect(service.resume(runId)).rejects.toThrow('SUPERVISION_REVIEW_CANCELLED') + expect(f.db.supervisionReviewStore().batches(runId)).toHaveLength(1) + expect(f.sql.prepare('SELECT COUNT(*) AS n FROM review_checkpoints').get()!.n).toBe(0) + f.db.close(); f.db.initialize(f.directory) + expect(f.db.listSupervisionActivity().find(row => row.id === runId)!.status).toBe('cancelled') + await expect(f.service().resume(runId)).rejects.toThrow('SUPERVISION_REVIEW_CANCELLED') +}) + +it('cancels navigation without publishing fully extracted source checkpoints', async () => { + const f = await fixture([['x'.repeat(1500)]], { concurrency: 1 }) + const service = f.service() + const entered = deferred(), finish = deferred() + f.summarize.mockResolvedValueOnce(empty).mockResolvedValueOnce(empty).mockImplementationOnce(async () => { + entered.resolve(); await finish.promise; return empty + }) + const pending = service.run({ ...request, trigger: 'heartbeat' }) + await entered.promise + const runId = service.execution().runId! + const cancellation = service.cancel(runId) + finish.resolve() + await cancellation + await expect(pending).resolves.toMatchObject({ status: 'cancelled', coverage: { batches: 2, remainingSources: 0, complete: false } }) + expect(f.sql.prepare('SELECT COUNT(*) AS n FROM review_checkpoints').get()!.n).toBe(0) + expect(f.sql.prepare('SELECT COUNT(*) AS n FROM supervision_review_navigation').get()!.n).toBe(0) + expect(f.db.listSupervisionResults()).toEqual([]) +}) + +it('projects cancellation into heartbeat activity while runtime cleanup is still pending', async () => { + const f = await fixture([['Evidence']]) + const service = f.service() + const entered = deferred(), finish = deferred() + f.summarize.mockImplementationOnce(async () => { entered.resolve(); await finish.promise; return empty }) + const heartbeat = new HeartbeatService(f.db, { summarize: async () => ({ summary: 'Report', highlights: [], proposedMemories: [], followUpTasks: [] }) }, () => {}, + async ({ run }) => { await service.run({ ...request, trigger: 'heartbeat' }, run.id) }) + const config = heartbeat.create({ name: 'Review', scope: request.scope, timezone: 'UTC', + recurrence: { type: 'daily', localTime: '09:00' }, enabled: true, lookbackHours: 24, retentionDays: 30 }) + const pending = heartbeat.runNow({ id: config.id, idempotencyKey: randomUUID() }) + await entered.promise + const runId = service.execution().runId! + const cancellation = service.cancel(runId) + expect(f.db.listSupervisionActivity()).toEqual([expect.objectContaining({ kind: 'heartbeat', status: 'cancelled', + heartbeatStatus: 'completed', supervisionStatus: 'cancelled', completedAt: expect.any(String), reviewProgress: expect.objectContaining({ runId }) })]) + finish.resolve() + await cancellation + await pending + expect(f.db.listSupervisionActivity()[0]!.status).toBe('cancelled') + expect(f.sql.prepare('SELECT COUNT(*) AS n FROM review_checkpoints WHERE stage = ?').get('supervisor')!.n).toBe(0) +}) + +it.each(['paused', 'failed', 'cancelled'] as const)('wakes waiting automatic work after %s without overlapping requests', async status => { + const f = await fixture([['Evidence']]) + const service = f.service() + const entered = deferred(), finish = deferred() + f.summarize.mockImplementationOnce(async () => { + entered.resolve(); await finish.promise + if (status === 'failed') throw new Error('Provider failed') + return empty + }) + const pending = service.run(request) + const outcome = status === 'failed' ? expect(pending).rejects.toThrow('Provider failed') + : expect(pending).resolves.toMatchObject({ status }) + await entered.promise + const runId = service.execution().runId! + const automatic = service.run({ ...request, trigger: 'heartbeat' }) + const cancellation = status === 'cancelled' ? service.cancel(runId) : undefined + if (status === 'paused') service.pause(runId) + expect(f.summarize).toHaveBeenCalledTimes(1) + expect(f.db.listSupervisionActivity()).toHaveLength(1) + finish.resolve() + await cancellation + await outcome + await expect(automatic).resolves.toMatchObject({ status: 'completed' }) + expect(f.summarize).toHaveBeenCalledTimes(2) + expect(service.execution().active).toBe(false) +}) + async function fixture(contents: string[][], overrides: Partial = {}) { const directory = await mkdtemp(join(tmpdir(), 'goodbuddy-review-')) const path = join(directory, 'assistant.sqlite') diff --git a/src/main/assistant/supervisor-service.ts b/src/main/assistant/supervisor-service.ts index 697df140..03140eed 100644 --- a/src/main/assistant/supervisor-service.ts +++ b/src/main/assistant/supervisor-service.ts @@ -10,7 +10,7 @@ import { import { reviewScope, type ReviewBatch } from './review-checkpoint' import { createHash } from 'node:crypto' import type { SupervisionReviewStore, ReviewConfiguration } from './supervision-review-store' -import type { SupervisionReviewProgress } from '../../shared/supervision-review-contracts' +import type { SupervisionReviewExecution, SupervisionReviewProgress } from '../../shared/supervision-review-contracts' export type SupervisorSummarizerRequest = { signal?: AbortSignal @@ -36,7 +36,7 @@ export interface SupervisorEvidenceCollector { export type StoredSupervisionResult = { batch?: ReviewBatch - status?: 'completed' | 'no_change' | 'paused' + status?: 'completed' | 'no_change' | 'paused' | 'cancelled' coverage?: SupervisionReviewProgress runId?: string candidates?: SupervisionCandidate[] @@ -170,7 +170,7 @@ function validateReferences( } export class SupervisorService { - private pending: Promise = Promise.resolve() + private active?: { settled: Promise; runId?: string; stopping?: 'paused' | 'cancelled' } private readonly controllers = new Map() private readonly activity = new Map() constructor( @@ -181,9 +181,24 @@ export class SupervisorService { ) {} async run(input: unknown, heartbeatRunId?: string): Promise { - const result = this.pending.catch(() => undefined).then(() => this.execute(input, heartbeatRunId)) - this.pending = result - return result + const request = supervisionRunRequestSchema.parse(input) + return this.admit(() => this.execute(request, heartbeatRunId), request.trigger === 'heartbeat') + } + + execution(): SupervisionReviewExecution { + return { active: Boolean(this.active), runId: this.active?.runId, stopping: this.active?.stopping } + } + + private async admit(operation: () => Promise, wait = false): Promise { + while (this.active) { + if (!wait) throw new Error('SUPERVISION_REVIEW_BUSY: 已有回顾正在执行或停止中,请等待结束后再开始或继续回顾。') + await this.active.settled.catch(() => undefined) + } + // Reserve before any asynchronous configuration, collection or resume work. + const active = { settled: Promise.resolve().then(operation) } + this.active = active + try { return await active.settled } + finally { if (this.active === active) this.active = undefined } } private async execute(input: unknown, heartbeatRunId?: string): Promise { @@ -240,18 +255,28 @@ export class SupervisorService { } pause(runId: string): void { + if (this.active?.runId === runId && this.active.stopping !== 'cancelled') this.active.stopping = 'paused' this.controllers.get(runId)?.abort(new Error('Review paused by user')) } + async cancel(runId: string): Promise { + if (!this.review) throw new Error('SUPERVISION_REVIEW_UNAVAILABLE: 此回顾不支持取消。') + this.review.database().cancel(runId) + const active = this.active?.runId === runId ? this.active : undefined + if (active) { + active.stopping = 'cancelled' + this.controllers.get(runId)?.abort(new Error('Review cancelled by user')) + await active.settled.catch(() => undefined) + } + } + progress(progress: SupervisionReviewProgress): SupervisionReviewProgress { return { ...progress, inFlight: 0, ...this.activity.get(progress.runId) } } async resume(runId: string): Promise { if (!this.review) throw new Error('Resumable reviews are unavailable') - const operation = this.pending.catch(() => undefined).then(() => this.executeReview(undefined, undefined, runId)) - this.pending = operation - return operation + return this.admit(() => this.executeReview(undefined, undefined, runId)) } private async executeReview(input?: SupervisionRunRequest, heartbeatRunId?: string, resumeId?: string): Promise { @@ -260,14 +285,16 @@ export class SupervisorService { const state = existing ? db.load(existing) : { request: input!, config: await this.review!.configuration() } const { request, config } = state const runId = existing ?? this.store.start!(request, heartbeatRunId) + if (this.active) this.active.runId = runId const controller = new AbortController() this.controllers.set(runId, controller) const activity = { inFlight: 0 } this.activity.set(runId, activity) const empty: SupervisionSummaryOutput = { summary: 'No new evidence', changeDigest: '', openItems: [], events: [], entities: [], entityChanges: [], relations: [] } - const paused = () => { - db.pause(runId, 'Review paused by user') - return { runId, request, evidence: [], output: empty, status: 'paused' as const, coverage: db.progress(runId) } + const stopped = (): StoredSupervisionResult => { + const status = this.active?.stopping === 'cancelled' ? 'cancelled' : 'paused' + if (status === 'paused') db.pause(runId, 'Review paused by user') + return { runId, request, evidence: [], output: empty, status, coverage: db.progress(runId) } } try { if (existing) db.resume(runId) @@ -296,7 +323,7 @@ export class SupervisorService { return output } for (;;) { - if (controller.signal.aborted) return paused() + if (controller.signal.aborted) return stopped() const groups = db.groups(runId, config.concurrency) if (!groups.length) break const results = await Promise.allSettled(groups.map(async group => { @@ -307,7 +334,7 @@ export class SupervisorService { controller.signal.throwIfAborted() db.save(runId, group.projectId, group.conversationId, evidence, output) })) - if (controller.signal.aborted) return paused() + if (controller.signal.aborted) return stopped() const failure = results.find(result => result.status === 'rejected') if (failure?.status === 'rejected') throw failure.reason } @@ -325,6 +352,7 @@ export class SupervisorService { const evidence = [left, right].map(card => ({ id: card.id, sourceType: 'note' as const, sourceId: card.id, title: 'Retained review navigation', content: card.output.summary, occurredAt: request.timeRange.to })) const output = await summarize(evidence, true) + controller.signal.throwIfAborted() db.saveNavigation(runId, id, [left.id, right.id], output) return { id, output } } @@ -375,7 +403,7 @@ export class SupervisorService { await this.store.save(result) return result } catch (error) { - if (controller.signal.aborted) return paused() + if (controller.signal.aborted) return stopped() this.store.fail?.(runId, error instanceof Error ? error.message : 'Review failed') throw error } finally { this.controllers.delete(runId); this.activity.delete(runId) } diff --git a/src/main/ipc.test.ts b/src/main/ipc.test.ts index 128f9d27..ac9ca813 100644 --- a/src/main/ipc.test.ts +++ b/src/main/ipc.test.ts @@ -5896,6 +5896,54 @@ describe('registerIpcHandlers agent terminal state', () => { } finally { finish(); await harness.dispose(); database.close(); vi.useRealTimers() } }) + it('production supervision IPC cancels the runtime and holds admission until conversation cleanup settles', async () => { + const database = new AssistantDatabase(':memory:') + database.initialize(process.cwd()) + const now = Date.now() + database.saveLocalConversations([{ header: { id: crypto.randomUUID(), projectId: database.listProjects()[0]!.id, title: 'Cancel review', updatedAt: now }, + messages: [{ id: crypto.randomUUID(), role: 'user', state: 'complete', content: 'Review this', createdAt: now }] }]) + let entered!: () => void, cleaning!: () => void, finish!: () => void + const started = new Promise(resolve => { entered = resolve }) + const cleanupStarted = new Promise(resolve => { cleaning = resolve }) + const cleanup = new Promise(resolve => { finish = resolve }) + const run = vi.fn(async function* (_input: AgentExecutionRequest, signal: AbortSignal) { + entered() + await new Promise(resolve => signal.addEventListener('abort', () => resolve(), { once: true })) + signal.throwIfAborted() + yield { type: 'done' as const, requestId: _input.requestId } + }) + const releaseConversation = vi.fn(async () => { cleaning(); await cleanup }) + const harness = createHarness({ runtimeId: 'model', capability: 'chat', run, releaseConversation }, undefined, 'always', undefined, false, + undefined, undefined, undefined, false, undefined, undefined, undefined, undefined, database) + try { + harness.getApplicationSettings.mockResolvedValue({ heartbeatEnabled: true }) + const event = trustedEvent(harness.webContents) + const request = { trigger: 'manual', scope: { kind: 'global' }, timeRange: { + from: new Date(now - 1).toISOString(), to: new Date(now + 1).toISOString() + } } + const pending = electronMocks.handlers.get(ipcChannels.supervisionRun)!(event, request) + await started + const runId = database.listSupervisionActivity()[0]!.id + const input = { runId } + await expect(electronMocks.handlers.get(ipcChannels.supervisionCancel)!({ sender: {} }, input)).rejects.toThrow() + await expect(electronMocks.handlers.get(ipcChannels.supervisionCancel)!(event, { runId: 'invalid' })).rejects.toThrow() + const cancellation = electronMocks.handlers.get(ipcChannels.supervisionCancel)!(event, input) + await cleanupStarted + expect(database.listSupervisionActivity()[0]!.status).toBe('cancelled') + expect(electronMocks.handlers.get(ipcChannels.supervisionExecution)!(event)).toEqual({ active: true, runId, stopping: 'cancelled' }) + await expect(electronMocks.handlers.get(ipcChannels.supervisionRun)!(event, request)).rejects.toThrow('SUPERVISION_REVIEW_BUSY') + await expect(electronMocks.handlers.get(ipcChannels.supervisionResume)!(event, input)).rejects.toThrow('SUPERVISION_REVIEW_BUSY') + finish() + await cancellation + await expect(pending).resolves.toMatchObject({ status: 'cancelled' }) + expect(electronMocks.handlers.get(ipcChannels.supervisionExecution)!(event)).toMatchObject({ active: false }) + await expect(electronMocks.handlers.get(ipcChannels.supervisionResume)!(event, input)).rejects.toThrow('SUPERVISION_REVIEW_CANCELLED') + expect(database.listSupervisionResults()).toEqual([]) + expect(run).toHaveBeenCalledTimes(1) + expect(releaseConversation).toHaveBeenCalledTimes(1) + } finally { finish(); await harness.dispose(); database.close() } + }) + it('production supervision IPC pauses in-flight work and resumes saved batches without replaying them', async () => { const database = new AssistantDatabase(':memory:') database.initialize(process.cwd()) diff --git a/src/main/ipc.ts b/src/main/ipc.ts index 23029042..e5b043fb 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -7840,6 +7840,15 @@ export function registerIpcHandlers( const { runId } = supervisionReviewIdSchema.parse(input) supervisorService.pause(runId) }) + registerHandler(ipcChannels.supervisionExecution, (event) => { + assertTrustedSender(event, window) + return supervisorService.execution() + }) + registerHandler(ipcChannels.supervisionCancel, async (event, input: unknown) => { + assertTrustedSender(event, window) + const { runId } = supervisionReviewIdSchema.parse(input) + await supervisorService.cancel(runId) + }) registerHandler(ipcChannels.supervisionResume, async (event, input: unknown) => { assertTrustedSender(event, window) const { runId } = supervisionReviewIdSchema.parse(input) diff --git a/src/preload/index.ts b/src/preload/index.ts index 67acf708..01fa8846 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -1382,6 +1382,8 @@ const desktopApi: DesktopApi = { supervision: { activity: (input) => ipcRenderer.invoke(ipcChannels.supervisionActivity, input), pause: (input) => ipcRenderer.invoke(ipcChannels.supervisionPause, input), + cancel: (input) => ipcRenderer.invoke(ipcChannels.supervisionCancel, input), + execution: () => ipcRenderer.invoke(ipcChannels.supervisionExecution), resume: (input) => ipcRenderer.invoke(ipcChannels.supervisionResume, input), batches: (input) => ipcRenderer.invoke(ipcChannels.supervisionBatches, input), overview: (input) => ipcRenderer.invoke(ipcChannels.supervisionOverview, input), diff --git a/src/preload/supervision-activity.test.ts b/src/preload/supervision-activity.test.ts index 304ad95a..d2486706 100644 --- a/src/preload/supervision-activity.test.ts +++ b/src/preload/supervision-activity.test.ts @@ -21,6 +21,10 @@ it('reads activity and exact review results through the explicit bridge', async expect(electron.ipcRenderer.invoke).toHaveBeenLastCalledWith(ipcChannels.supervisionOverview, { resultId: 'old-result' }) await api.supervision.pause({ runId: 'run' }) expect(electron.ipcRenderer.invoke).toHaveBeenLastCalledWith(ipcChannels.supervisionPause, { runId: 'run' }) + await api.supervision.cancel({ runId: 'run' }) + expect(electron.ipcRenderer.invoke).toHaveBeenLastCalledWith(ipcChannels.supervisionCancel, { runId: 'run' }) + await api.supervision.execution() + expect(electron.ipcRenderer.invoke).toHaveBeenLastCalledWith(ipcChannels.supervisionExecution) await api.supervision.resume({ runId: 'run' }) expect(electron.ipcRenderer.invoke).toHaveBeenLastCalledWith(ipcChannels.supervisionResume, { runId: 'run' }) await api.supervision.batches({ runId: 'run', offset: 10, limit: 10 }) diff --git a/src/renderer/src/App.test.tsx b/src/renderer/src/App.test.tsx index 0b630823..a55edd8b 100644 --- a/src/renderer/src/App.test.tsx +++ b/src/renderer/src/App.test.tsx @@ -691,6 +691,8 @@ const api: DesktopApi = { history: vi.fn(async () => ({ runs: [], entries: [] })), }, supervision: { + cancel: vi.fn(async () => undefined), + execution: vi.fn(async () => ({ active: false })), pause: vi.fn(async () => undefined), resume: vi.fn(async () => undefined), batches: vi.fn(async () => []), diff --git a/src/renderer/src/HeartbeatCenter.test.tsx b/src/renderer/src/HeartbeatCenter.test.tsx index 3d896056..9d0dbd59 100644 --- a/src/renderer/src/HeartbeatCenter.test.tsx +++ b/src/renderer/src/HeartbeatCenter.test.tsx @@ -151,7 +151,7 @@ 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: { overview: async () => [], graph } } as never + window.goodbuddy = { supervision: { execution: async () => ({ active: false }), overview: async () => [], graph } } as never const props = createProps({ graphNavigation: { resultId: 'A' } }) const view = renderComponent() const tab = screen.getByRole('tab', { name: '故事线图谱' }) @@ -171,6 +171,7 @@ it('graph navigation reopens the graph tab on repeated requests and preserves no describe('HeartbeatCenter', () => { beforeEach(() => { vi.stubGlobal('goodbuddy', { supervision: { + execution: vi.fn(async () => ({ active: false })), overview: vi.fn(async () => []), graph: vi.fn(async () => ({ storyLine: null, events: [], entities: [], relations: [], sources: [], eventEntities: [], eventSources: [] })) } }) @@ -189,6 +190,7 @@ describe('HeartbeatCenter', () => { await i18n.changeLanguage(language) const t = (key: string) => i18n.t(key, { ns: 'heartbeat' }) vi.stubGlobal('goodbuddy', { supervision: { + execution: vi.fn(async () => ({ active: false })), overview: vi.fn(async () => [{ id: 'result', storyLineId: 'story', sourceId: null, summary: 'Review summary', changeDigest: '', createdAt: '2026-09-22T00:00:00Z', scope: { kind: 'global' }, timeRange: { from: '2026-09-01T00:00:00Z', to: '2026-09-22T00:00:00Z' }, openItems: [] }]), activity: vi.fn(async () => []), graph: vi.fn(async () => ({ storyLine: { id: 'story', scope_json: JSON.stringify({ kind: 'global' }) }, events: [ @@ -242,7 +244,7 @@ describe('HeartbeatCenter', () => { const overview = vi.fn(async (input?: { resultId?: string }) => input?.resultId === 'old' ? [old] : []) const graph = vi.fn(async () => ({ storyLine: null, events: [], entities: [], relations: [], sources: [], eventEntities: [], eventSources: [] })) const activity = vi.fn(async () => [{ id: 'run', kind: 'supervision', trigger: 'manual', status: 'completed', scope: old.scope, startedAt: old.createdAt, completedAt: old.createdAt, timeRange: old.timeRange, error: null, summary: 'Activity summary', resultId: 'old', heartbeatStatus: null, supervisionStatus: 'completed' }]) - vi.stubGlobal('goodbuddy', { supervision: { overview, graph, activity } }) + vi.stubGlobal('goodbuddy', { supervision: { execution: async () => ({ active: false }), overview, graph, activity } }) renderComponent() expect(screen.getAllByRole('tab').map((tab) => tab.textContent)).toEqual(['工作回顾', '故事线图谱', '自动监督', '活动记录', '设置']) fireEvent.click(screen.getByRole('tab', { name: '活动记录' })) diff --git a/src/renderer/src/SupervisionReviewStages.tsx b/src/renderer/src/SupervisionReviewStages.tsx index 85fbd02e..56bdac17 100644 --- a/src/renderer/src/SupervisionReviewStages.tsx +++ b/src/renderer/src/SupervisionReviewStages.tsx @@ -3,7 +3,7 @@ import type { SupervisionActivity } from '../../shared/supervision-contracts' const phases = ['collecting', 'extracting', 'summarizing', 'saving'] as const -export function SupervisionReviewStages({ row }: { row: SupervisionActivity }) { +export function SupervisionReviewStages({ row, stopping }: { row: SupervisionActivity; stopping?: 'paused' | 'cancelled' }) { const { t } = useTranslation('heartbeat') const progress = row.reviewProgress! const current = progress.phase ? phases.indexOf(progress.phase) : -1 @@ -14,7 +14,7 @@ export function SupervisionReviewStages({ row }: { row: SupervisionActivity }) { const state = progress.complete ? (row.supervisionStatus === 'no_change' && index > 0 ? 'skipped' : 'completed') : index === 0 && current !== 0 ? 'completed' : current > index ? 'completed' - : current === index ? (row.supervisionStatus ?? row.status) + : current === index ? (stopping === 'cancelled' ? 'cancelling' : stopping === 'paused' ? 'pausing' : row.supervisionStatus ?? row.status) : current < 0 ? 'unknown' : 'pending' return
  • @@ -24,6 +24,6 @@ export function SupervisionReviewStages({ row }: { row: SupervisionActivity }) {

    {t('activity.coverage', { batches: progress.batches, remaining: progress.remainingSources })}

    {!progress.complete && !progress.phase &&

    {t('activity.stageUnknown')}

    } - {row.supervisionStatus === 'running' &&

    {t('reviewSettings.inFlight', { count: progress.inFlight ?? 0 })}

    } + {row.supervisionStatus === 'running' && !stopping &&

    {t('reviewSettings.inFlight', { count: progress.inFlight ?? 0 })}

    } } diff --git a/src/renderer/src/SupervisorActivity.test.tsx b/src/renderer/src/SupervisorActivity.test.tsx index 53411a09..f7bb0188 100644 --- a/src/renderer/src/SupervisorActivity.test.tsx +++ b/src/renderer/src/SupervisorActivity.test.tsx @@ -1,5 +1,5 @@ import '@testing-library/jest-dom/vitest' -import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { act, cleanup, fireEvent, render as renderComponent, screen, waitFor } from '@testing-library/react' import { afterEach, expect, it, vi } from 'vitest' import type { SupervisionActivity as Activity } from '../../shared/supervision-contracts' import { SupervisorActivity } from './SupervisorActivity' @@ -11,6 +11,10 @@ const row: Activity = { startedAt: '2026-09-23T10:00:00Z', completedAt: null, timeRange: null, error: null, summary: 'Saved heartbeat', resultId: null, heartbeatStatus: 'completed', supervisionStatus: 'running' } +const render = (ui: React.ReactNode) => { + if (window.goodbuddy?.supervision) window.goodbuddy.supervision.execution ??= vi.fn().mockResolvedValue({ active: false }) + return renderComponent(ui) +} afterEach(async () => { cleanup(); vi.useRealTimers(); vi.unstubAllGlobals(); await changeUiLocale('zh-CN') }) it.each(['zh-CN', 'en-US'] as const)('retains operational failure details and recovery in %s', async locale => { @@ -46,7 +50,7 @@ it('shows durable progress, pauses and continues the same run, and expands retai fireEvent.click(await screen.findByRole('button', { name: '暂停回顾' })) fireEvent.click(await screen.findByRole('button', { name: '继续已保存回顾' })) expect(pause).toHaveBeenCalledWith({ runId: 'saved-run' }) - expect(resume).toHaveBeenCalledWith({ runId: 'saved-run' }) + await waitFor(() => expect(resume).toHaveBeenCalledWith({ runId: 'saved-run' })) await waitFor(() => expect(activity).toHaveBeenCalledTimes(3)) fireEvent.click(await screen.findByRole('button', { name: '已保留事实与来源' })) expect(await screen.findByText('Retained fact')).toBeInTheDocument() @@ -54,6 +58,42 @@ it('shows durable progress, pauses and continues the same run, and expands retai expect(screen.getAllByText(/message-id \[0, 18\)/).length).toBeGreaterThan(0) }) +it('allows cancelling a resumed review before the resume request settles', async () => { + vi.useFakeTimers() + const progress = { runId: 'saved-run', phase: 'extracting', batches: 1, characters: 1000, sources: 2, remainingSources: 1, complete: false } + const activity = vi.fn().mockResolvedValue([{ ...row, status: 'paused', supervisionStatus: 'paused', reviewProgress: progress }]) + const execution = vi.fn().mockResolvedValue({ active: false }) + let finishResume!: () => void, finishCancel!: () => void + const resume = vi.fn(() => { + activity.mockResolvedValue([{ ...row, reviewProgress: progress }]) + execution.mockResolvedValue({ active: true, runId: progress.runId }) + return new Promise(resolve => { finishResume = resolve }) + }) + const cancel = vi.fn(() => { + activity.mockResolvedValue([{ ...row, status: 'cancelled', supervisionStatus: 'cancelled', reviewProgress: progress }]) + execution.mockResolvedValue({ active: true, runId: progress.runId, stopping: 'cancelled' }) + return new Promise(resolve => { finishCancel = resolve }) + }) + vi.stubGlobal('goodbuddy', { supervision: { activity, execution, resume, cancel } }) + render() + await act(() => vi.advanceTimersByTimeAsync(0)) + await act(async () => fireEvent.click(screen.getByRole('button', { name: '继续已保存回顾' }))) + await act(() => vi.advanceTimersByTimeAsync(10000)) + expect(resume).toHaveBeenCalledExactlyOnceWith({ runId: progress.runId }) + expect(screen.getByRole('button', { name: '暂停回顾' })).toBeEnabled() + expect(screen.getByRole('button', { name: '取消回顾' })).toBeEnabled() + fireEvent.click(screen.getByRole('button', { name: '取消回顾' })) + expect(cancel).toHaveBeenCalledExactlyOnceWith({ runId: progress.runId }) + await act(async () => finishResume()) + expect(screen.getByRole('status')).toHaveTextContent('正在取消回顾') + expect(screen.getByRole('button', { name: '取消回顾' })).toBeDisabled() + execution.mockResolvedValue({ active: false }) + await act(async () => finishCancel()) + await act(() => vi.advanceTimersByTimeAsync(0)) + expect(screen.queryByRole('status')).not.toBeInTheDocument() + expect(screen.queryByRole('button', { name: '继续已保存回顾' })).not.toBeInTheDocument() +}) + it('shows no-change execution without a new result action', async () => { vi.stubGlobal('goodbuddy', { supervision: { activity: vi.fn().mockResolvedValue([{ ...row, status: 'no_change', heartbeatStatus: 'no_change', supervisionStatus: 'no_change', @@ -65,6 +105,68 @@ it('shows no-change execution without a new result action', async () => { expect(screen.queryByRole('button', { name: '查看回顾' })).not.toBeInTheDocument() }) +it('cancels the automatic review ID and keeps cancelling visible until execution cleanup finishes', async () => { + vi.useFakeTimers() + const progress = { runId: 'review-id', phase: 'extracting', batches: 1, characters: 1000, sources: 2, remainingSources: 1, complete: false } + const activity = vi.fn().mockResolvedValue([{ ...row, reviewProgress: progress }]) + const execution = vi.fn().mockResolvedValue({ active: true, runId: progress.runId }) + let finish!: () => void + const cancel = vi.fn(() => { + activity.mockResolvedValue([{ ...row, status: 'cancelled', supervisionStatus: 'cancelled', error: 'Cancelled', reviewProgress: progress }]) + execution.mockResolvedValue({ active: true, runId: progress.runId, stopping: 'cancelled' }) + return new Promise(resolve => { finish = resolve }) + }) + const pause = vi.fn() + vi.stubGlobal('goodbuddy', { supervision: { activity, execution, cancel, pause } }) + const { container } = render() + await act(() => vi.advanceTimersByTimeAsync(0)) + expect(screen.getByLabelText('监督计划')).toBeEnabled() + expect(screen.queryByText('监督计划')).not.toBeInTheDocument() + expect(screen.getByRole('button', { name: '暂停回顾' })).toBeEnabled() + fireEvent.click(screen.getByRole('button', { name: '取消回顾' })) + expect(cancel).toHaveBeenCalledExactlyOnceWith({ runId: 'review-id' }) + expect(screen.getByRole('status')).toHaveTextContent('正在取消回顾') + expect(screen.getByRole('button', { name: '暂停回顾' })).toBeDisabled() + expect(screen.getByRole('button', { name: '取消回顾' })).toBeDisabled() + await act(() => vi.advanceTimersByTimeAsync(2000)) + expect(screen.getByRole('status')).toHaveTextContent('正在取消回顾') + expect(container.querySelector('[data-phase="extracting"]')).toHaveAttribute('data-state', 'cancelling') + expect(screen.queryByText('已取消')).not.toBeInTheDocument() + expect(screen.queryByRole('button', { name: '继续已保存回顾' })).not.toBeInTheDocument() + execution.mockResolvedValue({ active: false }) + await act(async () => finish()) + await act(() => vi.advanceTimersByTimeAsync(0)) + expect(screen.queryByRole('status')).not.toBeInTheDocument() + expect(screen.getAllByText('已取消')).toHaveLength(2) + expect(screen.queryByRole('button', { name: /取消回顾|暂停回顾|继续已保存回顾/ })).not.toBeInTheDocument() + expect(screen.queryByText('本次运行失败,请查看错误详情后继续。已保存的批次仍保留。')).not.toBeInTheDocument() + expect(pause).not.toHaveBeenCalled() +}) + +it('rediscovers cancellation cleanup on mount and blocks resuming another saved run', async () => { + vi.useFakeTimers() + const progress = { runId: 'review-id', batches: 1, characters: 1000, sources: 2, remainingSources: 1, complete: false } + const activity = vi.fn().mockResolvedValue([ + { ...row, status: 'cancelled', supervisionStatus: 'cancelled', reviewProgress: progress }, + { ...row, id: 'paused', status: 'paused', supervisionStatus: 'paused', reviewProgress: { ...progress, runId: 'paused-review' } } + ]) + const execution = vi.fn().mockResolvedValue({ active: true, runId: progress.runId, stopping: 'cancelled' }) + const resume = vi.fn() + vi.stubGlobal('goodbuddy', { supervision: { activity, execution, resume } }) + render() + await act(() => vi.advanceTimersByTimeAsync(0)) + expect(screen.getByRole('status')).toHaveTextContent('正在取消回顾') + expect(screen.getByRole('button', { name: '继续已保存回顾' })).toBeDisabled() + execution.mockResolvedValue({ active: false }) + await act(() => vi.advanceTimersByTimeAsync(2000)) + expect(screen.queryByRole('status')).not.toBeInTheDocument() + expect(screen.getByRole('button', { name: '继续已保存回顾' })).toBeEnabled() + execution.mockResolvedValue({ active: true, runId: 'new-automatic' }) + await act(async () => fireEvent.click(screen.getByRole('button', { name: '继续已保存回顾' }))) + expect(resume).not.toHaveBeenCalled() + expect(screen.getByRole('alert')).toHaveTextContent('新回顾正在整理') +}) + it('polls sequentially only while active and ignores late responses after leaving', async () => { vi.useFakeTimers() let resolve!: (value: Activity[]) => void diff --git a/src/renderer/src/SupervisorActivity.tsx b/src/renderer/src/SupervisorActivity.tsx index 8b10c21e..53833a52 100644 --- a/src/renderer/src/SupervisorActivity.tsx +++ b/src/renderer/src/SupervisorActivity.tsx @@ -3,6 +3,7 @@ import { History, RefreshCw } from 'lucide-react' import { useTranslation } from 'react-i18next' import type { AssistantHeartbeatConfig, AssistantProject } from '../../shared/assistant-contracts' import type { SupervisionActivity } from '../../shared/supervision-contracts' +import type { SupervisionReviewExecution } from '../../shared/supervision-review-contracts' import { EmptyState, ScopeBadge } from './WorkspacePrimitives' import { SupervisionBatchDetails } from './SupervisionBatchDetails' import { SupervisionReviewStages } from './SupervisionReviewStages' @@ -24,6 +25,9 @@ export function SupervisorActivity({ active, projects, onOpenResult, configId, c const [error, setError] = useState() const [expanded, setExpanded] = useState() const [pending, setPending] = useState() + const [execution, setExecution] = useState({ active: false }) + const [stopping, setStopping] = useState<{ runId: string; kind: 'paused' | 'cancelled' }>() + const actionPending = useRef(false) const filterRef = useRef(null) const [appliedConfigId, setAppliedConfigId] = useState(configId) if (appliedConfigId !== configId) { @@ -43,12 +47,16 @@ export function SupervisorActivity({ active, projects, onOpenResult, configId, c setReading(true) try { if (!api?.activity) throw new Error(t('supervisor.unavailable')) - const result = await api.activity({ limit: 50, offset, ...(configId ? { configId } : {}) }) + const [result, current] = await Promise.all([ + api.activity({ limit: 50, offset, ...(configId ? { configId } : {}) }), + api.execution() + ]) if (disposed) return setRows(result) + setExecution(current) setError(undefined) // One request at a time, only while this page is active. - timer = setTimeout(() => void load(), result.some((row) => row.status === 'running') ? 2000 : 10000) + timer = setTimeout(() => void load(), current.active || result.some((row) => row.status === 'running') ? 2000 : 10000) } catch (reason) { if (!disposed) setError(reason instanceof Error ? reason.message : t('common.operationFailed')) } finally { @@ -68,14 +76,46 @@ export function SupervisorActivity({ active, projects, onOpenResult, configId, c setRefresh((value) => value + 1) } const date = (value: string) => new Date(value).toLocaleString(i18n.resolvedLanguage) + const control = async (row: SupervisionActivity, action: 'pause' | 'cancel' | 'resume') => { + if (actionPending.current || !row.reviewProgress) return + const runId = row.reviewProgress.runId + actionPending.current = true + setPending(row.id) + setError(undefined) + if (action !== 'resume') setStopping({ runId, kind: action === 'cancel' ? 'cancelled' : 'paused' }) + try { + if (action === 'resume') { + const current = await api.execution() + setExecution(current) + if (current.active) { + setError(t('supervisor.runningHint')) + return + } + // Resume resolves only when execution stops; keep its pause/cancel controls available. + void api.resume({ runId }).then( + () => setRefresh(value => value + 1), + reason => setError(reason instanceof Error ? reason.message : String(reason)) + ) + return + } + await api[action]({ runId }) + } catch (reason) { + setError(reason instanceof Error ? reason.message : String(reason)) + } finally { + actionPending.current = false + setPending(undefined) + setStopping(undefined) + setRefresh(value => value + 1) + } + } const planScope = configs.find(config => config.id === configId)?.scope return
    - {onPlanChange &&
    } {loading ? } variant="loading" title={t('activity.loading')} description={t('activity.loadingHint')} /> : !error && rows.length === 0 ? } title={t(configId ? 'activity.filteredEmpty' : 'activity.empty')} description={t(configId ? 'activity.filteredEmptyHint' : 'activity.emptyHint')} /> - :
      {rows.map((row) =>
    1. + :
        {rows.map((row) => { + const runId = row.reviewProgress?.runId + const stoppingKind = runId && stopping?.runId === runId ? stopping.kind + : runId && execution.active && execution.runId === runId ? execution.stopping : undefined + return
      1. {t(`activity.kind.${row.kind}`)} - {t(`activity.status.${row.status}`)} + {t(stoppingKind === 'cancelled' ? 'reviewSettings.cancelling' : stoppingKind === 'paused' ? 'reviewSettings.pausing' : `activity.status.${row.status}`)}

        {!row.scope ? t('activity.unknownScope') : row.scope.kind === 'global' ? t('center.scope.global') : t('supervisor.projectScope', { names: row.scope.projectIds.map((id) => projects.find((project) => project.id === id)?.name ?? t('settings.scope.unavailableProject')).join(', ') })} · {t(`activity.triggers.${row.trigger}`)}

        @@ -110,19 +154,16 @@ export function SupervisorActivity({ active, projects, onOpenResult, configId, c {row.reviewProgress && <> {row.reviewProgress.restartRequired &&

        {t('reviewSettings.restartRequired')}

        }
        - {row.supervisionStatus === 'running' && } - {!row.reviewProgress.complete && !row.reviewProgress.restartRequired && (row.supervisionStatus === 'paused' || row.supervisionStatus === 'failed') && } + {(row.supervisionStatus === 'running' || stoppingKind) && <> + + + } + {!stoppingKind && !row.reviewProgress.complete && !row.reviewProgress.restartRequired && (row.supervisionStatus === 'paused' || row.supervisionStatus === 'failed') && }
        } - {row.reviewProgress && } - {row.error &&
        + {row.reviewProgress && } + {row.error && row.supervisionStatus !== 'cancelled' && !stoppingKind &&

        {t(row.status === 'paused' ? 'reviewSettings.pausedHint' : /persistedId|candidateRef|entity identity/.test(row.error) ? 'reviewSettings.identityError' : 'reviewSettings.runError')}

        {t('reviewSettings.diagnostics')} @@ -149,7 +190,7 @@ export function SupervisorActivity({ active, projects, onOpenResult, configId, c
        }
        -
      2. )}
      } +
    2. })}
    }