docs/plan/2026-08-18-execution-retry-design.md
Rama main · réplica de lectura del repositorio original.
# 技术方案:执行异常重试(宕机恢复语义)
## 1. 需求背景
会话任务在执行过程中可能因各种原因中断,例如:
- LLM 调用返回 400/500 等 HTTP 错误
- 工具执行异常
- 网络超时
- 技能同步失败
- 线程池拒绝等后端基础设施问题
当前客户端会在异常发生后通过 `ExecutionErrorBanner` 展示错误提示框,但仅在特定错误("模型流式响应已中断")时提供"继续"按钮,该按钮的逻辑是发送一条新的 user message "继续"给 Agent,不属于真正的宕机恢复。
**目标**:在所有执行异常场景下,统一提供一个"重试"按钮,点击后以宕机恢复语义(不插入新 user message,清理残留尾巴消息,从已有会话历史处重新执行)自动恢复任务。
## 2. 需求描述
### 2.1 功能要求
- 会话执行异常时,`ExecutionErrorBanner` 统一展示"重试"按钮
- 点击"重试"后,后端以宕机恢复语义重新执行:清理未完成的 assistant 尾巴消息 → 基于已有会话历史调用 `harnessService.execute()` 续跑
- 覆盖主会话、边路任务(SideTask)、子代理(Subagent)三个面板
- 重试完成后,前端恢复正常交互状态
### 2.2 不做的
- 不支持自动触发重试(仅手动点击)
- 不保留原有的"继续"按钮逻辑(发"继续"消息),统一替换为宕机恢复语义的重试
- 不修改子代理/边路任务的已有只读约束(子代理仍不可单独追问,但可重试)
- 不修改重试时的 Todo 清空逻辑(保留原有 Todo 列表)
## 3. 决策树
| 决策项 | 结论 |
|-------|------|
| 重试覆盖范围 | 全部异常(LLM 错误、工具错误、超时等) |
| 重试前是否清理尾巴消息 | 清理(调用 `cleanupIncompleteTail`) |
| 后端实现方案 | 新增 WS 消息类型 `retry_execution` |
| 重试时是否清除 Todo | 不清除 |
| 覆盖面板 | 主会话 + 边路任务 + 子代理 |
| 触发方式 | 用户手动点击"重试"按钮 |
| 与现有"继续"按钮关系 | 统一替换为"重试",去掉"继续"逻辑 |
| 重试可点击条件 | 仅当该会话不在运行中 |
| 点击后 UI 反馈 | 关闭错误提示框,直接恢复 |
## 4. 技术选型
### 4.1 通信协议
新增 WebSocket 消息类型 `retry_execution`,区别于已有的 `send_message`。
**理由**:
- 语义清晰:明确表示"重试上一次执行",而非"发送新消息"
- 后端处理路径独立:可复用 `CrashRecoveryRunner.recoverSession` 核心逻辑
- 避免与 `send_message` 中 `replaceExecution` 等复杂参数耦合
### 4.2 后端复用
重试逻辑复用 `CrashRecoveryRunner` 的核心步骤:
1. `cleanupIncompleteTail` — 清理未完成的尾巴消息
2. `updatePhase(RESUMING)` — 状态置为恢复中
3. `harnessService.execute()` — 基于已有会话历史重新执行(不插入新 user message)
### 4.3 前端复用
- 复用现有的 `ExecutionErrorBanner` 组件,改造其按钮逻辑
- 复用 `sessionStore` 中的 `executionError` 状态管理
- 复用 `useStreamWS` 中的 WebSocket 通信基础设施
## 5. 实现步骤
### 5.1 后端:新增 `retry_execution` WS 消息处理
#### 5.1.1 StreamingWsHandler 新增消息类型
文件:`backend-ts/src/session/ws/streaming-ws-handler.ts`
```typescript
// 在 handleTextMessage 的 switch 中新增分支
case 'retry_execution': await this.handleRetryExecution(userId, root); break;
```
#### 5.1.2 实现 `handleRetryExecution`
伪代码逻辑:
```typescript
private async handleRetryExecution(userId: number, root: Record<string, unknown>): Promise<void> {
const sessionId = this.getLong(root, 'sessionId');
if (sessionId == null) return;
const session = await this.requireOwnedSession(userId, sessionId);
if (!session) return;
// 检查会话是否已结束(FAILED)且没有正在运行的其他执行
if (!this.isTerminalPhase(session.phase) || this.executionClaims.has(sessionId)) {
this.deps.registry.send(userId, wsEvent('error', sessionId, {
message: '该会话仍在运行中,无法重试',
}));
return;
}
// 清理未完成尾巴消息
const deleted = await this.deps.sessionService.cleanupIncompleteTail(sessionId);
if (deleted > 0) {
harnessLog('info', `Session ${sessionId}: cleaned up ${deleted} incomplete tail messages before retry`);
}
// 置为 RESUMING 状态
await this.deps.sessionService.updatePhase(sessionId, 'RESUMING');
this.deps.registry.send(userId, wsEvent('session_status', sessionId, { phase: 'RESUMING' }));
// 分配新的 executionId
const executionId = randomUUID();
const cancelFlag = this.deps.agentLoop.registerCancelFlag(sessionId);
this.cancelFlags.set(sessionId, cancelFlag);
this.runningExecutionIds.set(sessionId, executionId);
// 提交执行(与 CrashRecoveryRunner 核心逻辑一致,但不重新同步 skills/todos)
this.submitExecution(sessionId, userId, executionId, (futureRef) =>
this.runRetryExecution(session, userId, sessionId, executionId, cancelFlag, futureRef));
}
```
#### 5.1.3 实现 `runRetryExecution`
与 `runExecution` 类似,但跳过 skill sync、todo 清理等步骤(已有会话上下文):
```typescript
private async runRetryExecution(
session: Session, userId: number, sessionId: number, executionId: string,
cancelFlag: { get(): boolean; set(v: boolean): void }, futureRef: { current: unknown },
): Promise<void> {
await this.withLock(this.sessionLocks, sessionId, async () => {
try {
await this.deps.sessionService.updatePhase(sessionId, 'RUNNING');
this.deps.registry.send(userId, wsEvent('session_status', sessionId, { phase: 'RUNNING', executionId }));
this.deps.registry.send(userId, wsEvent('session_list_update', sessionId, { phase: 'RUNNING' }));
// 清理残留 tool calls 状态
this.deps.registry.clearActiveToolCalls(sessionId);
const listener = new WsStreamingEventListener(
{ registry: this.deps.registry, activityService: this.deps.activityService,
activityHeartbeat: this.deps.activityHeartbeat, sessionTodoMapper: this.deps.sessionTodoMapper,
sessionService: this.deps.sessionService },
sessionId, userId, executionId, await this.resolveSupportsVision(session),
);
await this.deps.harnessService.executeFromEvent(sessionId, executionId, listener, cancelFlag);
if (cancelFlag.get()) await this.finishCancelledSession(sessionId, userId, executionId);
else await this.finishCompletedSession(sessionId, userId, executionId);
} catch (e) {
const message = e instanceof Error ? e.message : 'Agent 重试执行异常';
this.deps.registry.send(userId, wsEvent('error', sessionId, { message, executionId }));
await this.finishFailedSession(sessionId, userId, executionId, message);
} finally {
this.releaseSessionExecutionResources(sessionId);
this.deps.registry.clearActiveToolCalls(sessionId);
if (this.runningTasks.get(sessionId) === futureRef.current) this.runningTasks.delete(sessionId);
if (this.runningExecutionIds.get(sessionId) === executionId) this.runningExecutionIds.delete(sessionId);
this.executionClaims.delete(sessionId);
this.cancelFlags.delete(sessionId);
this.deps.agentLoop.removeCancelFlag(sessionId);
}
});
}
```
### 5.2 前端:改造 ExecutionErrorBanner 组件
#### 5.2.1 修改 `ExecutionErrorBanner.vue`
文件:`desktop/src/components/chat/ExecutionErrorBanner.vue`
- 移除原有的 `isStreamInterrupted` 判断逻辑
- 新增 `retry` emit 事件
- 无条件显示"重试"按钮(当 `canRetry` 为 true 时)
- 按钮文案固定为"重试"
```vue
<template>
<div v-if="message" class="execution-error-banner" role="alert">
<div class="error-header">
<el-icon class="error-icon" :size="14"><WarningFilled /></el-icon>
<span class="error-title">执行异常</span>
</div>
<pre class="error-message">{{ message }}</pre>
<el-button
v-if="canRetry"
class="retry-button"
type="primary"
size="small"
@click="emit('retry')"
>
重试
</el-button>
</div>
</template>
<script setup lang="ts">
const props = defineProps<{
message: string | null
canRetry?: boolean
}>()
const emit = defineEmits<{
retry: []
}>()
</script>
```
#### 5.2.2 改造调用方
**ChatPanel.vue**(主会话):
```vue
<ExecutionErrorBanner
:message="executionError"
:can-retry="!agentRunning"
@retry="handleRetry"
/>
```
**SideChatPanel.vue**(边路任务):
```vue
<ExecutionErrorBanner
:message="executionError"
:can-retry="!sending"
@retry="handleRetry"
/>
```
**SubagentChatPanel.vue**(子代理):
```vue
<ExecutionErrorBanner
:message="executionError"
:can-retry="!isRunning"
@retry="handleRetry"
/>
```
### 5.3 前端:实现 `handleRetry`
#### 5.3.1 在 `useStreamWS.ts` 中新增 `retryExecution` 方法
```typescript
async function retryExecution(sessionId: string): Promise<void> {
const payload = {
type: 'retry_execution',
sessionId: Number(sessionId),
data: {},
}
ws.send(JSON.stringify(payload))
}
```
#### 5.3.2 在 `useChat.ts` 中新增 `retryExecution` 方法
```typescript
async function retryExecution(): Promise<void> {
if (!sessionId.value) return
// 清理前端错误状态
sessionStore.clearExecutionError(sessionId.value)
// 发送重试消息
retryExecution(sessionId.value)
// 确保 assistant 占位消息存在以便流式输出
sessionStore.ensureStreamingAssistantMessage(sessionId.value)
}
```
#### 5.3.3 在 `useStreamWS.ts` 中监听重试后的 `session_status` 事件
重试发起的执行完成后,会走已有的 `session_status` 事件处理流程(COMPLETED/FAILED),无需新增特殊处理。但需注意:
- 重试开始后,后端会发送 `session_status: RUNNING`,前端应重置流式状态
- 重试完成后,后端会发送 `session_status: COMPLETED` 或 `FAILED`,前端应清理 loading 状态
### 5.4 边路/子代理的 retry 逻辑
边路和子代理面板的 `handleRetry` 逻辑与主会话相同,只是操作的 sessionId 不同:
- 边路任务:使用 `realSessionId`(边路会话的实际 ID)
- 子代理:使用 `sid`(子代理会话 ID)
### 5.5 清理旧"继续"逻辑
- 移除 `ExecutionErrorBanner.vue` 中的 `isStreamInterrupted` 计算属性
- 移除 `ChatPanel.vue`、`SideChatPanel.vue` 中的 `@continue="handleSend('继续', [])"` 逻辑
- 移除 `ExecutionErrorBanner` 的 `canContinue` prop(替换为 `canRetry`)
## 6. 落地清单
### 后端
| 文件 | 改动 |
|------|------|
| `backend-ts/src/session/ws/streaming-ws-handler.ts` | 新增 `retry_execution` 消息类型的 case 分支 |
| `backend-ts/src/session/ws/streaming-ws-handler.ts` | 新增 `handleRetryExecution` 方法 |
| `backend-ts/src/session/ws/streaming-ws-handler.ts` | 新增 `runRetryExecution` 方法 |
### 前端
| 文件 | 改动 |
|------|------|
| `desktop/src/components/chat/ExecutionErrorBanner.vue` | 移除 `isStreamInterrupted` 和 `continue` 逻辑,新增 `canRetry` prop 和 `retry` emit |
| `desktop/src/components/chat/ChatPanel.vue` | 调用方改为 `canRetry` + `@retry` |
| `desktop/src/components/chat/SideChatPanel.vue` | 同上 |
| `desktop/src/components/chat/SubagentChatPanel.vue` | 同上 |
| `desktop/src/composables/useStreamWS.ts` | 新增 `retryExecution` 函数并暴露 |
| `desktop/src/composables/useChat.ts` | 新增 `retryExecution` 方法并暴露 |
| `desktop/src/stores/session.ts` | 无需改动(已具备 `clearExecutionError`、`ensureStreamingAssistantMessage` 等方法) |
## 7. 风险与注意事项
1. **并发安全**:`handleRetryExecution` 必须检查 `executionClaims` 和 `isTerminalPhase`,避免同一会话同时触发多个重试
2. **LOCAL 模式**:LOCAL 模式的重试需要确保桌面端客户端仍在线,否则重试会因技能同步失败而失败。当前 `handleRetryExecution` 不做特殊处理,让后续的 skill sync 自然失败并报错
3. **子代理重试**:子代理的重试操作由子代理会话所属的 `SubagentChatPanel` 触发,不影响主会话的执行状态
4. **重试限制**:当前方案不限制重试次数,用户可以无限次重试。如需限制可在后续迭代中增加
5. **重试时的模型切换**:如果用户在重试前切换了模型,当前方案仍使用原会话绑定的模型。如需支持重试时使用新模型,需要额外在 `retry_execution` 消息中传入 `modelId`