Files
cc-web/.planning/conversation-stale-running-fix/findings.md
2026-07-17 11:29:51 +08:00

80 lines
6.7 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 调研记录
## 用户现象
- 助手正文和两个 Shell Command 均已完整结束,工具退出码为 0。
- 页面底部仍显示“运行中”,停止按钮仍存在。
- 随后发送“还在么”时插入失败,服务端提示:`Codex 任务失败:no active turn to steer`。
## 初始假设
- cc-web 的本地/持久化会话仍认为 turn 正在运行,而 Codex app-server 已释放 active turn。
- 发送路径依据陈旧运行态选择 `steer`,缺少对 app-server 明确“无活动 turn”响应的状态修复与普通 `turn/start` 降级。
## 工作区约束
- 检查开始时 Git 工作树干净。
- `.planning/.active_plan` 属于并发中的 `subagent-card-metadata`,本任务使用独立 scoped 目录且不覆盖 active 指针。
- Trellis 当前任务也属于其他工作,本任务不切换共享指针。
## 项目上下文
- 独立计划审查已通过,无阻塞问题;建议在进度记录中保留实际测试命令与 60 秒超时证据。
- `codebase-memory-mcp` 项目 `home-cc-web` 索引状态为 `ready`,当前包含 3246 个节点、7821 条边,无需重新全量索引。
## 初步代码命中
- 后端 `handleCodexAppSteerMessage` 直接读取 `activeCodexAppTurns` 中的 `threadId/turnId`,请求 `turn/steer`;失败分支目前只发送 `failed` 状态与 `codexapp_steer_failed` 错误,未见状态自愈或普通 turn 降级。
- 前端 `handleServerMessage` 只把 `codex_app_steer_status` 映射成 pending/inserted/failed 展示;是否运行的权威来源仍需沿 `done`、`session_info` 和 `session_list` 消息继续确认。
- `handleLoadSession` 返回的 `isRunning` 来自服务端 `isSessionRunning(sessionId)`,说明截图底部“运行中”不是单纯 DOM 残留,核心漂移大概率位于后端活动 turn Map 或其终态通知处理。
- mock 已有 `turn/steer` 和 `turn/completed` 基础能力,`scripts/regression.js` 也已有运行中 steer 正常路径,可在其附近追加竞态回归。
## 状态机证据
- `isSessionRunning` 直接以 `activeCodexAppTurns.has(sessionId)` 判定 Codex App 运行态;只要 Map 条目没有删除,所有页面和再次发送都会继续认为 turn 活跃。
- `processCodexAppNotification` 只有收到 `turn/completed` 或 `error` 才返回 `done: true`;`handleCodexAppNotification` 随后调用 `handleCodexAppTurnComplete` 删除活动条目并广播 `done`。
- `handleMessage` 在持久化新普通消息之前先检查 `activeCodexAppTurns`,存在条目便无条件转入 `handleCodexAppSteerMessage`。
- steer 路径会先把用户消息持久化,再异步请求 `turn/steer`。失败分支目前仅显示错误,不删除陈旧活动条目,也不为已经持久化的用户消息启动新 turn,因此会同时造成“持续运行中”和“消息插入失败”。
- 最小安全修复应只识别 app-server 的权威终态/turn 不匹配错误:先收敛仍指向同一旧 entry 的本地状态,再用已持久化的用户消息启动新 turn;不能再次经过 `handleMessage`,否则会重复写入用户历史。
## 前端恢复态证据
- 前端用 `isGenerating` 控制停止按钮和“插入”发送分支,用 `currentSessionRunning` 控制“运行中”徽标,两者不是同一状态源。
- 正常 `done` 会调用 `finishGenerating` 同时清理两者;但断线/切后台后收到权威 `resume_session_result(isRunning=false)` 时,仅清理徽标并重新读取仍为 true 的 `isGenerating`,因此停止按钮和 steer 视觉状态可继续残留。
- 前端最小修复是在当前会话的 idle 恢复结果中调用 `finishGenerating`;该函数已有空 streaming bubble 与历史计数保护,无需单独操作停止按钮。
- 本次先不扩大到 resume requestId/epoch 的乱序重构;现有服务端会以同一 requestId 先发 `resume_generating` 再发最终 `resume_session_result`,直接在前一个事件清 pending 会让简单匹配方案误拒绝最终响应。
## 修复范围决策
- 后端:只对 `no active turn to steer` 这类明确“无活动 turn”的权威错误自动收敛并新开 turn;不把一般 expectedTurnId 不匹配、超时或网络错误视为安全降级条件。
- 前端:用权威 idle 恢复结果收敛本地生成态,解决漏收 `done` 后的停止按钮残留。
- 暂不扩展 `thread/status/changed`;其非 systemError 状态可能出现在 turn 开始前,笼统作为终态会误结束真实任务。
## 测试入口
- 项目完整回归入口为 `timeout 60s npm run regression`。
- 现有 `scripts/regression.js --target` 只有 composer、未路由通知和子代理卡片三个定向目标;本次应新增独立 stale-turn 目标,避免每次红绿循环都启动完整回归。
- 前端已有 `assertFrontendGenerationControlsContract` 与 `assertSessionSwitchResilienceContract` 源码合同,可在前者或本次新目标中断言 idle resume 必须调用完整生成态收敛函数。
## 实现结果
- mock 新增确定性场景:app-server 内部 turn 已结束但故意不发 `turn/completed`,下一次 steer 返回真实 `no active turn to steer`。
- 后端新增窄匹配分类器,仅在旧 entry 身份仍一致时调用现有完成流程保存输出并清理状态,再直接以已持久化消息启动新 turn;一般 expectedTurnId 不匹配继续报错。
- 前端 idle `resume_session_result` 改为调用 `finishGenerating`,同步清理 `isGenerating`、停止按钮、streaming 状态与运行徽标。
- 定向回归覆盖消息/输出不重复、自动转新 turn、最终 idle、无失败 toast,以及 expectedTurnId 不匹配负例。
## 独立审查修正
- 首轮实现会在 follow-up 用户消息之后追加旧 turn 助手输出,刷新后历史顺序错误;现通过 timestamp+content 定位,把旧助手消息插入 follow-up 之前,并补严格索引顺序断言。
- 完成旧 turn 时同步 flush 跨会话回复可能先创建新 entry;现仅在 stale replacement 期间延迟 flush,并在替代 turn 启动前再次确认 Map 为空。
- `done` 会短暂清掉前端 `isGenerating`;现替代 turn 创建成功后、首个 delta 前发送空 `resume_generating`,恢复停止/插入控制状态。
- 替代 turn 优先沿用旧 entry 的 `mcpContext`,避免线程级 MCP 上下文退化。
- 独立质量审查最终通过,无阻塞问题。
## 部署验证
- 重启前通过 ccweb 会话列表确认只有当前对话处于 `running`,不存在会被中断的其他会话。
- `pm2 restart ccweb --update-env` 导致调用连接中断,但 PM2 进程创建时间、restart 计数与启动日志确认重启已经执行。
- 重启后 `ccweb` 状态为 `online`,unstable restart 为 0,事件循环 P95 约 1.61 ms。
- 本地 `http://127.0.0.1:8002/` 返回 HTTP 200,探针耗时约 3.9 ms。