Ctrl K
ComunidadGuías…

docs/plan/2026-06-03-tech-crash-recovery.md

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
# 技术方案: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<Session> stale = sessionMapper.selectList(
            new QueryWrapper<Session>().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<Message> 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<String> expectedIds = extractToolCallIds(msg.getToolCalls());
            Set<String> 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<Message> 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<Session> 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<String, Future<String>>`)。崩溃后所有已提交的后台任务丢失。

**影响**:如果 Agent 在崩溃前通过 shell 工具提交了后台命令(如 `npm install &`),恢复后 AgentLoop 不会收到这些命令的结果。Agent 在下一轮循环中看不到 `<background-task-results>` 注入。

**处理**:可接受。LLM 在下一轮会看到之前的 shell 调用和结果(都在 DB 消息中),如果需要会重新执行。不引入后台任务持久化的复杂度。

### 5.8 Shell 会话句柄丢失

`ShellSessionManager` 管理的 bash 进程句柄(`ConcurrentHashMap<String, ShellSession>`)在崩溃后丢失。对应的 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 自行处理