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

6.7 KiB
Raw Blame History

调研记录

用户现象

  • 助手正文和两个 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。