diff --git a/.planning/conversation-stale-running-fix/findings.md b/.planning/conversation-stale-running-fix/findings.md new file mode 100644 index 0000000..79dabd2 --- /dev/null +++ b/.planning/conversation-stale-running-fix/findings.md @@ -0,0 +1,79 @@ +# 调研记录 + +## 用户现象 + +- 助手正文和两个 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。 diff --git a/.planning/conversation-stale-running-fix/progress.md b/.planning/conversation-stale-running-fix/progress.md new file mode 100644 index 0000000..b62666c --- /dev/null +++ b/.planning/conversation-stale-running-fix/progress.md @@ -0,0 +1,20 @@ +# 进度日志 + +- 2026-07-17:读取项目指令、Trellis 工作流及 `planning-with-files`、`todo-list-csv` 技能。 +- 2026-07-17:确认工作树干净,已有共享规划任务正在使用 active 指针,本任务采用独立 scoped 计划。 +- 2026-07-17:根据截图固化状态漂移与 `steer` 失败的初始因果假设,建立实施与验收计划。 +- 2026-07-17:独立计划审查通过;代码索引就绪,开始并行定位后端终态与前端发送判定链路。 +- 2026-07-17:初步定位 `handleCodexAppSteerMessage`、`isSessionRunning` 与现有 steer 回归入口;确认失败分支缺少明显的状态自愈。 +- 2026-07-17:完成核心状态机梳理;根因收敛为“终态通知缺失/乱序后 Map 陈旧,steer 权威拒绝未触发本地收敛与新 turn 降级”。 +- 2026-07-17:前后端并行排查完成;另确认前端 idle 恢复只清徽标、不清 `isGenerating`,会保留停止按钮与插入视觉状态。 +- 2026-07-17:定位阶段完成,进入失败回归编写;计划仅窄匹配明确无 active turn,保留其他 steer 错误原行为。 +- 2026-07-17:确认完整回归入口和现有定向 target 结构;实施代理将新增本问题专用 target 以保留红绿测试证据。 +- 2026-07-17:实施代理完成测试先行修复;旧实现定向回归按预期失败于 steer 错误和 done 超时,修复后连续三次通过。 +- 2026-07-17:主线程复跑 `timeout 60s node scripts/regression.js --target codexapp-stale-running` 通过;四个修改文件语法检查和 `git diff --check` 通过。 +- 2026-07-17:进入完整回归与独立边界审查阶段。 +- 2026-07-17:首轮独立审查发现历史顺序与跨会话 flush 重入问题,已补实现及红绿回归。 +- 2026-07-17:最终复审进一步指出 done→running 的短暂 UI 窗口;已发送 `resume_generating` 收敛并保留旧 MCP 上下文。 +- 2026-07-17:主线程再次运行定向回归、四文件语法检查、`git diff --check` 和 `timeout 60s npm run regression`,全部通过;独立审查最终放行。 +- 2026-07-17:进入最终差异、部署条件与临时跟踪文件清理。 +- 2026-07-17:重启前确认只有当前对话 running;执行 cc-web 重启后 PM2 online、HTTP 200、事件循环与进程状态正常。 +- 2026-07-17:最终行号、差异统计和 `git diff --check` 核验完成,任务闭环。 diff --git a/.planning/conversation-stale-running-fix/task_plan.md b/.planning/conversation-stale-running-fix/task_plan.md new file mode 100644 index 0000000..045e125 --- /dev/null +++ b/.planning/conversation-stale-running-fix/task_plan.md @@ -0,0 +1,35 @@ +# 对话结束后仍显示运行中修复计划 + +## 目标 + +修复 Codex App 对话实际已结束但 cc-web 仍显示“运行中”的偶发状态漂移,确保后续消息不会误走 `steer` 并触发 `no active turn to steer`。 + +## 阶段 + +1. [complete] 定位运行状态结束与再次发送的完整调用链 +2. [complete] 补充卡住运行态与 steer 失败的回归用例 +3. [complete] 实现终态事件驱动的运行状态收敛 +4. [complete] 实现无活动 turn 时的安全发送降级 +5. [complete] 运行定向回归与语法静态检查 +6. [complete] 执行完整回归并独立审查边界 +7. [complete] 核对差异风险并清理临时跟踪文件 + +## 验收标准 + +- turn 正常完成、失败或中断后,会话最终稳定为非运行态。 +- 状态漂移时再次发送不会把用户消息永久失败在 `steer`。 +- 不引入重复 turn、重复消息或吞掉真实 steer 错误。 +- 回归测试覆盖终态缺失/乱序和 `no active turn to steer` 竞态。 +- 定向测试、语法检查与完整回归均通过,单次测试最长 60 秒。 + +## 约束 + +- 代码理解优先使用 `codebase-memory-mcp`,`rg` / `nl` 仅做校验。 +- 不覆盖现有 `.planning` 任务,不切换共享 Trellis 当前任务指针。 +- 未确认其他会话均非 `running` 前不重启 cc-web。 +- 代码注释与新增文档使用简体中文。 + +## 错误记录 + +| 错误 | 尝试 | 处理 | +|---|---:|---| diff --git a/dist-exe/cc-web-bun-linux-x64-baseline.tar.gz b/dist-exe/cc-web-bun-linux-x64-baseline.tar.gz index f026b16..a44a52f 100644 Binary files a/dist-exe/cc-web-bun-linux-x64-baseline.tar.gz and b/dist-exe/cc-web-bun-linux-x64-baseline.tar.gz differ diff --git a/public/app.js b/public/app.js index 1622647..1d41176 100644 --- a/public/app.js +++ b/public/app.js @@ -5574,7 +5574,7 @@ clearPendingSessionResumeRequest(msg.sessionId || currentSessionId, msg.requestId); setCurrentSessionRunningState(!!msg.isRunning); if (!msg.isRunning && currentSessionId && msg.sessionId === currentSessionId) { - updateGenerationControls(); + finishGenerating(msg.sessionId || currentSessionId); } break; diff --git a/scripts/mock-codex-app-server.js b/scripts/mock-codex-app-server.js index 7dab4a2..ca11af4 100755 --- a/scripts/mock-codex-app-server.js +++ b/scripts/mock-codex-app-server.js @@ -364,6 +364,47 @@ function completeTurn(thread, turnId, text, status = 'completed') { thread.steers = []; } +function completeTurnWithoutTerminalNotification(thread, turnId, text) { + if (thread.activeTurnId !== turnId) return; + const responseText = `Codex App stale turn output: ${text}`; + send({ + method: 'item/agentMessage/delta', + params: { + threadId: thread.id, + turnId, + itemId: 'agent-msg', + delta: responseText, + }, + }); + send({ + method: 'item/completed', + params: { + threadId: thread.id, + turnId, + completedAtMs: Date.now(), + item: { + id: 'agent-msg', + type: 'agentMessage', + content: [{ type: 'text', text: responseText }], + status: 'completed', + }, + }, + }); + send({ + method: 'thread/tokenUsage/updated', + params: { + threadId: thread.id, + turnId, + tokenUsage: tokenUsage(text), + }, + }); + + // 模拟 app-server 已结束内部 turn,但终态通知在传输中丢失。 + thread.activeTurnId = null; + thread.timer = null; + thread.steers = []; +} + function completeGoalBackgroundTurn(thread, objective) { const turnId = `goal-turn-${crypto.randomUUID()}`; const text = `Goal background output: ${objective}`; @@ -685,6 +726,26 @@ function startTurn(params) { }, }); + if (/^codexapp stale running first$/i.test(text)) { + completeTurnWithoutTerminalNotification(thread, turnId, text); + return { turn: { id: turnId, status: 'running', items: [] } }; + } + + if (/^codexapp expected turn mismatch first$/i.test(text)) { + send({ + method: 'item/agentMessage/delta', + params: { + threadId: thread.id, + turnId, + itemId: 'agent-msg', + delta: `Codex App expected turn mismatch fixture: ${text}`, + }, + }); + // 保留一个不同的活动 turn,确保 steer 返回“不匹配”而不是“无活动 turn”。 + thread.activeTurnId = `app-turn-mismatch-${crypto.randomUUID()}`; + return { turn: { id: turnId, status: 'running', items: [] } }; + } + if (/runtime warning/i.test(text)) { const message = 'Heads up: Long threads and multiple compactions can cause the model to be less accurate. Start a new thread when possible to keep threads small and targeted.'; for (let i = 0; i < 2; i += 1) { @@ -819,7 +880,15 @@ function interruptTurn(params) { function steerTurn(params) { const thread = ensureThread(params.threadId, params); - if (!thread.activeTurnId || thread.activeTurnId !== params.expectedTurnId) { + if (!thread.activeTurnId) { + return { + error: { + code: -32001, + message: 'no active turn to steer', + }, + }; + } + if (thread.activeTurnId !== params.expectedTurnId) { return { error: { code: -32001, diff --git a/scripts/regression.js b/scripts/regression.js index bd24239..c210f2a 100644 --- a/scripts/regression.js +++ b/scripts/regression.js @@ -132,10 +132,11 @@ async function startServer(env) { }; } -function connectWs(port, password) { +function connectWs(port, password, options = {}) { return new Promise((resolve, reject) => { const ws = new WebSocket(`ws://127.0.0.1:${port}/ws`); const messages = []; + const receivedMessages = options.trackReceived ? [] : null; let settled = false; ws.on('open', () => { @@ -144,9 +145,10 @@ function connectWs(port, password) { ws.on('message', (buf) => { const msg = JSON.parse(String(buf)); messages.push(msg); + if (receivedMessages) receivedMessages.push(msg); if (msg.type === 'auth_result' && msg.success) { settled = true; - resolve({ ws, messages, token: msg.token }); + resolve({ ws, messages, receivedMessages, token: msg.token }); } if (msg.type === 'auth_result' && !msg.success) { settled = true; @@ -1423,6 +1425,10 @@ function assertSessionSwitchResilienceContract() { assert(frontendSource.includes('function requestSessionResume'), 'Frontend should request running-session resume without full history reload'); assert(frontendSource.includes("type: 'resume_session'"), 'Frontend should use resume_session for reconnecting running conversations'); assert(frontendSource.includes("case 'resume_session_result':"), 'Frontend should handle lightweight resume results'); + assert( + /case 'resume_session_result':[\s\S]*?if \(!msg\.isRunning && currentSessionId && msg\.sessionId === currentSessionId\) \{[\s\S]*?finishGenerating\(msg\.sessionId \|\| currentSessionId\);[\s\S]*?\}[\s\S]*?break;/.test(frontendSource), + 'Frontend idle resume result should finish generation state for the current session' + ); assert(frontendSource.includes('recoverCurrent: true'), 'Frontend fallback load_session should preserve the current running view'); const visibilityStart = frontendSource.indexOf("document.addEventListener('visibilitychange'"); const visibilityEnd = visibilityStart >= 0 ? frontendSource.indexOf("if (!authToken)", visibilityStart) : -1; @@ -1515,6 +1521,175 @@ function assertSessionSwitchResilienceContract() { ); } +function assertCodexAppStaleRunningRecoveryContract() { + const serverSource = fs.readFileSync(SERVER_PATH, 'utf8'); + const completeBlock = extractFunctionSource(serverSource, 'handleCodexAppTurnComplete'); + const steerBlock = extractFunctionSource(serverSource, 'handleCodexAppSteerMessage'); + assert( + completeBlock.includes('!options.deferPendingCrossConversationFlush') && completeBlock.includes('flushPendingCrossConversationReplies(sessionId)'), + 'Stale recovery should be able to defer pending cross-conversation reply flushes' + ); + assert( + steerBlock.includes('deferPendingCrossConversationFlush: true'), + 'Stale steer recovery should defer pending cross-conversation reply flushes while replacing the turn' + ); + assert( + steerBlock.includes('!activeCodexAppTurns.has(sessionId)') && steerBlock.includes('handleCodexAppMessage(ws, refreshedSession'), + 'Stale steer recovery should only start the replacement turn while the active turn map is empty' + ); + assert( + steerBlock.includes('mcpContext: entry.mcpContext || options.mcpContext || {}'), + 'Stale steer recovery should prefer the original active entry MCP context' + ); +} + +async function runCodexAppStaleRunningRegression() { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'cc-web-stale-running-regression-')); + const configDir = path.join(tempRoot, 'config'); + const sessionsDir = path.join(tempRoot, 'sessions'); + const logsDir = path.join(tempRoot, 'logs'); + const homeDir = path.join(tempRoot, 'home'); + mkdirp(configDir); + mkdirp(sessionsDir); + mkdirp(logsDir); + mkdirp(homeDir); + + const port = await getFreePort(); + const password = 'StaleRunning!234'; + await withServer({ + PORT: String(port), + CC_WEB_PASSWORD: password, + CC_WEB_INTERNAL_MCP_TOKEN: 'StaleRunningMcp!234', + CC_WEB_CONFIG_DIR: configDir, + CC_WEB_SESSIONS_DIR: sessionsDir, + CC_WEB_LOGS_DIR: logsDir, + HOME: homeDir, + CLAUDE_PATH: MOCK_CLAUDE, + CODEX_PATH: MOCK_CODEX_APP_SERVER, + }, async () => { + const { ws, messages, receivedMessages } = await connectWs(port, password, { trackReceived: true }); + await nextMessage(messages, ws, (msg) => msg.type === 'session_list'); + + ws.send(JSON.stringify({ type: 'new_session', agent: 'codexapp', cwd: homeDir, mode: 'yolo' })); + const sessionInfo = await nextMessage(messages, ws, (msg) => ( + msg.type === 'session_info' && msg.agent === 'codexapp' && msg.cwd === homeDir + )); + const sessionId = sessionInfo.sessionId; + + ws.send(JSON.stringify({ + type: 'message', + text: 'codexapp stale running first', + sessionId, + mode: 'yolo', + agent: 'codexapp', + })); + await nextMessage(messages, ws, (msg) => ( + msg.type === 'session_list' && msg.sessions.some((session) => session.id === sessionId && session.isRunning) + )); + const staleOutput = await nextMessage(messages, ws, (msg) => ( + msg.type === 'text_delta' && msg.sessionId === sessionId && /Codex App stale turn output/.test(msg.text || '') + )); + assert(/codexapp stale running first/.test(staleOutput.text || ''), 'Stale running fixture should emit the first turn output'); + await sleep(150); + + ws.send(JSON.stringify({ + type: 'message', + text: 'codexapp stale running follow-up', + sessionId, + mode: 'yolo', + agent: 'codexapp', + clientMessageId: 'regression-stale-running-follow-up', + })); + await nextMessage(messages, ws, (msg) => ( + msg.type === 'codex_app_steer_status' && + msg.sessionId === sessionId && + msg.clientMessageId === 'regression-stale-running-follow-up' && + msg.status === 'pending' + ), 5000); + await nextMessage(messages, ws, (msg) => msg.type === 'done' && msg.sessionId === sessionId, 5000); + await nextMessage(messages, ws, (msg) => ( + msg.type === 'session_list' && msg.sessions.some((session) => session.id === sessionId && !session.isRunning) + ), 5000); + await nextMessage(messages, ws, (msg) => ( + msg.type === 'session_list' && msg.sessions.some((session) => session.id === sessionId && session.isRunning) + ), 5000); + const replacementResume = await nextMessage(messages, ws, (msg) => ( + msg.type === 'resume_generating' && msg.sessionId === sessionId + ), 5000); + assert(replacementResume.text === '' && Array.isArray(replacementResume.toolCalls) && replacementResume.toolCalls.length === 0, 'Stale replacement should resume generation with an empty streaming payload'); + const recoveredStatus = await nextMessage(messages, ws, (msg) => ( + msg.type === 'codex_app_steer_status' && + msg.sessionId === sessionId && + msg.clientMessageId === 'regression-stale-running-follow-up' && + msg.status === 'inserted' + ), 5000); + assert(!/失败/.test(recoveredStatus.message || ''), 'Recovered stale steer should update the UI as a non-failure'); + const recoveredHint = await nextMessage(messages, ws, (msg) => ( + msg.type === 'system_message' && msg.sessionId === sessionId && /已自动开始新一轮对话/.test(msg.message || '') + ), 5000); + assert(recoveredHint.transient === true, 'Recovered stale steer hint should be transient'); + await nextMessage(messages, ws, (msg) => ( + msg.type === 'text_delta' && msg.sessionId === sessionId && /codexapp stale running follow-up/.test(msg.text || '') + ), 5000); + const replacementResumeIndex = receivedMessages.findIndex((msg) => msg.type === 'resume_generating' && msg.sessionId === sessionId); + const replacementDeltaIndex = receivedMessages.findIndex((msg) => msg.type === 'text_delta' && msg.sessionId === sessionId && /codexapp stale running follow-up/.test(msg.text || '')); + assert(replacementResumeIndex >= 0 && replacementResumeIndex < replacementDeltaIndex, 'Stale replacement should restore generating UI before its first text delta'); + await nextMessage(messages, ws, (msg) => msg.type === 'done' && msg.sessionId === sessionId, 5000); + const finalList = await nextMessage(messages, ws, (msg) => ( + msg.type === 'session_list' && msg.sessions.some((session) => session.id === sessionId && !session.isRunning) + ), 5000); + assert(finalList.sessions.some((session) => session.id === sessionId && !session.isRunning), 'Recovered follow-up should finish idle'); + assert(!messages.some((msg) => msg.code === 'codexapp_steer_failed'), 'Recovered stale steer should not emit codexapp_steer_failed'); + + const stored = JSON.parse(fs.readFileSync(path.join(sessionsDir, `${sessionId}.json`), 'utf8')); + assert(stored.messages.filter((message) => message.role === 'user' && message.content === 'codexapp stale running follow-up').length === 1, 'Recovered follow-up user message should persist exactly once'); + assert(stored.messages.filter((message) => message.role === 'assistant' && /Codex App stale turn output/.test(String(message.content || ''))).length === 1, 'Stale first turn output should persist exactly once'); + assert(stored.messages.filter((message) => message.role === 'assistant' && /Codex App mock handled: codexapp stale running follow-up/.test(String(message.content || ''))).length === 1, 'Recovered follow-up output should persist exactly once'); + const staleAssistantIndex = stored.messages.findIndex((message) => message.role === 'assistant' && /Codex App stale turn output/.test(String(message.content || ''))); + const followUpUserIndex = stored.messages.findIndex((message) => message.role === 'user' && message.content === 'codexapp stale running follow-up'); + const recoveredAssistantIndex = stored.messages.findIndex((message) => message.role === 'assistant' && /Codex App mock handled: codexapp stale running follow-up/.test(String(message.content || ''))); + assert(staleAssistantIndex < followUpUserIndex && followUpUserIndex < recoveredAssistantIndex, 'Recovered history should keep stale assistant before follow-up user before recovered assistant'); + assert(!fs.existsSync(path.join(sessionsDir, `${sessionId}-run`)), 'Recovered follow-up should clean the Codex App run directory after completion'); + + ws.send(JSON.stringify({ type: 'new_session', agent: 'codexapp', cwd: homeDir, mode: 'yolo' })); + const mismatchSession = await nextMessage(messages, ws, (msg) => ( + msg.type === 'session_info' && msg.agent === 'codexapp' && msg.cwd === homeDir && msg.sessionId !== sessionId + )); + ws.send(JSON.stringify({ + type: 'message', + text: 'codexapp expected turn mismatch first', + sessionId: mismatchSession.sessionId, + mode: 'yolo', + agent: 'codexapp', + })); + await nextMessage(messages, ws, (msg) => ( + msg.type === 'text_delta' && msg.sessionId === mismatchSession.sessionId && /expected turn mismatch fixture/.test(msg.text || '') + ), 5000); + await sleep(150); + ws.send(JSON.stringify({ + type: 'message', + text: 'codexapp mismatch follow-up', + sessionId: mismatchSession.sessionId, + mode: 'yolo', + agent: 'codexapp', + clientMessageId: 'regression-expected-turn-mismatch', + })); + const mismatchFailed = await nextMessage(messages, ws, (msg) => ( + msg.type === 'codex_app_steer_status' && + msg.sessionId === mismatchSession.sessionId && + msg.clientMessageId === 'regression-expected-turn-mismatch' && + msg.status === 'failed' + ), 5000); + assert(/失败/.test(mismatchFailed.message || ''), 'Expected turn mismatch should keep the original failed steer status'); + const mismatchError = await nextMessage(messages, ws, (msg) => ( + msg.type === 'error' && msg.sessionId === mismatchSession.sessionId && msg.code === 'codexapp_steer_failed' + ), 5000); + assert(/expectedTurnId does not match active turn/.test(mismatchError.message || ''), 'Expected turn mismatch should not trigger stale-turn recovery'); + assert(!messages.some((msg) => msg.type === 'system_message' && msg.sessionId === mismatchSession.sessionId && /已自动开始新一轮对话/.test(msg.message || '')), 'Expected turn mismatch should not start a replacement turn'); + ws.close(); + }); +} + function assertUnlimitedImageAttachmentsContract() { const frontendSource = fs.readFileSync(PUBLIC_APP_PATH, 'utf8'); const serverSource = fs.readFileSync(SERVER_PATH, 'utf8'); @@ -1722,6 +1897,13 @@ async function main() { console.log('Subagent card metadata regression checks passed.'); return; } + if (regressionTarget === 'codexapp-stale-running') { + await runCodexAppStaleRunningRegression(); + assertSessionSwitchResilienceContract(); + assertCodexAppStaleRunningRecoveryContract(); + console.log('Codex App stale running regression checks passed.'); + return; + } throw new Error(`Unknown regression target: ${regressionTarget}`); } diff --git a/server.js b/server.js index 1bc5da1..66028fa 100644 --- a/server.js +++ b/server.js @@ -10075,7 +10075,7 @@ function handleCodexAppTurnComplete(sessionId, options = {}) { } if (session && (assistantContent.trim() || assistantToolCalls.length > 0) && !hasCodexAppTurnMessage(session, turnKey)) { - session.messages.push({ + const assistantMessage = { role: 'assistant', content: assistantContent, toolCalls: assistantToolCalls, @@ -10084,7 +10084,21 @@ function handleCodexAppTurnComplete(sessionId, options = {}) { codexAppThreadId: entry.threadId || null, codexAppTurnId: entry.turnId || null, interrupted: !!options.interrupted, - }); + }; + const beforeUserMessage = options.beforeUserMessage; + const beforeUserMessageIndex = beforeUserMessage + ? session.messages.findIndex((message) => ( + message?.role === 'user' + && message.timestamp === beforeUserMessage.timestamp + && message.content === beforeUserMessage.content + )) + : -1; + if (beforeUserMessageIndex >= 0) { + // stale steer 已先持久化用户消息,把旧 turn 输出恢复到该消息之前。 + session.messages.splice(beforeUserMessageIndex, 0, assistantMessage); + } else { + session.messages.push(assistantMessage); + } session.updated = new Date().toISOString(); if (!entry.ws) session.hasUnread = true; saveSession(session); @@ -10095,7 +10109,9 @@ function handleCodexAppTurnComplete(sessionId, options = {}) { if (entry.crossConversationReplyRequestId) { completeCrossConversationReply(entry.crossConversationReplyRequestId, entry, session); } - flushPendingCrossConversationReplies(sessionId); + if (!options.deferPendingCrossConversationFlush) { + flushPendingCrossConversationReplies(sessionId); + } plog(completionError ? 'WARN' : 'INFO', 'codex_app_turn_complete', { sessionId: sessionId.slice(0, 8), threadId: entry.threadId || null, @@ -10140,6 +10156,10 @@ function handleCodexAppTurnFailure(sessionId, err) { handleCodexAppTurnComplete(sessionId, { error: entry.lastError }); } +function isCodexAppNoActiveTurnError(err) { + return /\bno active turn to steer\b/i.test(String(err?.message || err || '')); +} + function handleCodexAppSteerMessage(ws, msg, options = {}) { const sessionId = sanitizeId(msg?.sessionId || ''); const entry = activeCodexAppTurns.get(sessionId); @@ -10231,6 +10251,42 @@ function handleCodexAppSteerMessage(ws, msg, options = {}) { message: `已引导对话: ${previewInlineText(textValue)}`, }); }).catch((err) => { + if (isCodexAppNoActiveTurnError(err) && activeCodexAppTurns.get(sessionId) === entry) { + // app-server 已确认旧 turn 不存在:先收敛旧输出,再复用已持久化的消息直接启动新 turn。 + handleCodexAppTurnComplete(sessionId, { + beforeUserMessage: { + timestamp: persistedUserMessage.timestamp, + content: persistedUserMessage.content, + }, + deferPendingCrossConversationFlush: true, + }); + const refreshedSession = loadSession(sessionId); + const restarted = refreshedSession + && isCodexAppSession(refreshedSession) + && !activeCodexAppTurns.has(sessionId) + ? handleCodexAppMessage(ws, refreshedSession, runtimeTextValue, [], { + mcpContext: entry.mcpContext || options.mcpContext || {}, + }) + : null; + if (restarted?.ok) { + wsSend(entry.ws || ws, { + type: 'resume_generating', + sessionId, + text: '', + toolCalls: [], + }); + sendSteerStatus('inserted', '已转为新对话'); + wsSend(entry.ws || ws, { + type: 'system_message', + sessionId, + tone: 'info', + transient: true, + autoDismissMs: 5000, + message: '检测到上一轮已结束,已自动开始新一轮对话。', + }); + return; + } + } sendSteerStatus('failed', '插入失败'); wsSend(entry.ws || ws, { type: 'error',