chore: rebuild release package
This commit is contained in:
79
.planning/conversation-stale-running-fix/findings.md
Normal file
79
.planning/conversation-stale-running-fix/findings.md
Normal file
@@ -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。
|
||||
20
.planning/conversation-stale-running-fix/progress.md
Normal file
20
.planning/conversation-stale-running-fix/progress.md
Normal file
@@ -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` 核验完成,任务闭环。
|
||||
35
.planning/conversation-stale-running-fix/task_plan.md
Normal file
35
.planning/conversation-stale-running-fix/task_plan.md
Normal file
@@ -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。
|
||||
- 代码注释与新增文档使用简体中文。
|
||||
|
||||
## 错误记录
|
||||
|
||||
| 错误 | 尝试 | 处理 |
|
||||
|---|---:|---|
|
||||
Binary file not shown.
@@ -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;
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
|
||||
|
||||
62
server.js
62
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',
|
||||
|
||||
Reference in New Issue
Block a user