# 技术方案:Agent 任务宕机恢复(断点续跑) ## 1. 背景与目标 当前 AgentLoop 执行完全依赖内存状态。后端宕机或重启时,正在运行的任务直接中断,最终被 10 分钟超时清扫标记为 FAILED,所有未完成的工作丢失。 **目标**:后端重启后自动检测并恢复被中断的 Agent 任务,从 DB 持久化的最后一条完整消息处继续执行。允许丢失最多一轮工具调用(即宕机时正在执行的那一轮)。 ## 2. 崩溃时序分析 AgentLoop 每轮循环的持久化时序: ``` LLM 流式输出 → 拼装完整内容 → 持久化 assistant message(含 tool_calls) → 执行工具 → 持久化 tool result message → 下一轮 ``` 关键:assistant message(含 tool_calls)在工具执行**之前**持久化(`AgentLoop.java:167`),tool result 在工具执行**之后**持久化(`AgentLoop.java:234-236`)。 因此崩溃只可能产生三种 DB 尾部状态: | 尾部消息 | 含义 | 恢复策略 | |---------|------|---------| | `tool result` | 上一轮完整结束,LLM 调用中途崩溃 | 直接续跑,LLM 调用无副作用可安全重试 | | `assistant + tool_calls`(无对应 result) | 工具执行中途崩溃 | 丢弃这条 assistant 及其后续 partial results,LLM 自行决定下一步 | | `user` | 用户消息已存但 Agent 还没开始处理 | 直接续跑,等同于正常执行 | ## 3. 整体设计 ### 3.1 新增 Phase:RESUMING 在 `Session.phase` 枚举中新增 `RESUMING`,用于标识正在恢复中的任务。与 `RUNNING` 的区别: - `RUNNING`:正常执行中,由用户发起的 `send_message` 触发 - `RESUMING`:宕机恢复中,由启动钩子自动触发 `RESUMING` 在 AgentLoop 实际开始执行后转为 `RUNNING`。 ### 3.2 恢复流程 ``` Spring Boot 启动 → CrashRecoveryRunner (ApplicationRunner) → 扫描 phase=RUNNING 的 session(上次宕机遗留) → 对每个 session: 1. 清理尾部不完整消息 2. 将 phase 改为 RESUMING 3. 提交到 agentExecutor 线程池 4. 线程内:phase → RUNNING,重建上下文,启动 AgentLoop 5. AgentLoop 结束后正常更新 phase(COMPLETED/FAILED/CANCELLED) ``` ## 4. 详细改动点 ### 4.1 CrashRecoveryRunner — 启动钩子 **新增文件**:`backend/src/main/java/cn/etarch/mao/harness/core/CrashRecoveryRunner.java` **职责**:Spring Boot 启时扫描遗留的 RUNNING session,逐个提交恢复任务。 ```java @Component @Slf4j @RequiredArgsConstructor public class CrashRecoveryRunner implements ApplicationRunner { private final SessionMapper sessionMapper; private final MessageMapper messageMapper; private final SessionService sessionService; private final HarnessService harnessService; private final AgentLoop agentLoop; private final StreamingWsRegistry registry; private final ExecutorService agentExecutor; // 注入 StreamingWsHandler 中同名 bean @Override public void run(ApplicationArguments args) { List stale = sessionMapper.selectList( new QueryWrapper().eq("phase", "RUNNING")); if (stale.isEmpty()) return; log.warn("Found {} sessions stuck in RUNNING after restart, initiating recovery", stale.size()); for (Session session : stale) { agentExecutor.submit(() -> recoverSession(session)); } } private void recoverSession(Session session) { Long sessionId = session.getId(); try { // 1. 清理尾部不完整消息 int deleted = cleanupIncompleteTail(sessionId); if (deleted > 0) { log.info("Session {}: cleaned up {} incomplete tail messages", sessionId, deleted); } // 2. 标记为 RESUMING sessionService.updatePhase(sessionId, "RESUMING"); // 3. 通知已连接的客户端(如果有) notifyClient(session.getUserId(), sessionId, "RESUMING"); // 4. 注册取消标志 AtomicBoolean cancelFlag = agentLoop.registerCancelFlag(sessionId); // 5. 创建 listener(无 userId 时不发 WS 事件,仅持久化) AgentEventListener listener = new RecoveryEventListener(registry, sessionService, sessionId, session.getUserId()); // 6. 执行 — 复用 HarnessService.execute(),它会从 DB 重建上下文 // 传 null userContent,因为用户消息已经在 DB 中 sessionService.updatePhase(sessionId, "RUNNING"); notifyClient(session.getUserId(), sessionId, "RUNNING"); harnessService.execute(sessionId, null, listener, cancelFlag); // 7. 正常结束 if (cancelFlag.get()) { sessionService.updatePhase(sessionId, "CANCELLED"); } else { sessionService.updatePhase(sessionId, "COMPLETED"); } } catch (Exception e) { log.error("Recovery failed for session {}", sessionId, e); sessionService.updatePhase(sessionId, "FAILED"); } finally { agentLoop.removeCancelFlag(sessionId); } } } ``` **需要暴露为 Bean 的依赖**: - `agentExecutor` 线程池当前在 `StreamingWsHandler` 构造函数中创建为局部变量。需要将其提取为独立的 `@Bean`,使 `CrashRecoveryRunner` 和 `StreamingWsHandler` 共享同一个线程池。 ### 4.2 尾部不完整消息清理 **改动文件**:`backend/src/main/java/cn/etarch/mao/session/service/SessionService.java` 新增方法 `cleanupIncompleteTail(sessionId)`,逻辑如下: ```java /** * 清理 session 尾部的不完整消息序列。 * 当 assistant message 携带 tool_calls 但缺少对应的 tool result 时, * 说明上次执行在工具执行阶段崩溃。需要丢弃这整轮不完整的消息。 * * @return 删除的消息数量 */ public int cleanupIncompleteTail(Long sessionId) { List messages = getMessages(sessionId); if (messages.isEmpty()) return 0; // 从尾部向前扫描,找到第一个不完整的 assistant+tool_calls int cutIndex = -1; for (int i = messages.size() - 1; i >= 0; i--) { Message msg = messages.get(i); if ("ASSISTANT".equals(msg.getRole()) && msg.getToolCalls() != null && !msg.getToolCalls().isEmpty()) { // 找到一个有 tool_calls 的 assistant message // 检查它后面是否有完整的 tool result 覆盖所有 tool_calls List expectedIds = extractToolCallIds(msg.getToolCalls()); Set foundIds = new HashSet<>(); for (int j = i + 1; j < messages.size(); j++) { Message后续 = messages.get(j); if ("TOOL".equals(后续.getRole()) && 后续.getToolCallId() != null) { foundIds.add(后续.getToolCallId()); } else if ("ASSISTANT".equals(后续.getRole())) { // 遇到下一个 assistant message,停止 break; } } if (!foundIds.containsAll(expectedIds)) { // 不完整,从这条 assistant 开始全部删除 cutIndex = i; break; } } } if (cutIndex < 0) return 0; // 删除从 cutIndex 到末尾的所有消息 List toDelete = messages.subList(cutIndex, messages.size()); for (Message msg : toDelete) { messageMapper.deleteById(msg.getId()); } return toDelete.size(); } ``` **关键细节**: - 并行工具调用场景:一个 assistant message 可能带多个 tool_calls(如 `tc_A, tc_B, tc_C`)。如果只有 `result_A` 和 `result_B` 落盘而 `result_C` 丢失,整条 assistant + 已有的 partial results 一并删除。丢失范围仍然是 1 轮。 - 反向扫描:从尾部向前扫描,找到第一个不完整的 assistant 就停止。更早的消息(已经是完整轮次)不受影响。 - 不需要处理 `user` 消息尾部的情况 — user 消息是 `handleSendMessage` 在提交 AgentLoop 之前就持久化的(`StreamingWsHandler.java:261`),不存在不完整的问题。 ### 4.3 Session 实体与 Phase 管理 **改动文件**:`backend/src/main/java/cn/etarch/mao/session/entity/Session.java` 更新 `phase` 字段注释: ```java /** Task phase: IDLE|RUNNING|RESUMING|WAITING_USER|WAITING_APPROVAL|COMPLETED|FAILED|CANCELLED */ private String phase; ``` **改动文件**:`backend/src/main/java/cn/etarch/mao/session/service/SessionService.java` `updatePhase()` 方法需要处理 `RESUMING` phase。`RESUMING` 不应重置 `startedAt`(因为恢复的耗时应计入总 elapsed),但应更新 `lastActivityAt`: ```java public void updatePhase(Long sessionId, String phase) { Session session = getSession(sessionId); session.setPhase(phase); session.setLastActivityAt(LocalDateTime.now()); if ("RUNNING".equals(phase)) { // 如果是从 RESUMING 转来,不重置 startedAt(保持原始开始时间) if (session.getStartedAt() == null) { session.setStartedAt(LocalDateTime.now()); } } else if (isTerminalPhase(phase)) { if (session.getStartedAt() != null) { long elapsed = Duration.between(session.getStartedAt(), LocalDateTime.now()).toMillis(); session.setElapsedMs((session.getElapsedMs() != null ? session.getElapsedMs() : 0) + elapsed); session.setStartedAt(null); } } sessionMapper.updateById(session); } private boolean isTerminalPhase(String phase) { return "IDLE".equals(phase) || "COMPLETED".equals(phase) || "FAILED".equals(phase) || "CANCELLED".equals(phase); } ``` ### 4.4 清扫逻辑调整 **改动文件**:`backend/src/main/java/cn/etarch/mao/session/service/SessionService.java` `sweepStaleRunningSessions()` 需要同时处理 `RESUMING` 状态的 session。如果一个 session 在 RESUMING 状态停留超过阈值(说明恢复本身也失败了),也应清扫为 FAILED: ```java @Scheduled(fixedRate = 60_000) public void sweepStaleRunningSessions() { LocalDateTime threshold = LocalDateTime.now().minusMinutes(STALE_MINUTES); UpdateWrapper uw = new UpdateWrapper<>(); uw.in("phase", "RUNNING", "RESUMING") .and(w -> w.lt("last_activity_at", threshold) .or() .isNull("last_activity_at")); Session update = new Session(); update.setPhase("FAILED"); int affected = sessionMapper.update(update, uw); if (affected > 0) { log.warn("Swept {} stale sessions to FAILED", affected); } } ``` ### 4.5 Dashboard 查询适配 **改动文件**:`backend/src/main/java/cn/etarch/mao/session/service/SessionService.java` `listSessionsForDashboard()` 中,`RESUMING` 应归入 running 分组: ```java .in("phase", Arrays.asList("RUNNING", "RESUMING", "WAITING_USER", "WAITING_APPROVAL")) ``` 排除分组同步调整: ```java .notIn("phase", Arrays.asList("RUNNING", "RESUMING", "WAITING_USER", "WAITING_APPROVAL")) ``` ### 4.6 线程池 Bean 提取 **改动文件**:`backend/src/main/java/cn/etarch/mao/session/ws/StreamingWsHandler.java` 当前 `agentExecutor` 在 `StreamingWsHandler` 构造函数中创建为私有字段。需要将其提取为独立的 `@Bean`。 **新增文件或改动**:`backend/src/main/java/cn/etarch/mao/config/AgentExecutorConfig.java` ```java @Configuration public class AgentExecutorConfig { @Bean("agentExecutor") public ExecutorService agentExecutor( @Value("${app.harness.agent-thread-pool-size:20}") int poolSize, @Value("${app.harness.agent-thread-pool-max:100}") int maxPoolSize, @Value("${app.harness.agent-thread-pool-queue:200}") int queueCapacity) { ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); executor.setCorePoolSize(poolSize); executor.setMaxPoolSize(maxPoolSize); executor.setQueueCapacity(queueCapacity); executor.setThreadNamePrefix("ws-agent-"); executor.initialize(); return executor.getThreadPoolExecutor(); } } ``` `StreamingWsHandler` 改为通过 `@Qualifier("agentExecutor")` 注入,不再自行创建。 ### 4.7 HarnessService.execute() 适配 **改动文件**:`backend/src/main/java/cn/etarch/mao/harness/core/HarnessService.java` 当前 `execute()` 的 `userContent` 参数在恢复场景下为 null(用户消息已在 DB 中)。需要确认 `buildContext()` 能正确处理这种情况。 查看当前代码(`HarnessService.java:82-114`),`execute()` 中 `userContent` 参数实际未被使用 — 用户消息由 `StreamingWsHandler` 在调用前通过 `sessionService.saveMessage()` 持久化,`buildContext()` 从 DB 加载全部消息。因此 **无需改动**,传 null 即可。 但 `executeFromEvent()` 方法中,`eventContentStore` 的 eventId 在恢复场景下不存在。恢复流程应直接调用 `execute(sessionId, null, listener, cancelFlag)`,绕过 `executeFromEvent()`。 ### 4.8 WsStreamingEventListener 的 userId 可空处理 **改动文件**:`backend/src/main/java/cn/etarch/mao/session/ws/WsStreamingEventListener.java` 恢复场景下,客户端可能尚未重连,`userId` 可能没有对应的 WS 连接。当前 `send()` 方法调用 `registry.send(userId, ...)` — 如果 userId 没有注册的 WS session,该方法应静默忽略(当前实现已是如此,无需改动)。 但需要确认:恢复任务期间如果客户端重连并 subscribe,`handleSubscribe()` 中的 `session_snapshot` 逻辑需要识别 `RESUMING` 状态。 **改动文件**:`backend/src/main/java/cn/etarch/mao/session/ws/StreamingWsHandler.java` `handleSubscribe()` 方法: ```java if ("RUNNING".equals(s.getPhase()) || "RESUMING".equals(s.getPhase())) { registry.send(userId, WsEvent.of("session_snapshot", sessionId, Map.of( "phase", s.getPhase() ))); } ``` 同时,恢复任务中的 LOCAL 模式需要注册 `localToolSessionRegistry`: ```java if ("LOCAL".equals(s.getExecutionMode()) && ("RUNNING".equals(s.getPhase()) || "RESUMING".equals(s.getPhase()))) { localToolSessionRegistry.setUserForSession(sessionId, userId); } ``` ### 4.9 客户端适配 **改动文件**:`desktop/src/stores/session.ts` `TaskPhase` 类型需要新增 `RESUMING`: ```typescript export type TaskPhase = 'IDLE' | 'RUNNING' | 'RESUMING' | 'WAITING_USER' | 'WAITING_APPROVAL' | 'COMPLETED' | 'FAILED' | 'CANCELLED' | 'CANCELLING' ``` **改动文件**:`desktop/src/composables/useChat.ts` `restoreSession()` 中的 phase 判断需要包含 `RESUMING`: ```typescript if (phase === 'RUNNING' || phase === 'RESUMING' || phase === 'WAITING_APPROVAL' || phase === 'CANCELLING') { sending.value = true // ... } ``` **改动文件**:`desktop/src/composables/useStreamWS.ts` `routeEvent()` 中 `session_status` 事件的处理逻辑中,`RESUMING` 不应视为终止态(不应 resolve pendingCallback)。当前逻辑是 `if (data.phase !== 'RUNNING')` 就 resolve,需要调整: ```typescript case 'session_status': if (sessionId) { sessionStore.updateSessionPhase(sessionId, data.phase as TaskPhase) // 只有真正的终态才 resolve pending callback const terminalPhases = ['COMPLETED', 'FAILED', 'CANCELLED', 'IDLE'] if (terminalPhases.includes(data.phase)) { sessionStore.setStreaming(sessionId, false) const cb = pendingCallbacks.get(sessionId) if (cb) { pendingCallbacks.delete(sessionId) cb.resolve?.() } } } break ``` ### 4.10 AgentLoop 的 finally 块与恢复 **改动文件**:`backend/src/main/java/cn/etarch/mao/harness/core/AgentLoop.java` `execute()` 的 `finally` 块(`AgentLoop.java:212-219`)会清理 cancelFlag 和 shell session。恢复场景下这是正确的行为 — 恢复完成后也应清理这些资源。**无需改动**。 ## 5. 容易被忽略的点 ### 5.1 恢复任务的 LOCAL 模式 LOCAL 模式下工具通过 WS 委托给 Electron 客户端执行。恢复时客户端可能还没重连,`localToolSessionRegistry.isConnected(sessionId)` 会返回 false。 **影响**:恢复的 AgentLoop 在执行第一个工具时,`LocalToolExecutor` 会检测到未连接并返回错误 JSON。AgentLoop 会将此作为 tool error 返回给 LLM,LLM 会看到工具执行失败。 **处理**:这是可接受的行为。LLM 会自行决定是否重试或换策略。当客户端重连并 subscribe 后,`handleSubscribe()` 会重新注册 `localToolSessionRegistry`,后续工具调用即可正常执行。 但有一个时序问题:如果恢复任务在客户端重连之前就完成了所有轮次(比如 LLM 决定不再调用工具直接给出结论),任务会正常结束。这是正确的行为。 ### 5.2 恢复任务的事件推送 恢复期间产生的流式事件(content_delta、tool_call_start 等)会通过 `WsStreamingEventListener.send()` 推送给 userId。如果客户端尚未重连,这些事件会丢失(`registry.send()` 找不到 WS session 会静默忽略)。 **影响**:客户端重连后通过 `restoreSession()` → `fetchMessages()` 从 DB 拉取消息列表,可以看到恢复期间产生的最终消息。但中间的流式动画效果丢失。 **处理**:可接受。用户最终能看到完整的消息内容,只是缺少了打字机效果。 ### 5.3 恢复期间的用户新消息 恢复任务执行期间,用户可能通过客户端发送新消息。当前 `handleSendMessage()` 会检查 `if ("RUNNING".equals(session.getPhase()))` 并拒绝。 **影响**:恢复中的 session 无法接收新消息,用户会看到 "Session is already running" 错误。 **处理**:`handleSendMessage()` 的检查需要同时包含 `RESUMING`: ```java if ("RUNNING".equals(session.getPhase()) || "RESUMING".equals(session.getPhase())) { registry.send(userId, WsEvent.of("error", sessionId, Map.of("message", "Session is already running"))); return; } ``` ### 5.4 恢复任务的取消 用户可能想取消一个恢复中的任务。当前 `handleCancel()` 只检查 `cancelFlags` 中是否有该 sessionId 的 flag。恢复任务通过 `agentLoop.registerCancelFlag(sessionId)` 注册了 flag,因此取消逻辑天然支持。 **无需改动**。但客户端 UI 需要在 `RESUMING` 状态下也展示取消按钮(与 `RUNNING` 一致)。 ### 5.5 多实例部署的并发恢复 如果后端部署了多个实例(如 2 个 Pod),重启时所有实例都会执行 `CrashRecoveryRunner`,可能同时恢复同一个 session。 **处理**:当前项目是单实例架构(AGENTS.md 未提及多实例部署),暂不考虑。如果未来需要多实例,可以利用 DB 的乐观锁(version 字段)防止并发恢复。 ### 5.6 workingSummary 丢失的影响 Loop 级 `workingSummary`(`AgentExecutionContext.java:46`)纯内存,不持久化。如果崩溃前 AgentLoop 已执行多轮并触发了 loop compaction,恢复后 workingSummary 为空。 **影响**:恢复后的上下文从 DB 全量消息重建。如果消息量很大,可能超过 context window。但 `CompactionService` 会在 `buildContext()` 阶段自动触发 session 级压缩(`HarnessService.java:195-208`),将早期历史压缩为摘要。 **处理**:无需特殊处理。Session 级压缩摘要已持久化到 `session_compaction` 表,不受崩溃影响。Loop 级压缩摘要丢失是可接受的 — 它只是同一请求内的优化,不影响正确性。 ### 5.7 BackgroundTaskManager 状态丢失 `BackgroundTaskManager` 纯内存(`ConcurrentHashMap>`)。崩溃后所有已提交的后台任务丢失。 **影响**:如果 Agent 在崩溃前通过 shell 工具提交了后台命令(如 `npm install &`),恢复后 AgentLoop 不会收到这些命令的结果。Agent 在下一轮循环中看不到 `` 注入。 **处理**:可接受。LLM 在下一轮会看到之前的 shell 调用和结果(都在 DB 消息中),如果需要会重新执行。不引入后台任务持久化的复杂度。 ### 5.8 Shell 会话句柄丢失 `ShellSessionManager` 管理的 bash 进程句柄(`ConcurrentHashMap`)在崩溃后丢失。对应的 OS 进程可能成为孤儿进程。 **影响**: 1. 孤儿进程占用系统资源(CPU/内存/文件句柄) 2. 恢复后创建新的 shell session,之前的 shell 环境(cd 目录、环境变量、后台进程)丢失 **处理**: - 孤儿进程:不在本次方案范围内,可通过 OS 层面的进程管理(systemd、容器重启)清理 - Shell 环境丢失:可接受。Agent 会在新的 shell session 中重新建立工作环境 ### 5.9 恢复失败的幂等性 如果恢复本身也失败了(比如 LLM 服务不可用、DB 连接断开),`CrashRecoveryRunner` 的 catch 块会将 session 标记为 FAILED。 **风险**:如果后端反复重启(如 crash loop),每次都会尝试恢复、失败、标记 FAILED。但下一次重启时 session 已经是 FAILED 状态,不会再被选中恢复。这是正确的行为。 ### 5.10 eventContentStore 与恢复 `HarnessService` 的 `eventContentStore`(Caffeine cache)在崩溃后清空。恢复流程不走 `executeFromEvent()`,直接调用 `execute(sessionId, null, listener, cancelFlag)`,因此不受影响。 ### 5.11 Session.startedAt 的时间累计 恢复任务的 `startedAt` 不应重置。如果原始任务在 14:00 开始,14:05 崩溃,14:06 恢复,14:10 完成,总 elapsed 应为 9 分钟(14:00→14:05 + 14:06→14:10),而不是 4 分钟。 **处理**:在 `updatePhase()` 中,`RUNNING` 仅在 `startedAt == null` 时设置(见 4.3 节)。恢复时 session 的 `startedAt` 仍然保留着原始值,不会被覆盖。`COMPLETED` 时从 `startedAt` 到 `now()` 计算 elapsed 并累加,自然包含了中间的宕机时间。 但这里有一个问题:如果清扫逻辑在 10 分钟后将 session 标记为 FAILED,`updatePhase("FAILED")` 会清空 `startedAt` 并累加 elapsed。恢复时 `startedAt` 已经是 null 了。 **修正**:恢复流程中,在调用 `updatePhase(sessionId, "RUNNING")` 之前,检查 `startedAt` 是否为 null。如果为 null(说明被清扫过),重新设置 `startedAt = now()`。这会导致丢失清扫前的 elapsed 时间,但这是可接受的 — 被清扫说明已经过了 10 分钟,恢复后的任务不应承担清扫前的耗时。 实际上更准确的做法是:恢复时直接设置 `startedAt = now()`,因为恢复后的行为本质上是一个新的执行周期。elapsedMs 字段已经累计了之前的部分。 ## 6. 不改动的部分 以下组件在本方案中无需修改: | 组件 | 原因 | |------|------| | `AgentLoop.execute()` | 恢复走正常执行路径,无需特殊逻辑 | | `PromptEngine` | 从 context 构建 prompt,context 由 buildContext() 重建 | | `ContextManager` / `CompactionService` | Session 级压缩从 DB 加载,不受崩溃影响 | | `ToolDispatcher` / `ToolRegistry` | 工具纯内存注册,重启后自动重建 | | `MessagePersistenceCallback` | 恢复执行的持久化逻辑与正常执行一致 | | `BackgroundTaskManager` | 不引入持久化,接受状态丢失 | | `ShellSessionManager` | 不引入进程恢复,接受环境重建 | | `LocalToolExecutor` | 未连接时返回错误的现有逻辑已足够 | | `OpenAiLlmAdapter` | LLM 调用无状态,恢复后正常调用 | ## 7. 涉及文件清单 | 文件 | 改动类型 | 说明 | |------|---------|------| | `harness/core/CrashRecoveryRunner.java` | **新增** | 启动钩子,扫描并恢复遗留 session | | `config/AgentExecutorConfig.java` | **新增** | 提取 agentExecutor 线程池为共享 Bean | | `session/service/SessionService.java` | 修改 | 新增 cleanupIncompleteTail()、调整 updatePhase()、sweepStaleRunningSessions()、listSessionsForDashboard() | | `session/entity/Session.java` | 修改 | 更新 phase 注释 | | `session/ws/StreamingWsHandler.java` | 修改 | agentExecutor 改为注入、handleSubscribe 适配 RESUMING、handleSendMessage 适配 RESUMING | | `session/ws/WsStreamingEventListener.java` | 无改动 | send() 已能容忍 userId 无 WS 连接 | | `desktop/src/stores/session.ts` | 修改 | TaskPhase 类型新增 RESUMING | | `desktop/src/composables/useChat.ts` | 修改 | restoreSession() 的 phase 判断 | | `desktop/src/composables/useStreamWS.ts` | 修改 | routeEvent() 的终态判断逻辑 | ## 8. Flyway 迁移 无需数据库迁移。`phase` 字段是 VARCHAR 类型,新增 `RESUMING` 值不需要 DDL 变更。 ## 9. 测试要点 1. **正常恢复**:手动将某个 session 的 phase 更新为 RUNNING,重启后端,验证 session 被恢复并正常执行 2. **尾部清理 — 单工具**:在 DB 中手动构造一条 assistant+tool_calls 消息但不写 tool result,重启后验证该消息被清理 3. **尾部清理 — 并行工具**:构造 assistant+3 个 tool_calls,只写 2 个 tool result,重启后验证整轮被清理 4. **尾部清理 — 完整消息不误删**:正常完成的 session 不应被清理任何消息 5. **RESUMING → RUNNING 转换**:验证恢复过程中 phase 正确流转 6. **客户端重连**:恢复期间客户端重连,验证能收到 session_snapshot 并正确展示状态 7. **恢复期间取消**:恢复中发送 cancel,验证任务被正确取消 8. **清扫逻辑**:验证 RESUMING 状态的 session 也能被超时清扫 9. **LOCAL 模式恢复**:验证 LOCAL 模式下客户端未连接时工具调用返回错误、LLM 自行处理