# 技术方案:因 402/403(额度不足等)失败会话的一键批量恢复 - 状态:已实施(2026-11-07 合入 main;范围见 CHANGELOG 0.0.466,P1 降级推送已含、P2 熔断未做;决策记录见 §11) - 日期:2026-11-06 - 需求来源:飞书对话 2026-11-06(杨佳毅)——「重试只会把问题延迟暴露,不合适。我想的是在后台增加一个功能,一键恢复所有因 402、403 导致失败的任务(限最近 1 天内活动的会话)」 - 影响范围:`backend-ts`(恢复内核开放 FAILED 入口、管理端接口、候选查询)、`admin`(会话管理页新增入口)、`CHANGELOG.md` - 不涉及:数据库结构变更(零迁移)、桌面端、agent-cli、LLM 层重试策略、定时任务重试链语义 - 规模:中(后端 ≈ 1 人日,前端 ≈ 0.5 人日,含自测) ## 1. 背景 ### 1.1 事件复盘(2026-11-06 18:15–18:25) NewAPI 返回 **403 `insufficient_user_quota`**(「预扣费额度失败,用户剩余额度 …」),生产实测: | 指标 | 数值 | |---|---| | LLM 调用失败次数 | 16 次(`llm_call`,18:15:52–18:24:52) | | 受影响会话 | 8 个(7 个用户) | | 当前仍停在 FAILED | 5 个(431 / 473 / 1220 / 1541 / 1592) | | 已由用户点失败卡「重试」续跑恢复 | 3 个(797 / 2273 / 2287,日志可见 `飞书卡片动作 progress.retry`) | | 定时任务影响 | 无(18:10–18:30 `scheduled_task_run` 零记录) | 也就是说:**一次余额中断,会在各用户手里留下 N 个停在 FAILED 的会话,需要用户逐个点「重试」**。这既是用户侧的操作负担,也是系统运营方(本次需求的提出者)无法批量兜底的空档。 ### 1.2 现有机制与缺口 | 机制 | 触发方 | 覆盖范围 | 代码位置 | 缺口 | |---|---|---|---|---| | LLM 内建重试 | 自动 | 404 / 429 / 5xx | `harness/llm/openai-llm-adapter.ts:711`(`isRetryableStatus`) | **402 / 403 不重试**,轮次直接 FAILED | | 飞书失败卡「重试」 | 用户逐会话点击 | 单会话续跑 | `feishu/agent-inbound-handler.ts:261`(`retryExecution`)→ `runRetry`(:304) | 需要用户逐个操作;用户未必理解发生了什么 | | 桌面端 `retry_execution` | 用户逐会话点击 | 单会话续跑 | `session/ws/streaming-ws-handler.ts:1487` | 同上 | | 定时任务失败重试 | 自动 | 定时任务 | `schedule/scheduled-task.service.ts:527`(`executeTask`)/ `finishRun` | 只覆盖定时任务 | | 崩溃恢复(CrashRecoveryRunner) | 自动(重启/孤儿巡检) | `RUNNING` / `RESUMING` 的在途会话 | `harness/core/crash-recovery-runner.ts:351` / `:367` | **不处理 FAILED 终态** | 结论:系统缺少「按错误类型批量把 FAILED 会话续跑起来」的能力。 ## 2. 需求定义 ### 2.1 要做的 1. 管理后台「会话管理」页新增「恢复 402/403 失败会话」入口(`session:write` 权限可见)。 2. 一键扫描候选:最近 1 天内有活动、当前 `phase=FAILED`、**最后一次 Agent LLM 调用是 402/403** 的会话。 3. 提供**预览清单**(用户 / 标题 / 失败时间 / 错误摘要 / 最后一条用户消息),支持人工取消勾选后再执行。 4. 一键发起恢复:服务端**异步续跑**(不插入新用户消息、不从头重跑),接口立即返回受理结果。 5. **恢复后原渠道卡片原地复活并持续更新**(与后端重启恢复同一能力):飞书/钉钉失败卡被刷回「任务正在恢复执行」,执行中轮次与工具进度持续更新,终态更新为完成/失败;无卡片映射的会话降级为执行完成后推送最终回复。 6. 全过程留痕:操作人、候选数、发起会话、跳过原因。 ### 2.2 明确不做的 - **不做自动定时恢复**(如「每 5 分钟扫一次、余额恢复就自动补跑」):本方案只做人工一键,自动版本另行立项。 - **不修改 LLM 重试策略**:不在 `isRetryableStatus` 里加 402/403(4xx 客户端语义 + 余额未恢复时会持续重试打接口)。 - **不补写 `scheduled_task_run`**:定时任务有自己的重试链(`retryMax` / `retryIntervalMinutes`),本功能不介入其状态机;需要补跑定时任务应走任务自带的「立即执行」或重试能力。 - **不改失败卡 / 桌面端重试入口**:现有单会话重试保持原样。 - **不做数据库迁移**:不加表、不加列、不加索引(现有 `idx_llm_call_session_created` 与 `idx_session_last_activity` 足够)。 - **不恢复 `SUBAGENT` / `SIDE_TASK` 会话**:子代理/边路跟随父会话语义,单独恢复会重复执行。 ## 3. 现状盘点:可直接复用的能力 ### 3.1 恢复内核:`CrashRecoveryRunner.runRecovery()` 崩溃恢复的续跑流程与「重试」完全同语义(清理未完成尾巴 → 基于历史续跑),可整体复用: | 步骤 | 代码位置 | 说明 | |---|---|---| | 同一实例内去重 | `crash-recovery-runner.ts:351`(`recovering` Set) | 防止同一会话并发跑两次 | | 重查当前状态 | `:372-377` | 现只允许 `RUNNING`/`RESUMING` → **本方案需放宽出 FAILED 分支** | | 清理未完成尾部 | `:382-385`(`cleanupIncompleteTail`) | 补齐/清理缺失 tool output,避免续跑再次 400 | | 相位流转 | `:386`(`RESUMING`)→ `:404`(`RUNNING`) | 与崩溃恢复一致 | | 客户端通知 | `:387` / `:405`(`notifyClient`) | 桌面端在线时可见(不在线也不阻断) | | 注册取消标志 + 监听器 | `:388-397` | `WsStreamingEventListener`,用于流式事件与活动记录 | | 渠道卡片续更 | `:398-403`(`createExtraListeners`) | 飞书/钉钉进度卡片;批量恢复同样挂载——原失败卡被复活为「任务正在恢复执行」(见 §4.4/§4.8) | | 执行 | `:408-411`(`harnessService.execute`) | 按 `lastRuntimeUserId ?? owner` 的持久化运行身份续跑 | | 终态落库 | `:412-437`(`finishExecution`) | 落 phase、清 runtimeStatus、构建 citations、发 WS 事件、未读处理 | | 收尾 | `:441-453` | `removeCancelFlag`、`activityHeartbeat.clear`、`onExecutionFinished` | ### 3.2 其他复用点 - **执行池**:`createAgentExecutor(threadPoolSize, threadPoolMax, threadPoolQueue)`(`create-app.ts:750`),`submit` 自动排队,池满抛 `AgentExecutorRejectedError`(`harness/core/agent-executor.ts:1-40`)。 - **终态收尾回调**:`app-lifecycle.ts:145` 构造 `CrashRecoveryRunner` 时注入的 `onExecutionFinished` 已包含:定时任务 run 收敛(`reconcileRunsForSession`)、`autoConsumeQueue`(排队消息接力)、飞书进度卡片清理、飞书/钉钉队列接力——复用后无需重写。 - **渠道推送**:`ScheduledTaskService.setFeishuResultPusher()`(`scheduled-task.service.ts:363`)注入的 `feishuResultPusher(sessionId, reply)`,`sendFeishuReplyIfApplicable`(`:1249`)为现成调用样例。 - **权限**:管理端写操作复用 `session:write`(`session/admin-session.routes.ts:28`,删除/归档同款)。 - **审计**:`/v1/admin` 前缀由全局审计拦截器自动留痕(`audit/audit.interceptor.ts:7-17`),无需额外代码。 - **前端**:`admin/src/views/session/SessionListView.vue` 已有 `canWrite` 计算属性(`:288`)与操作列(`:160-170`)可挂入口。 ## 4. 总体设计 ### 4.1 端到端流程 ``` 管理后台「会话管理」 │ 点击「恢复 402/403 失败会话」 ▼ GET /v1/admin/sessions/recover-failed/preview?windowHours=24&scope=QUOTA │ → 候选查询(4.2)→ 逐条排除判定(4.3) │ → 返回清单:willRecover / skipReason ▼ 前端预览弹窗(默认全选可恢复项,可手动取消) │ 点击「确认恢复」 ▼ POST /v1/admin/sessions/recover-failed/run { sessionIds: [...] } │ → 服务端对每个 id 重新校验(4.7 幂等) │ → 逐个 agentExecutor.submit(recoveryKernel.recoverFailed(id)) │ → 立即返回 accepted / skipped(异步执行,不等跑完) ▼ 每个会话(异步): cleanupIncompleteTail → RESUMING → RUNNING → 原飞书/钉钉进度卡片原地复活(「任务正在恢复执行」,持续更新轮次/工具进度) → harnessService.execute(基于历史续跑) → finishExecution(COMPLETED / FAILED / CANCELLED) → 卡片终态收尾(完成 / 失败) → onExecutionFinished(队列接力、定时任务收敛、卡片映射清理) → 无卡片映射会话:推送最终回复兜底 ``` ### 4.2 候选口径(已验证) **判定链**:会话当前仍是 `FAILED` + 最近 1 天有活动 + **该会话最后一条 `scene='agent'` 的 LLM 调用是 402/403 失败**。 ```sql SELECT s.id, s.user_id, s.agent_id, s.title, s.execution_mode, s.last_activity_at, s.last_runtime_user_id, l.error_message, l.created_at AS failed_at FROM session s JOIN llm_call l ON l.id = ( SELECT l2.id FROM llm_call l2 WHERE l2.session_id = s.id AND l2.scene = 'agent' ORDER BY l2.id DESC LIMIT 1 ) WHERE s.deleted = 0 AND s.status = 'ACTIVE' AND s.phase = 'FAILED' AND s.session_type = 'NORMAL' AND s.last_activity_at >= ? -- 窗口起点:NOW() - windowHours(默认 24h,服务端算好再传) AND l.success = 0 AND (l.error_message LIKE 'LLM API returned 402%' OR l.error_message LIKE 'LLM API returned 403%') -- scope=QUOTA(默认):只取额度类;scope=ALL_402_403 时去掉本段 AND (l.error_message LIKE '%insufficient%' OR l.error_message LIKE '%quota%' OR l.error_message LIKE '%预扣费%' OR l.error_message LIKE '%余额%' OR l.error_message LIKE '%balance%' OR l.error_message LIKE '%欠费%' OR l.error_message LIKE '%credit%') ORDER BY s.last_activity_at DESC; ``` 要点: - **用「最后一条 `scene='agent'` 调用」而不是「窗口内出现过 402/403」**:避免把「403 之后又因别的原因失败(如工具报错)」的会话误捞;`scene='agent'` 限定排除 `compaction` / `session_title` / `memory_extract` 等辅助调用。 - **额度类细分**:403 里既有额度类(`insufficient_user_quota`、「预扣费额度失败」)也有权限类(模型无权访问、内容拦截)。权限类恢复也会再次失败,默认只恢复额度类;`scope=ALL_402_403` 作为可选项。 - **性能**:`session` 走 `last_activity_at` 索引(V188,2.3k 行);每个会话一次 `llm_call(session_id, created_at)` 索引查找,实测可用。 - **今日数据验证**:按上述口径查询生产库,精确命中 5 个仍 FAILED 的会话(431 / 473 / 1220 / 1541 / 1592,全部 `CLOUD`/`NORMAL`,最后一条 agent 调用均为 `LLM API returned 403 … 预扣费额度失败`)。 ### 4.3 排除项 | 排除条件 | 判定方式 | 理由 | |---|---|---| | 非 `NORMAL` 会话 | `session_type != 'NORMAL'` | 子代理/边路跟随父会话,单独恢复会重复执行 | | 本地执行模式 | `execution_mode = 'LOCAL'` | 工具在用户桌面端执行,服务端无法续跑(第一版跳过并列明) | | 已被处理/已在跑 | 执行前重查 `phase != 'FAILED'` | 用户可能刚点过重试或发了新消息 | | 实例内正在恢复 | `recovering` Set / `agentLoop.getCancelFlag(id) != null` | 防并发双跑 | | 有排队执行占位 | `wsHandler.hasExecutionClaim(sessionId)` | 同上 | | 待重试的定时任务会话 | `EXISTS (SELECT 1 FROM scheduled_task_run r WHERE r.session_id = s.id AND r.next_retry_at IS NOT NULL)` | 该会话会被定时任务自己的重试链续跑,避免双重执行 | | 非额度类 403(默认) | 见 4.2 `scope=QUOTA` 关键词 | 权限类恢复必然再次失败 | | 用户手动取消勾选 | 前端 `sessionIds` 白名单 | 人工兜底 | > 说明:`scheduled_task_run` 已耗尽重试的会话本方案不特殊处理(可被恢复)。**恢复不会回写 run 状态**——若该会话最近一次运行是定时任务触发,前端预览会展示「该会话由定时任务 X 触发」的提示,由操作者决定是否勾选(见 4.5 `scheduledTask` 字段)。 ### 4.4 恢复执行:复用内核的改造 在 `CrashRecoveryRunner` 上新增一个 public 入口(不改动现有崩溃恢复语义): ```ts /** * 批量恢复因 402/403 失败、已被人工选中的会话。 * 与崩溃恢复共用 runRecovery 的续跑流程,差异仅在于: * 1) 允许进入时 phase=FAILED(且重查时仍要求 FAILED,防重复执行); * 2) 不挂渠道进度卡片续更(失败卡已是终态); * 3) 执行前额外校验「无执行占位 / 实例内不在跑」。 */ async recoverFailedSessions(sessionIds: number[]): Promise<{ accepted: number[]; skipped: Array<{ id: number; reason: string }> }> ``` 内部改造点: 1. `runRecovery` 增加参数 `opts?: { allowedPhases?: string[] }`: - 默认 `['RUNNING','RESUMING']`(现有行为不变); - 批量恢复传 `['FAILED']`,重查处不满足则跳过。 2. 复用 `recoverSession` 的 `recovering` 去重;新增 `isRecovring` 查询供接口回显(可选)。 3. `createExtraListeners`(飞书/钉钉卡片续更)**照常挂载**(与崩溃恢复完全一致,零新增依赖): - 飞书:`createFeishuRecoveryProgress(sessionId)`(`feishu-cards.ts:237`)按会话查 `feishu_progress_card` 映射 → 对**原失败卡**整卡重建为「任务正在恢复执行」RUNNING 态并 PATCH(`feishu/patched-progress.ts` 的 `send` 整卡重建路径,失败卡上的「重试」按钮随重建移除)→ 返回 `{ progress, roundOffset }` 包装为 `FeishuCardProgressListener`; - 钉钉:`dingtalk.recoverProgress(sessionId)`(现成); - 找不到卡片映射时返回 null(降级:无卡片续更,不影响恢复执行); - 数据基础(已核实):失败会话的卡片映射会被保留——`app-feishu-handler.ts:339` 在 `phase === 'FAILED'` 时直接 return,不执行 `deleteBySessionId`;本次 4 个失败会话(431/473/1220/1541)的映射均在库。 4. 其余(`cleanupIncompleteTail`、相位流转、`execute`、`finishExecution`、`onExecutionFinished`)完全复用。 > 备选方案(不推荐):在 `session/` 下重写一份「批量恢复服务」并复制 `runRecovery` 的 80 行流程。会与崩溃恢复产生两份孪生逻辑,后续相位/收尾调整必须双改,故不采用。 ### 4.5 接口契约 #### 4.5.1 预览 `GET /v1/admin/sessions/recover-failed/preview` | 参数 | 类型 | 默认 | 说明 | |---|---|---|---| | `windowHours` | int | 24 | 活跃时间窗(1–168) | | `scope` | string | `QUOTA` | `QUOTA`(额度类)/ `ALL_402_403`(全部 402/403) | 响应(`Result` 信封,`code=0`): ```jsonc { "code": 0, "data": { "windowHours": 24, "scope": "QUOTA", "total": 5, "recoverableCount": 5, "items": [ { "sessionId": 431, "userId": 24, "userName": "张三", "agentName": "梁子", "title": "采购入库品牌查询", "executionMode": "CLOUD", "phase": "FAILED", "lastActivityAt": "2026-11-06 18:15:53", "failedAt": "2026-11-06 18:15:52", "httpStatus": 403, "errorCode": "insufficient_user_quota", "errorSummary": "LLM API returned 403: … 预扣费额度失败, 用户剩余额度: $0.007852 …", "lastUserMessage": "可以根据主体(寰明、鼎樽)在宁波大贸仓…", "scheduledTask": null, // 或 { id, name }(该会话被定时任务引用时) "hasProgressCard": true, // 是否有可原地续更的渠道进度卡(恢复后原卡片会复活) "willRecover": true, "skipReason": null // 不可恢复时:LOCAL_MODE / NOT_FAILED / PENDING_TASK_RETRY / … } ] } } ``` #### 4.5.2 执行 `POST /v1/admin/sessions/recover-failed/run` ```jsonc // 请求 { "sessionIds": [431, 473, 1220, 1541, 1592] } // 响应(立即返回,不等执行完成) { "code": 0, "data": { "accepted": [431, 473, 1220, 1541, 1592], "skipped": [ { "sessionId": 999, "reason": "NOT_FAILED" } ] } } ``` - 服务端对每个 id **重新执行 4.2 + 4.3 校验**(预览到执行之间状态可能变化),不满足则进 `skipped`。 - 受理后逐个 `agentExecutor.submit(...)`;池满(`AgentExecutorRejectedError`)时该会话进 `skipped`,`reason=EXECUTOR_BUSY`,前端提示稍后重试。 - 执行结果为异步:前端刷新会话列表查看 phase 变化;恢复终态可通过会话详情查看。 ### 4.6 前端交互(`admin/src/views/session/SessionListView.vue`) 1. **入口**:筛选区右侧新增按钮「恢复 402/403 失败会话」,`v-if="canWrite"`(`session:write`)。 2. **预览弹窗**(`el-dialog`): - 顶部说明:口径「最近 N 天活跃、当前失败、最后一次模型调用因 402/403 中断」;`scope` 切换(额度类 / 全部 402/403);**窗口下拉:最近 24 小时 / 3 天 / 7 天(默认 24 小时)**。 - 表格多选:用户、会话标题、失败时间、错误摘要(`show-overflow-tooltip`)、最后用户消息、卡片续更(有/无)、跳过原因(存在时该行禁用勾选)。 - 底部:「取消」/「确认恢复 N 个」——**普通二次确认**(不要求输入确认文本);N 为当前勾选的可恢复项数量。 3. **执行**:`POST run` 后 `ElMessage` 提示「已发起 N 个恢复,跳过 M 个,恢复进度将更新到原会话卡片」;关闭弹窗并刷新列表。 4. **错误**:接口错误由 axios 拦截器统一 toast(`admin/src/api/index.ts` 响应拦截器)。 5. **不做**:不在行内加「恢复」按钮(会话行内已有查看/归档/删除,且批量是主场景);不继承列表筛选条件(固定 24h 窗口)。 ### 4.7 并发、幂等与限流 | 层 | 机制 | 说明 | |---|---|---| | L1 接口 | 请求内去重 | `sessionIds` 去重、上限 50 | | L2 服务 | 执行前重查 | `phase=FAILED` + 4.2/4.3 全部条件重验;不满足 → `skipped` | | L3 服务 | 实例内 in-flight Set | 同一会话在提交后、开跑前的窗口内不会被二次提交 | | L4 内核 | `recovering` Set / `agentLoop.getCancelFlag` / `wsHandler.hasExecutionClaim` | 与崩溃恢复、用户手动重试共用同一套并发防护 | | L5 执行池 | `agentExecutor.submit` 排队;池满 `AgentExecutorRejectedError` → `skipped: EXECUTOR_BUSY` | 不阻塞接口 | - **单批上限**:默认 20 个可恢复会话(可配置),超过时前端提示「请分批恢复」,避免一次性占满执行池。 - **提交节流**:逐个 `submit`,不额外 sleep(执行池自身排队);如需降低瞬时并发,可在服务层加 200ms 间隔(配置项,默认关闭)。 - **熔断(P2,可选)**:批次内连续 3 个会话在恢复开始后 60 秒内再次因 402/403 失败 → 停止提交剩余会话,返回/日志提示「额度可能仍未恢复」。实现点:在 `onExecutionFinished` 回调(`app-lifecycle.ts:145`)里向恢复服务上报批次结果,第一版不实现,预留注入位。 ### 4.8 恢复后的用户可见性:卡片续更(主路径)+ 降级推送(兜底) **主路径:原进度卡片原地复活**(与后端重启恢复同一能力,2026-11-06 决策要求): - 批量恢复挂载 `createExtraListeners`(复用崩溃恢复注入的同一实现,`app-lifecycle.ts:167-172`): 1. 飞书:`createFeishuRecoveryProgress`(`feishu-cards.ts:237`)按会话查 `feishu_progress_card` 映射 → 对原卡片 PATCH `update('RUNNING', roundOffset, '任务正在恢复执行。', [])`(整卡重建,失败卡上的「重试」按钮随重建移除); 2. 钉钉:`dingtalk.recoverProgress(sessionId)`(现成); 3. 执行过程中 `FeishuCardProgressListener` 持续把轮次/工具/子代理进度 PATCH 到原卡片; 4. 终态:`extra.complete(...)` / `extra.fail(...)` 收尾(机制与崩溃恢复完全一致)。 - 可行性依据(已核实):① 失败会话的卡片映射会被保留——`app-feishu-handler.ts:339` 在 `phase === 'FAILED'` 时 return,不删除映射;② 失败卡刷回 RUNNING 的「整卡重建 + PATCH」路径与「失败卡重试」共用(`feishu/patched-progress.ts` `send` → `buildFeishuProgressCard`),现网有先例(2026-11-06 18:27 `progress.retry` 后卡片被成功更新)。 - 现状数据:本次 4 个失败会话(431/473/1220/1541)均有可用卡片映射;1592 无映射(走降级路径)。 **降级路径:执行完成后推送最终回复**(无卡片映射时): - 复用 `feishuResultPusher`(`scheduled-task.service.ts:363` 注入、`:1249` 调用样例),把最终 ASSISTANT 回复发到原飞书会话; - 推送内容为最终回复(`channelAssistantReply` 同口径,剥掉 `[^n^]` 引用标记);推送失败仅记录日志,不影响恢复结果与 phase; - 钉钉的降级推送列为 P2(可复用其已有发送通道)。 ### 4.9 审计与日志 - **审计**:`/v1/admin` 前缀由全局拦截器自动记录(`audit/audit.interceptor.ts:7-17`,读/写方法均留痕),记录操作人、路径、方法、耗时、结果。无需额外代码。 - **服务日志**:统一前缀 `[failed-session-recovery]`,打印批次开始(操作人/候选数/scope/window)、accepted/skipped 明细(含 reason)、单会话终态。单会话执行日志沿用 `harnessLog`(`CrashRecoveryRunner`)。 - **不建批次表**:第一版以审计 + 日志追溯;若后续需要「恢复批次报告」,再评估 `session_recovery_batch` 表(不在本方案范围)。 ## 5. 详细改动点(文件级) | # | 文件 | 改动 | 预估 | |---|---|---|---| | B1 | `backend-ts/src/harness/core/crash-recovery-runner.ts` | `runRecovery` 增加 `opts.allowedPhases`(默认 `['RUNNING','RESUMING']`,批量恢复传 `['FAILED']`);新增 public `recoverFailedSessions(ids)`(复用 `recovering` 去重、`createExtraListeners` 卡片续更、`finishExecution`、`onExecutionFinished`) | ~70 行 | | B2 | `backend-ts/src/session/failed-session-recovery.service.ts`(新增) | 候选查询编排、排除判定(含 `scheduledTask` / `hasProgressCard` 提示)、in-flight 去重、批量 submit、结果汇总、无卡片降级推送调用、日志 | ~230 行 | | B3 | `backend-ts/src/session/session.repository.ts` | 新增原生 SQL 查询 `listFailedByLastAgentError(windowStart, scope)`(4.2 的 SQL) | ~40 行 | | B4 | `backend-ts/src/session/admin-session.routes.ts` | 新增 `GET /v1/admin/sessions/recover-failed/preview` 与 `POST /v1/admin/sessions/recover-failed/run`;`AdminSessionRouteDeps` 增加 `failedSessionRecovery?`(可选依赖,未注入时接口返回 501/空清单,便于灰度) | ~70 行 | | B5 | `backend-ts/src/app-routes.ts` | 注册路由时注入 `failedSessionRecovery` | ~5 行 | | B6 | `backend-ts/src/app-lifecycle.ts` | 构造 `FailedSessionRecoveryService`(依赖 crash 内核 / sessionService / 执行池 / 推送),并暴露给路由层 | ~20 行 | | B7 | `backend-ts/src/session/failed-session-recovery.service.spec.ts`(新增) | 候选筛选、排除项、幂等、池满、权限/审计断言 | ~180 行 | | F1 | `admin/src/views/session/SessionListView.vue` | 入口按钮 + 预览弹窗 + 结果提示 | ~200 行 | | F2 | `admin/src/api/index.ts`(或页面内直调) | `previewFailedRecovery` / `runFailedRecovery` 两个封装 | ~20 行 | | D1 | `CHANGELOG.md` | 顶部新增一条用户可见改动 | 1 行 | | D2 | `docs/agents/*.md` + `INDEX.md`(按工作区约定) | 若产生排障经验则沉淀;本方案本身放 `docs/plan/` | — | > **卡片续更零新增依赖**:`CrashRecoveryRunner` 构造时已注入 `createExtraListeners`(`app-lifecycle.ts:167-172`),批量恢复直接复用;`FailedSessionRecoveryService` 仅需注入 crash 内核引用与 `feishuResultPusher`(降级推送用)。 > 路由共存说明:`/v1/admin/sessions/recover-failed/*` 与 `/v1/admin/sessions/:id` 同前缀。Fastify(find-my-way)静态段优先,不会被 `:id` 吞掉;若担心歧义可改用 `/v1/admin/session-recovery/*`(实现时二选一,推荐前者,语义聚合)。 ## 6. 数据契约 ### 6.1 数据库 **无改动。** 用到的既有字段: | 表 | 字段 | |---|---| | `session` | `id / user_id / agent_id / title / status / phase / session_type / execution_mode / last_activity_at / last_runtime_user_id / deleted` | | `llm_call` | `session_id / scene / success / error_message / created_at`(走 `idx_llm_call_session_created`) | | `scheduled_task_run` | `session_id / next_retry_at`(排除待重试会话,仅 `EXISTS` 判断) | ### 6.2 接口 见 §4.5。新增 VO 建议放 `backend-ts/src/session/session-vo.ts` 或恢复服务同目录: ```ts interface FailedSessionRecoveryItem { sessionId: number; userId: number; userName: string; agentName: string; title: string | null; executionMode: string | null; phase: string; lastActivityAt: string | null; failedAt: string | null; httpStatus: 402 | 403 | null; errorCode: string | null; errorSummary: string; lastUserMessage: string; scheduledTask: { id: number; name: string } | null; willRecover: boolean; skipReason: string | null; } ``` `skipReason` 枚举:`NOT_FAILED` / `LOCAL_MODE` / `NON_NORMAL_SESSION` / `OUT_OF_WINDOW` / `NOT_402_403` / `NON_QUOTA_ERROR` / `PENDING_TASK_RETRY` / `ALREADY_RUNNING` / `EXECUTOR_BUSY`。 ## 7. 边界与风险 | # | 风险 | 影响 | 应对 | |---|---|---|---| | R1 | **非幂等工具重复执行** | 极端情况下 Agent 可能重复下 P 单 / 重复发外部消息 | 与现有「重试」完全同语义(历史工具结果都在),不新增风险面;预览清单允许人工剔除高风险会话;不做无人值守自动批次 | | R2 | 余额实际上未恢复 | 恢复后立刻再失败一次 | 卡片会被 `extra.fail` 同步刷回失败态(不会卡在「恢复中」);前端确认文案提示「请先确认模型额度已恢复」;P2 增加轻量探活或快速失败熔断 | | R3 | 与用户手动重试/发消息竞态 | 同一会话双跑 | L2-L5 五层幂等(§4.7),执行前重查 phase 是最后一道闸 | | R4 | 定时任务会话语义不同步 | `scheduled_task_run` 仍显示 FAILED,但会话已补跑完成 | 预览展示 `scheduledTask` 标识 + 默认排除待重试 run;本功能不承诺修正任务状态 | | R5 | LOCAL 模式会话 | 服务端无法执行(工具在桌面端) | 预览 `skipReason=LOCAL_MODE`,默认不勾选 | | R6 | 执行池被批量恢复占满 | 其他用户任务排队变慢 | 单批上限(默认 20)+ 池满返回 `EXECUTOR_BUSY` | | R7 | 恢复耗时长(大任务几十分钟) | 接口不能同步等待 | 异步提交、接口立即返回;前端提示「后台执行中,可在列表查看进度」 | | R8 | 鉴权类 403 被误恢复 | 再次失败,浪费一次执行 | 默认仅额度类关键词(§4.2);`ALL_402_403` 需显式切换 | | R9 | 用户看到会话从 FAILED 回到 RUNNING | 观感变化 | 属预期,与失败卡「重试」一致;桌面端 WS 事件会自动刷新;飞书/钉钉侧表现为卡片从「失败」翻回「恢复执行中」 | | R10 | 失败卡映射缺失(如本次 1592 群聊会话) | 恢复后无卡片可续更 | 降级为执行完成后推送最终回复;恢复执行本身不受影响 | ## 8. 测试计划 **单元测试**(`failed-session-recovery.service.spec.ts` + `admin-session.routes.spec.ts` 扩展): 1. 候选筛选: - 最后一条 `scene='agent'` 调用非 402/403 → 不入选; - 最后一条是 403 但非额度类且 `scope=QUOTA` → 不入选,`scope=ALL_402_403` → 入选; - `phase != FAILED` / 超窗口 / `SUBAGENT` / `SIDE_TASK` / `LOCAL` → 不入选(或预览标记 skip); - 有待重试 `scheduled_task_run.next_retry_at` → `PENDING_TASK_RETRY`。 2. 执行: - 重复 `sessionIds` 去重; - 预览后 phase 变化(用户已重试)→ `skipped: NOT_FAILED`; - 执行池拒绝 → `skipped: EXECUTOR_BUSY`; - 单批上限 20 生效。 3. 权限与审计:无 `session:write` → 403;`shouldAudit('/v1/admin/sessions/recover-failed/run','POST') === true`。 4. 回归:`CrashRecoveryRunner` 现有单测全部保持通过(默认 `allowedPhases` 行为不变)。 **手工验证**(预发): 1. 在预发构造一个 402/403 失败会话(可临时把模型余额置零/或 mock LLM 返回 403),确认 `phase=FAILED`; 2. `preview` 返回该会话且 `willRecover=true`; 3. `run` 后观察:FAILED → RESUMING → RUNNING → COMPLETED;消息历史无重复 USER 消息、工具结果连贯; 4. **卡片续更(重点)**: - 飞书场景:恢复发起后,原失败卡先被刷为「任务正在恢复执行」(「重试」按钮消失),执行中轮次/工具进度持续更新,完成后变为完成卡; - 恢复途中再次失败:卡片同步刷回失败态(`extra.fail`),不会停留在「恢复中」; - 无卡片映射的会话(如历史群聊会话 1592):恢复执行不受影响,完成后走降级推送(若已实现); 5. 反向用例:余额仍未恢复时执行 → 会话再次 FAILED、卡片刷回失败态,服务日志可见错误摘要(不破坏数据)。 **回归命令**:`cd mao/backend-ts && npm run build && npm test`;`cd mao/admin && npm run build`。 ## 9. 发布与回滚 - 后端改动走 `deploy-mao.sh`(蓝绿重启 9080↔9081);前端 `admin` 构建后刷新页面即可。 - **回滚**:前端去掉入口按钮、后端回滚上一版本镜像即可;零 DB 变更、无数据修复动作。 - 发布后观测: - 日志 `[failed-session-recovery]` 的 batch / skip 统计; - 恢复会话的终态分布(COMPLETED 占比); - 执行池拒绝(`AgentExecutorRejectedError`)是否增加; - 审计日志 `admin.sessions` 相关记录。 ## 10. 工作量拆解与实施顺序 | 阶段 | 内容 | 预估 | |---|---|---| | P0-1 | B3 候选查询 + B2 服务骨架 + B7 单测 | 0.4 人日 | | P0-2 | B1 内核入口(含卡片续更复用)+ B4/B5/B6 路由与装配 + 单测 | 0.3 人日 | | P0-3 | F1/F2 前端按钮、预览弹窗、结果提示 | 0.5 人日 | | P0-4 | 联调自测(预发构造失败会话)+ D1 CHANGELOG | 0.3 人日 | | **P0 合计** | 手动一键恢复(不含自动与熔断) | **≈1.5 人日** | | P1 | 无卡片降级推送(飞书)接入与验证 | +0.2 人日 | | P2(可选) | 快速失败熔断 / 余额探活 / 自动定时恢复 / 钉钉降级推送 | 另行评估 | 实施顺序建议:**先 P0-1(只读预览能力)**——上线后即可让运维看到「当前有多少会话因 402/403 卡住」,零风险;再 P0-2/P0-3 打开执行入口。 ## 11. 决策记录(2026-11-06 定稿) | # | 问题 | 决策 | |---|---|---| | 1 | 入口位置 | 会话管理页顶部按钮(推荐方案) | | 2 | 默认筛选范围 | 额度类 only(`scope=QUOTA` 默认;`ALL_402_403` 可选切换) | | 3 | 恢复后的用户可见性 | **原渠道卡片原地续更**(执行中持续更新,非仅终态推送,与后端重启恢复能力一致);无卡片映射时降级为完成后推送 | | 4 | 二次确认 | 普通确认框(不要求输入确认文本) | | 5 | 窗口参数 | 界面暴露:最近 24 小时 / 3 天 / 7 天(默认 24 小时);接口 `windowHours` 支持 1–168 |