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. })}
    }